├── .editorconfig ├── .gitignore ├── .travis.yml ├── LICENSE.md ├── Package.swift ├── Package@swift-4.0.swift ├── README.md ├── Sources ├── FireAlarmCore │ ├── BackgroundTaskManager.swift │ ├── Filter.swift │ ├── PostFetcher.swift │ ├── PostScanner.swift │ └── Redunda.swift ├── Frontend │ ├── BackgroundTasks.swift │ ├── BlacklistManager.swift │ ├── Bonfire.swift │ ├── Commands │ │ ├── Filters │ │ │ ├── CommandAddSite.swift │ │ │ ├── CommandBlacklist.swift │ │ │ ├── CommandCheckSites.swift │ │ │ ├── CommandCheckThreshold.swift │ │ │ ├── CommandGetBlacklist.swift │ │ │ ├── CommandRemoveSite.swift │ │ │ ├── CommandSetThreshold.swift │ │ │ ├── CommandTestBayesian.swift │ │ │ ├── CommandTestPost.swift │ │ │ ├── CommandUnblacklist.swift │ │ │ ├── TrollCommandBlacklist.swift │ │ │ ├── TrollCommandDisable.swift │ │ │ └── TrollCommandEnable.swift │ │ ├── Reports │ │ │ ├── CommandCheckNotification.swift │ │ │ ├── CommandCheckPost.swift │ │ │ ├── CommandOptIn.swift │ │ │ ├── CommandOptOut.swift │ │ │ ├── CommandReport.swift │ │ │ ├── CommandUnclosed.swift │ │ │ └── CommandWhy.swift │ │ └── Utilities │ │ │ ├── CommandGitStatus.swift │ │ │ ├── CommandHelp.swift │ │ │ ├── CommandLeaveRoom.swift │ │ │ ├── CommandLocation.swift │ │ │ ├── CommandPingOnError.swift │ │ │ ├── CommandQuota.swift │ │ │ ├── CommandStatus.swift │ │ │ ├── CommandUnprivilege.swift │ │ │ └── CommandUpdate.swift │ ├── Filters │ │ ├── BlacklistFilter.swift │ │ ├── FilterCodeWithoutExplanation.swift │ │ ├── FilterImageWithoutCode.swift │ │ ├── FilterLowLength.swift │ │ ├── FilterMisleadingLinks.swift │ │ ├── FilterNaiveBayes.swift │ │ └── FilterNonEnglishPost.swift │ ├── Models │ │ └── Site.swift │ ├── Reporter.swift │ ├── RowDecoder.swift │ ├── RowEncoder.swift │ ├── Secrets.swift │ ├── TrainWrecker.swift │ ├── Utilities.swift │ ├── WebhookHandler.swift │ ├── main.swift │ ├── startup.swift │ └── update.swift └── Run │ └── main.swift ├── Tests ├── .DS_Store ├── FireAlarmTests │ └── FireAlarmTests.swift └── LinuxMain.swift ├── blacklisted_users.json ├── build-nopm.sh ├── build.sh ├── clean.sh ├── filter_static.sqlite.gz ├── firealarm.xcconfig └── project.sh /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | indent_style = tab 4 | indent_size = 4 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | FireAlarm.xcodeproj 3 | FireAlarm.xcworkspace 4 | .DS_Store 5 | Package.pins 6 | Package.resolved 7 | .build 8 | filter.json 9 | blacklisted_*.json 10 | blacklists.json 11 | filter_static.sqlite 12 | redunda_key.txt 13 | secrets.json 14 | room_*_stackoverflow.com.json 15 | room_*_stackexchange.com.json 16 | room_*_meta.stackexchange.com.json 17 | reports.json 18 | location.txt 19 | /Packages 20 | 21 | # Ignore swap files; I use vim. 22 | *.sw[klmnop] 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | branches: 2 | except: 3 | - rpi 4 | - swift3 5 | 6 | language: generic 7 | sudo: required 8 | 9 | os: 10 | - linux 11 | - osx 12 | 13 | dist: trusty 14 | osx_image: xcode9.3 15 | 16 | env: 17 | - SWIFT_VERSION=4.1 18 | 19 | install: 20 | - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then export OPENSSL_ROOT_DIR=$(brew --prefix openssl); fi; 21 | 22 | - eval "$(curl -sL https://swiftenv.fuller.li/install.sh)" 23 | 24 | - echo "Installing libwebsockets..."; 25 | git clone "https://github.com/warmcat/libwebsockets" || exit 3; 26 | pushd libwebsockets || exit 5; 27 | (cmake . && make && sudo make install) || exit 5; 28 | popd; 29 | if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then sudo ldconfig; fi; 30 | 31 | script: 32 | - swift test 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // You can downgrade to an earlier Swift version if needed (5.0 or newer should be OK) 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "FireAlarm", 8 | platforms: [ 9 | .macOS(.v10_16), // You can downgrade to 10.12 or newer if needed 10 | ], 11 | dependencies: [ 12 | .package(url: "git://github.com/SOBotics/SwiftChatSE", from: "5.1.0"), 13 | .package(url: "git://github.com/SOBotics/SwiftStack", from: "0.5.0"), 14 | .package(url: "git://github.com/krzyzanowskim/CryptoSwift", from: "0.9.0") 15 | ], 16 | targets: [ 17 | .target(name: "FireAlarmCore", dependencies: ["SwiftChatSE", "SwiftStack", "CryptoSwift"]), 18 | .target(name: "Frontend", dependencies: ["FireAlarmCore", "SwiftChatSE", "CryptoSwift"]), 19 | .testTarget(name: "FireAlarmTests", dependencies: ["Frontend"]) 20 | ] 21 | ) 22 | -------------------------------------------------------------------------------- /Package@swift-4.0.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:4.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "FireAlarm", 7 | dependencies: [ 8 | .package(url: "git://github.com/SOBotics/SwiftChatSE", from: "5.0.0"), 9 | .package(url: "git://github.com/SOBotics/SwiftStack", from: "0.5.0"), 10 | .package(url: "git://github.com/krzyzanowskim/CryptoSwift", .exact("0.8.3")), 11 | ], 12 | targets: [ 13 | .target(name: "FireAlarm", dependencies: ["SwiftChatSE", "SwiftStack", "CryptoSwift"]), 14 | .testTarget(name: "FireAlarmTests", dependencies: ["FireAlarm"]) 15 | ] 16 | ) 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FireAlarm 2 | 3 | [![Build Status](https://travis-ci.org/SOBotics/FireAlarm.svg?branch=master)](https://travis-ci.org/SOBotics/FireAlarm) 4 | 5 | A Stack Exchange chatbot to catch questions that need closing. Uses [SwiftChatSE](https://github.com/SOBotics/SwiftChatSE) and [SwiftStack](https://github.com/SOBotics/SwiftStack). 6 | 7 | ![Example Chat Post](https://i.stack.imgur.com/Mfmpz.png) 8 | 9 | # License 10 | 11 | Licensed under MIT (https://github.com/SOBotics/FireAlarm/blob/swift/LICENSE.md or https://opensource.org/licenses/MIT) 12 | 13 | # Authors 14 | 15 | - Ashish Ahuja ([StackOverflow](http://stackoverflow.com/users/4688119/ashish-ahuja), [Github](https://github.com/Fortunate-MAN)) 16 | 17 | - NobodyNada ([StackOverflow](http://stackoverflow.com/users/3476191/nobodynada), [Github](https://github.com/NobodyNada)) 18 | -------------------------------------------------------------------------------- /Sources/FireAlarmCore/BackgroundTaskManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackgroundTasks.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 4/18/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | import Dispatch 11 | 12 | public final class BackgroundTask: Hashable { 13 | public private(set) var isCancelled = false 14 | public private(set) var isScheduled = false 15 | 16 | private(set) var manager: BackgroundTaskManager! 17 | 18 | public var run: ((BackgroundTask) -> ())? 19 | 20 | public func onRun(_ block: @escaping (BackgroundTask) -> ()) { 21 | run = block 22 | } 23 | 24 | 25 | 26 | public var interval: TimeInterval? 27 | 28 | public init(interval: TimeInterval? = nil, run: @escaping (BackgroundTask) -> ()) { 29 | self.interval = interval 30 | self.run = run 31 | } 32 | 33 | 34 | 35 | public func schedule(manager: BackgroundTaskManager) { 36 | if isScheduled { return } 37 | self.manager = manager 38 | 39 | isScheduled = true 40 | isCancelled = false 41 | 42 | if let interval = interval { 43 | 44 | manager.queue.asyncAfter(deadline: DispatchTime.now() + interval) { 45 | if self.isCancelled { return } 46 | 47 | self.run?(self) 48 | self.isScheduled = false 49 | 50 | if self.isCancelled { return } 51 | self.schedule(manager: manager) 52 | } 53 | 54 | } else { 55 | manager.queue.async { 56 | if self.isCancelled { return } 57 | 58 | self.run?(self) 59 | self.isScheduled = false 60 | } 61 | } 62 | } 63 | 64 | public func cancel() { 65 | if isCancelled { return } 66 | 67 | isCancelled = true 68 | isScheduled = false 69 | if let manager = manager { 70 | manager.tasks = manager.tasks.filter { $0 != self } 71 | } 72 | } 73 | 74 | public static func ==(lhs: BackgroundTask, rhs: BackgroundTask) -> Bool { 75 | return lhs === rhs 76 | } 77 | 78 | public func hash(into hasher: inout Hasher) { 79 | ObjectIdentifier(self).hash(into: &hasher) 80 | } 81 | } 82 | 83 | open class BackgroundTaskManager { 84 | open var tasks = [BackgroundTask]() { 85 | didSet { 86 | //Schedule all tasks which have not been scheduled. 87 | for task in tasks { 88 | if !task.isScheduled && !task.isCancelled { 89 | task.schedule(manager: self) 90 | } 91 | } 92 | 93 | //Cancel all tasks which were removed from the array. 94 | for task in Set(oldValue).subtracting(tasks) { 95 | task.cancel() 96 | } 97 | } 98 | } 99 | 100 | public let queue = DispatchQueue(label: "Background Tasks", attributes: .concurrent) 101 | 102 | public static var shared = BackgroundTaskManager() 103 | } 104 | -------------------------------------------------------------------------------- /Sources/FireAlarmCore/Filter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Filter.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 9/24/16. 6 | // Copyright © 2016 NobodyNada. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | import SwiftStack 12 | import Dispatch 13 | 14 | public extension Post { 15 | var id: Int? { 16 | if let q = self as? Question, let id = q.question_id { 17 | return id 18 | } else if let a = self as? Answer, let id = a.answer_id { 19 | return id 20 | } else { 21 | return post_id 22 | } 23 | } 24 | } 25 | 26 | public struct FilterResult { 27 | public enum ResultType { 28 | case bayesianFilter(difference: Int) 29 | case customFilter(filter: Filter) 30 | case customFilterWithWeight(filter: Filter, weight: Int) 31 | case manuallyReported 32 | } 33 | 34 | public let type: ResultType 35 | public let header: String 36 | public let details: String? 37 | 38 | public init(type: ResultType, header: String, details: String? = nil) { 39 | self.type = type 40 | self.header = header 41 | self.details = details 42 | } 43 | } 44 | 45 | public protocol Filter: class { 46 | func check(post: Post, site: String) throws -> FilterResult? 47 | func save() throws 48 | } 49 | -------------------------------------------------------------------------------- /Sources/FireAlarmCore/PostFetcher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostFetcher.swift 3 | // FireAlarm 4 | // 5 | // Created by Ashish Ahuja on 24/04/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | import SwiftStack 12 | import Dispatch 13 | 14 | open class PostFetcher { 15 | open var postsToCheck = [String:[Int]]() // Posts by apiSiteParameter 16 | 17 | public let queue = DispatchQueue(label: "Filter") 18 | 19 | 20 | private var ws: WebSocket! 21 | private var wsRetries: Int 22 | private let wsMaxRetries: Int 23 | 24 | public var apiClient: APIClient 25 | 26 | /// A callback that recieves a post and its apiSiteParameter. 27 | open var callback: (Post, String) throws -> () 28 | 29 | open var shouldFetchAnswers = false 30 | 31 | public private(set) var running: Bool 32 | 33 | private var lastEventDate: Date? 34 | 35 | public init (apiClient: APIClient, callback: @escaping (Post, String) throws -> ()) { 36 | wsRetries = 0 37 | wsMaxRetries = 10 38 | running = false 39 | 40 | self.apiClient = apiClient 41 | self.callback = callback 42 | } 43 | 44 | public enum QuestionProcessingError: Error { 45 | case textNotUTF8(text: String) 46 | 47 | case jsonNotDictionary(json: String) 48 | case jsonParsingError(json: String, error: String) 49 | case noDataObject(json: String) 50 | case noQuestionID(json: String) 51 | case noSite(json: String) 52 | case noSiteBaseHostAddress(json: String) 53 | 54 | case siteLookupFailed(siteID: Int) 55 | } 56 | 57 | open func checkStackOverflow() { 58 | Thread.detachNewThread { 59 | var lastCheck = Date() 60 | while true { 61 | do { 62 | let wakeTime = lastCheck.addingTimeInterval(60) 63 | lastCheck = Date() 64 | Thread.sleep(until: wakeTime) 65 | 66 | if !self.running { 67 | return 68 | } 69 | 70 | var hasMore = true 71 | var page = 1 72 | var posts: [Post] = [] 73 | while hasMore { 74 | let response = try self.queue.sync { try self.apiClient.fetchQuestions(parameters: [ 75 | "pagesize": "100", 76 | "page": "\(page)", 77 | "fromdate": String(Int(lastCheck.timeIntervalSince1970)), 78 | "site": "stackoverflow" 79 | ])} 80 | hasMore = response.has_more ?? false 81 | page += 1 82 | posts.append(contentsOf: response.items ?? []) 83 | } 84 | 85 | let questionIDs = posts.compactMap { $0.id } 86 | 87 | // now fetch answers 88 | hasMore = true 89 | page = 1 90 | while hasMore { 91 | let response = try self.queue.sync { try self.apiClient.fetchAnswersOn( 92 | questions: questionIDs, 93 | parameters: [ 94 | "pagesize": "100", 95 | "page": "\(page)", 96 | "site": "stackoverflow" 97 | ])} 98 | hasMore = response.has_more ?? false 99 | page += 1 100 | posts.append(contentsOf: response.items ?? []) 101 | } 102 | 103 | self.queue.sync { 104 | posts.forEach { post in 105 | do { 106 | //don't report posts that are more than a day old 107 | let creation = (post.creation_date ?? Date()).timeIntervalSinceReferenceDate 108 | let activity = (post.last_activity_date ?? Date()).timeIntervalSinceReferenceDate 109 | 110 | if creation > (activity - 60 * 60 * 24) { 111 | try self.callback(post, "stackoverflow") 112 | } 113 | } catch { 114 | handleError(error, "while processing a post") 115 | } 116 | } 117 | } 118 | } catch { 119 | handleError(error, "while fetching questions on Stack Overflow") 120 | } 121 | } 122 | } 123 | } 124 | 125 | open func doCheckPosts() { 126 | Thread.detachNewThread { 127 | while true { 128 | do { 129 | // Wait 60 seconds due to API caching. 130 | let posts = self.postsToCheck 131 | sleep(60) 132 | if !self.running { 133 | return 134 | } 135 | 136 | guard !posts.isEmpty else { 137 | continue 138 | } 139 | 140 | // Remove the posts we're checking now from the list of postsToCheck. 141 | for site in self.postsToCheck.keys { 142 | let postsToCheckNow = posts[site] ?? [] 143 | self.postsToCheck[site] = self.postsToCheck[site]?.filter { post in 144 | !postsToCheckNow.contains(post) 145 | } 146 | } 147 | 148 | for (site, posts) in posts { 149 | if posts.isEmpty { continue } 150 | var fetchedPosts = [Post]() 151 | 152 | var hasMore = true 153 | var page = 1 154 | while hasMore { 155 | let response = try self.queue.sync { try self.apiClient.fetchQuestions( 156 | posts, parameters: [ 157 | "pagesize": "100", 158 | "page": "\(page)", 159 | "site": site 160 | ])} 161 | hasMore = response.has_more ?? false 162 | page += 1 163 | fetchedPosts.append(contentsOf: response.items ?? []) 164 | } 165 | 166 | let questionIDs = fetchedPosts.compactMap { $0.id } 167 | 168 | // now fetch answers 169 | hasMore = true 170 | page = 1 171 | while hasMore { 172 | let response = try self.queue.sync { try self.apiClient.fetchAnswersOn( 173 | questions: questionIDs, 174 | parameters: [ 175 | "pagesize": "100", 176 | "page": "\(page)", 177 | "site": site 178 | ])} 179 | hasMore = response.has_more ?? false 180 | page += 1 181 | fetchedPosts.append(contentsOf: response.items ?? []) 182 | } 183 | 184 | self.queue.sync { 185 | fetchedPosts.forEach { post in 186 | do { 187 | //don't report posts that are more than a day old 188 | let creation = (post.creation_date ?? Date()).timeIntervalSinceReferenceDate 189 | let activity = (post.last_activity_date ?? Date()).timeIntervalSinceReferenceDate 190 | 191 | if creation > (activity - 60 * 60 * 24) { 192 | try self.callback(post, site) 193 | } 194 | } catch { 195 | handleError(error, "while processing a post") 196 | } 197 | } 198 | } 199 | } 200 | } catch { 201 | handleError(error, "while fetching posts") 202 | } 203 | } 204 | } 205 | } 206 | 207 | open func start() throws { 208 | running = true 209 | 210 | //let request = URLRequest(url: URL(string: "ws://qa.sockets.stackexchange.com/")!) 211 | //ws = WebSocket(request: request) 212 | //ws.eventQueue = room.client.queue 213 | //ws.delegate = self 214 | //ws.open() 215 | ws = try WebSocket.open("wss://qa.sockets.stackexchange.com/") 216 | 217 | ws.onOpen {socket in 218 | self.webSocketOpen() 219 | } 220 | ws.onText {socket, text in 221 | self.webSocketMessageText(text) 222 | } 223 | ws.onBinary {socket, data in 224 | self.webSocketMessageData(data) 225 | } 226 | ws.onClose {socket in 227 | self.webSocketClose(0, reason: "", wasClean: true) 228 | self.webSocketEnd(0, reason: "", wasClean: true, error: socket.error) 229 | } 230 | ws.onError {socket in 231 | self.webSocketEnd(0, reason: "", wasClean: true, error: socket.error) 232 | } 233 | 234 | doCheckPosts() 235 | } 236 | 237 | open func stop() { 238 | running = false 239 | ws?.disconnect() 240 | } 241 | 242 | private func webSocketOpen() { 243 | print("Listening to active questions!") 244 | wsRetries = 0 245 | ws.write("155-questions-active") 246 | 247 | Thread.detachNewThread { 248 | while true { 249 | sleep(600) 250 | if (Date().timeIntervalSinceReferenceDate - 251 | (self.lastEventDate?.timeIntervalSinceReferenceDate ?? 0)) > 600 { 252 | 253 | self.ws?.disconnect() 254 | } 255 | return 256 | } 257 | } 258 | } 259 | 260 | private func webSocketClose(_ code: Int, reason: String, wasClean: Bool) { 261 | //do nothing -- we'll handle this in webSocketEnd 262 | } 263 | 264 | private func webSocketError(_ error: NSError) { 265 | //do nothing -- we'll handle this in webSocketEnd 266 | } 267 | 268 | private func webSocketMessageText(_ text: String) { 269 | do { 270 | guard let data = text.data(using: .utf8) else { 271 | throw QuestionProcessingError.textNotUTF8(text: text) 272 | } 273 | webSocketMessageData(data) 274 | } catch { 275 | handleError(error, "while processing an active question") 276 | } 277 | } 278 | 279 | private func webSocketMessageData(_ data: Data) { 280 | lastEventDate = Date() 281 | let string = String(data: data, encoding: .utf8) ?? "" 282 | do { 283 | 284 | do { 285 | guard let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String:String] else { 286 | throw QuestionProcessingError.jsonNotDictionary(json: string) 287 | } 288 | 289 | guard json["action"] == "155-questions-active" else { 290 | if json["action"] == "hb" { 291 | //heartbeat 292 | ws.write("{\"action\":\"hb\",\"data\":\"hb\"}") 293 | } 294 | return 295 | } 296 | 297 | guard let dataObject = json["data"]?.data(using: .utf8) else { 298 | throw QuestionProcessingError.noDataObject(json: string) 299 | } 300 | 301 | guard let data = try JSONSerialization.jsonObject(with: dataObject, options: []) as? [String:Any] else { 302 | throw QuestionProcessingError.noDataObject(json: string) 303 | } 304 | 305 | guard let id = data["id"] as? Int else { 306 | throw QuestionProcessingError.noQuestionID(json: string) 307 | } 308 | 309 | guard let apiSiteParameter = data["apiSiteParameter"] as? String else { 310 | throw QuestionProcessingError.noSite(json: string) 311 | } 312 | 313 | if apiSiteParameter != "stackoverflow" { // SO questions are handled seperately 314 | postsToCheck[apiSiteParameter, default: []].append(id) 315 | } 316 | 317 | } catch { 318 | if let e = errorAsNSError(error) { 319 | throw QuestionProcessingError.jsonParsingError(json: string, error: formatNSError(e)) 320 | } else { 321 | throw QuestionProcessingError.jsonParsingError(json: string, error: String(describing: error)) 322 | } 323 | } 324 | } 325 | catch { 326 | handleError(error, "while processing an active question") 327 | } 328 | } 329 | 330 | 331 | private func attemptReconnect() { 332 | var done = false 333 | repeat { 334 | do { 335 | if wsRetries >= wsMaxRetries { 336 | fatalError( 337 | "Realtime questions websocket died; failed to reconnect! Active posts will not be reported until a reboot. \(ping)" 338 | ) 339 | } 340 | wsRetries += 1 341 | try ws.connect() 342 | done = true 343 | } catch { 344 | done = false 345 | sleep(5) 346 | } 347 | } while !done 348 | } 349 | 350 | private func webSocketEnd(_ code: Int, reason: String, wasClean: Bool, error: Error?) { 351 | if let e = error { 352 | print("Websocket error:\n\(e)") 353 | } 354 | else { 355 | print("Websocket closed") 356 | } 357 | 358 | if running { 359 | print("Trying to reconnect...") 360 | attemptReconnect() 361 | } 362 | } 363 | } 364 | -------------------------------------------------------------------------------- /Sources/FireAlarmCore/PostScanner.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostScanner.swift 3 | // FireAlarmCore 4 | // 5 | // Created by NobodyNada on 9/22/18. 6 | // 7 | 8 | import Foundation 9 | import SwiftStack 10 | 11 | open class PostScanner { 12 | open var filters: [Filter] 13 | 14 | public init(filters: [Filter]) { 15 | self.filters = filters 16 | } 17 | 18 | open func scan(post: Post, site: String) throws -> [FilterResult] { 19 | return try filters.compactMap { try $0.check(post: post, site: site) } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/FireAlarmCore/Redunda.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Redunda.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 3/23/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | import Dispatch 12 | import CryptoSwift 13 | 14 | open class Redunda { 15 | public enum RedundaError: Error { 16 | case invalidJSON(json: Any) 17 | case downloadFailed(status: Int) 18 | case uploadFailed(status: Int) 19 | } 20 | 21 | 22 | public struct Event { 23 | public let name: String 24 | public let headers: [String:String] 25 | public let content: String 26 | 27 | public func contentAsJSON() throws -> Any { 28 | return try JSONSerialization.jsonObject(with: content.data(using: .utf8)!) 29 | } 30 | 31 | public init(json: [String:Any]) throws { 32 | guard let name = json["name"] as? String, 33 | let headers = json["headers"] as? [String:String], 34 | let content = json["content"] as? String 35 | else { throw RedundaError.invalidJSON(json: json) } 36 | 37 | self.name = name 38 | self.headers = headers 39 | self.content = content 40 | } 41 | } 42 | 43 | public let key: String 44 | public let client: Client 45 | public let filesToSync: [String] //An array of regexes. 46 | 47 | 48 | open func downloadFile(named name: String) throws { 49 | print("Downloading \(name).") 50 | 51 | let (data, response) = try client.get("https://redunda.sobotics.org/bots/data/\(name)?key=\(key)") 52 | 53 | guard response.statusCode == 200 else { 54 | throw RedundaError.downloadFailed(status: response.statusCode) 55 | } 56 | 57 | try data.write(to: 58 | URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(name) 59 | ) 60 | } 61 | 62 | open func uploadFile(named name: String) throws { 63 | print("Uploading \(name).") 64 | 65 | let data = try Data(contentsOf: 66 | URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(name) 67 | ) 68 | 69 | let (_, response) = try client.post( 70 | "https://redunda.sobotics.org/bots/data/\(name)?key=\(key)", 71 | data: data, contentType: "application/octet-stream" 72 | ) 73 | 74 | guard response.statusCode < 400 else { 75 | throw RedundaError.uploadFailed(status: response.statusCode) 76 | } 77 | } 78 | 79 | 80 | open func hash(of file: String) throws -> String { 81 | return try Data(contentsOf: URL(fileURLWithPath: file)).sha256().toHexString() 82 | } 83 | 84 | ///Downloads modified files from Redunda. 85 | ///- Warning: 86 | ///Do not post non-`RedundaError`s to chat; they may contain the instance key! 87 | open func downloadFiles() throws { 88 | let response = try client.parseJSON(client.get("https://redunda.sobotics.org/bots/data.json?key=\(key)")) 89 | 90 | guard let json = response as? [[String:Any]] else { 91 | throw RedundaError.invalidJSON(json: response) 92 | } 93 | 94 | let manager = FileManager.default 95 | 96 | for item in json { 97 | guard let filename = item["key"] as? String else { 98 | throw RedundaError.invalidJSON(json: response) 99 | } 100 | if manager.fileExists(atPath: filename) { 101 | if try hash(of: filename) != item["sha256"] as? String { 102 | try downloadFile(named: filename) 103 | } 104 | } else { 105 | try downloadFile(named: filename) 106 | } 107 | } 108 | } 109 | 110 | ///Downloads modified files from Redunda. 111 | ///- Warning: 112 | ///Do not post non-`RedundaError`s to chat; they may contain the instance key! 113 | open func uploadFiles() throws { 114 | let response = try client.parseJSON(client.get("https://redunda.sobotics.org/bots/data.json?key=\(key)")) 115 | 116 | guard let json = response as? [[String:Any]] else { 117 | throw RedundaError.invalidJSON(json: response) 118 | } 119 | 120 | let manager = FileManager.default 121 | 122 | for filename in try manager.contentsOfDirectory(atPath: ".") { 123 | for regex in filesToSync { 124 | guard filename.range(of: regex, options: [.regularExpression]) != nil else { 125 | continue 126 | } 127 | 128 | if let index = json.firstIndex(where: { filename == $0["key"] as? String }) { 129 | if try hash(of: filename) != json[index]["sha256"] as? String { 130 | try uploadFile(named: filename) 131 | } 132 | } else { 133 | try uploadFile(named: filename) 134 | } 135 | } 136 | } 137 | } 138 | 139 | public init(key: String, client: Client, filesToSync: [String] = []) { 140 | self.key = key 141 | self.client = client 142 | self.filesToSync = filesToSync 143 | } 144 | 145 | open var shouldStandby: Bool = false 146 | open var locationName: String? 147 | 148 | //The number of unread events. 149 | open var eventCount: Int = 0 150 | 151 | open func fetchEvents() throws -> [Event] { 152 | let json = try client.parseJSON( 153 | try client.post( 154 | "https://redunda.sobotics.org/events.json", 155 | ["key":key] 156 | ) 157 | ) 158 | 159 | guard let events = try (json as? [[String:Any]])?.map(Event.init) else { 160 | throw RedundaError.invalidJSON(json: json) 161 | } 162 | 163 | eventCount = 0 164 | return events 165 | } 166 | 167 | open func sendStatusPing(version: String? = nil) throws { 168 | let data = version == nil ? ["key":key] : ["key":key, "version":version!] 169 | let response = try client.parseJSON(try client.post("https://redunda.sobotics.org/status.json", data)) 170 | guard let json = response as? [String:Any] else { 171 | throw RedundaError.invalidJSON(json: response) 172 | } 173 | 174 | guard let standby = json["should_standby"] as? Bool else { 175 | throw RedundaError.invalidJSON(json: response) 176 | } 177 | 178 | guard let eventCount = json["event_count"] as? Int else { 179 | throw RedundaError.invalidJSON(json: response) 180 | } 181 | 182 | shouldStandby = standby 183 | locationName = json["location"] as? String 184 | self.eventCount = eventCount 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /Sources/Frontend/BackgroundTasks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackgroundTasks.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 4/18/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | import FireAlarmCore 12 | 13 | func save(rooms: [ChatRoom]) { 14 | do { 15 | try rooms.forEach { try $0.saveUserDB() } 16 | } catch { 17 | handleError(error, "while saving the user database") 18 | } 19 | 20 | for filter in reporter.postScanner.filters { 21 | do { 22 | try filter.save() 23 | } catch { 24 | handleError(error, "while saving \(type(of: filter))") 25 | } 26 | } 27 | 28 | do { 29 | try reporter.saveReports() 30 | } catch { 31 | handleError(error, "while saving reports") 32 | } 33 | 34 | do { 35 | try reporter.blacklistManager.save(url: saveDirURL.appendingPathComponent("blacklists.json")) 36 | } catch { 37 | handleError(error, "while saving blacklists") 38 | } 39 | 40 | do { 41 | try redunda?.uploadFiles() 42 | } catch { 43 | print("Could not upload files!") 44 | print(error) 45 | } 46 | } 47 | 48 | 49 | func handleInput(input: String, rooms: [ChatRoom], listener: ChatListener) { 50 | var messageContent = input 51 | 52 | guard let firstComponent = input.components(separatedBy: .whitespaces).first else { 53 | return 54 | } 55 | 56 | let room: ChatRoom 57 | if firstComponent.hasPrefix(">") { 58 | let roomIDStr = firstComponent[firstComponent.index(after: firstComponent.startIndex)...] 59 | guard let roomID = Int(roomIDStr) else { 60 | print("Invalid room ID.") 61 | return 62 | } 63 | 64 | guard let roomIndex = rooms.firstIndex(where: { roomID == $0.roomID }) else { 65 | print("I'm not in that room.") 66 | return 67 | } 68 | 69 | room = rooms[roomIndex] 70 | 71 | messageContent = input.components(separatedBy: .whitespaces).dropFirst().joined(separator: " ") 72 | } else { 73 | room = rooms.first! 74 | } 75 | 76 | listener.processMessage( 77 | room, 78 | message: ChatMessage( 79 | room: room, 80 | user: room.userWithID(0), 81 | content: messageContent, 82 | id: nil 83 | ), 84 | isEdit: false 85 | ) 86 | } 87 | 88 | let maxRedundaErrors = 3 89 | var redundaErrorCount = 0 90 | 91 | 92 | func scheduleBackgroundTasks(rooms: [ChatRoom], listener: ChatListener) { 93 | BackgroundTaskManager.shared.tasks = [ 94 | //Save 95 | BackgroundTask(interval: 60) {task in 96 | save(rooms: rooms) 97 | }, 98 | 99 | 100 | //Watch for input 101 | BackgroundTask() { task in 102 | repeat { 103 | if let input = readLine() { 104 | handleInput(input: input, rooms: rooms, listener: listener) 105 | } else { 106 | //if EOF is reached, 107 | return 108 | } 109 | } while !task.isCancelled 110 | }, 111 | 112 | BackgroundTask(interval: 1) { task in 113 | do { 114 | let manager = FileManager.default 115 | let file = "input.txt" 116 | if manager.fileExists(atPath: file) { 117 | let input = String( 118 | data: try Data(contentsOf: saveURL.appendingPathComponent(file)), 119 | encoding: .utf8 120 | )!.trimmingCharacters(in: .whitespacesAndNewlines) 121 | 122 | try manager.removeItem(atPath: file) 123 | 124 | handleInput(input: input, rooms: rooms, listener: listener) 125 | } 126 | } catch { 127 | handleError(error, "while monitoring input.txt") 128 | } 129 | }, 130 | 131 | 132 | //Ping Redunda 133 | BackgroundTask(interval: 30) {task in 134 | guard let r = redunda else { task.cancel(); return } 135 | 136 | let webhookHandler: WebhookHandler? 137 | var ciVersion: String? 138 | var updateVersion: String? 139 | if let secret = secrets.githubWebhookSecret { 140 | webhookHandler = WebhookHandler(githubSecret: secret) 141 | webhookHandler!.onSuccess {repo, branches, commit in 142 | if repo == "SOBotics/FireAlarm" && branches.contains(updateBranch) { 143 | ciVersion = commit 144 | } 145 | } 146 | webhookHandler!.onUpdate { commit in 147 | updateVersion = commit 148 | } 149 | } else { webhookHandler = nil } 150 | 151 | do { 152 | if getShortVersion(currentVersion) == "" { 153 | try r.sendStatusPing() 154 | } else { 155 | try r.sendStatusPing(version: getShortVersion(currentVersion)) 156 | } 157 | 158 | if r.eventCount != 0 { 159 | for event in try r.fetchEvents() { 160 | try webhookHandler?.process(event: event, rooms: [rooms.first!]) 161 | } 162 | } 163 | if r.shouldStandby { 164 | rooms.first!.postMessage("[ [\(botName)](\(githubLink)) ] Switching to standby mode on \(location).") 165 | 166 | listener.stop(.reboot) 167 | } 168 | 169 | if redundaErrorCount >= maxRedundaErrors { 170 | rooms.first!.postMessage("[ [\(botName)](\(githubLink)) ] Redunda appears to be back up (cc @NobodyNada).") 171 | } 172 | redundaErrorCount = 0 173 | 174 | if let commit = ciVersion ?? updateVersion { 175 | _ = update(to: commit, listener: listener, rooms: [rooms.first!]) 176 | } 177 | } catch { 178 | redundaErrorCount += 1 179 | if redundaErrorCount == maxRedundaErrors { 180 | rooms.first!.postMessage("[ [\(botName)](\(githubLink)) ] Redunda appears to be down; silencing errors (cc @NobodyNada).") 181 | } else if redundaErrorCount < maxRedundaErrors { 182 | handleError(error, "while sending a status ping to Redunda") 183 | } 184 | } 185 | } 186 | ] 187 | } 188 | -------------------------------------------------------------------------------- /Sources/Frontend/BlacklistManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BlacklistManager.swift 3 | // FireAlarm 4 | // 5 | // Created by Jonathan Keller on 10/18/17. 6 | // 7 | 8 | import Foundation 9 | 10 | class Blacklist: Codable { 11 | var items: [String] 12 | 13 | func encode(to encoder: Encoder) throws { 14 | try items.encode(to: encoder) 15 | } 16 | 17 | required init(from decoder: Decoder) throws { 18 | items = try [String](from: decoder) 19 | } 20 | 21 | required init() { 22 | items = [] 23 | } 24 | 25 | func items(catching string: String) -> [String] { 26 | return items.filter { string.range(of: $0, options: [.regularExpression, .caseInsensitive]) != nil } 27 | } 28 | 29 | ///Adds an item to the blacklist. 30 | ///- returns: `true` if the item was added; `false` if it was already present. 31 | @discardableResult func add(item: String) -> Bool { 32 | if items.contains(item) { return false } 33 | items.append(item) 34 | return true 35 | } 36 | 37 | ///Removes an item from the blacklist. 38 | ///- returns: `true` if the item was removed; `false` if it was not present. 39 | @discardableResult func remove(item: String) -> Bool { 40 | guard let index = items.firstIndex(of: item) else { return false } 41 | items.remove(at: index) 42 | return true 43 | } 44 | } 45 | 46 | class BlacklistManager { 47 | private var blacklists: [String:Blacklist] 48 | 49 | init(blacklists: [String:Blacklist] = [:]) { 50 | self.blacklists = blacklists 51 | } 52 | 53 | convenience init(json: Data) throws { 54 | self.init(blacklists: try JSONDecoder().decode([String:Blacklist].self, from: json)) 55 | } 56 | 57 | convenience init(url: URL) throws { 58 | try self.init(json: Data(contentsOf: url)) 59 | } 60 | 61 | func save(url: URL) throws { 62 | try JSONEncoder().encode(blacklists).write(to: url) 63 | } 64 | 65 | enum BlacklistType: String { 66 | case username 67 | case keyword 68 | case tag 69 | 70 | init?(name: String) { 71 | if let result = BlacklistType.init(rawValue: name) { 72 | self = result 73 | } else if let result = BlacklistType.init(rawValue: name + "s") { 74 | self = result 75 | } else if name.last == "s", let result = BlacklistType.init(rawValue: String(name.dropLast())) { 76 | self = result 77 | } else { 78 | return nil 79 | } 80 | } 81 | } 82 | 83 | func blacklist(ofType type: BlacklistType) -> Blacklist { 84 | if let blacklist = blacklists[type.rawValue] { 85 | return blacklist 86 | } else { 87 | let blacklist = Blacklist() 88 | self.blacklists[type.rawValue] = blacklist 89 | return blacklist 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/Frontend/Bonfire.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bonfire.swift 3 | // FireAlarm 4 | // 5 | // Created by Ashish Ahuja on 14/04/18. 6 | // 7 | 8 | import Foundation 9 | import Dispatch 10 | import SwiftChatSE 11 | import SwiftStack 12 | 13 | class Bonfire { 14 | enum BonfireError: Error { 15 | case postCreationFailed(status: Int) 16 | case invalidJSON(json: Any) 17 | } 18 | 19 | let key: String 20 | let client: Client 21 | let host: String 22 | 23 | func getPostLink(_ bonfire_post_id: Int) -> String { 24 | return self.host + "/posts/\(bonfire_post_id)" 25 | } 26 | 27 | func uploadPost(post: Post, postDetails: String?, likelihood: Int) throws -> String { 28 | //TODO: Looks like SwiftStack does not have a 'creation_date' in the Post class; add it. Currently just using last activity date. 29 | let dateFormatter = DateFormatter() 30 | dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" 31 | let creation_date = dateFormatter.string(from: post.last_activity_date!) 32 | 33 | var reasonList = [String]() 34 | let reasons = postDetails!.components(separatedBy: ", ") 35 | for reason in reasons { 36 | if reason.range(of: "manually reported") != nil { 37 | reasonList.append("Manually Reported") 38 | } else if reason.range(of: "Naive Bayes") != nil { 39 | reasonList.append("Naive Bayes") 40 | } else { 41 | reasonList.append(reason) 42 | } 43 | } 44 | 45 | let postDict: [String:String?] = [ 46 | "title": post.title, 47 | "body": post.body, 48 | "question_id": String(post.id ?? -1), 49 | "likelihood": String(likelihood), 50 | "link": post.link?.absoluteString, 51 | "username": post.owner?.display_name, 52 | "user_reputation": String(post.owner?.reputation ?? 1), 53 | "user_link": post.owner?.link?.absoluteString, 54 | "post_creation_date": creation_date 55 | ] 56 | 57 | let dataDict = [ 58 | "authorization": self.key, 59 | "site": post.link?.absoluteString.components(separatedBy: "/q")[0] ?? "https://stackoverflow.com", 60 | "reasons": reasonList, 61 | "post": postDict 62 | ] as [String : Any] 63 | 64 | let jsonData = try JSONSerialization.data(withJSONObject: dataDict) 65 | 66 | let response = try client.parseJSON(try client.post(self.host + "/posts/new", data: jsonData, contentType: "application/json")) 67 | 68 | guard let json = response as? [String: Any] else { 69 | throw BonfireError.invalidJSON(json: response) 70 | } 71 | 72 | guard let statusCode = json["code"] as? String else { 73 | throw BonfireError.invalidJSON(json: response) 74 | } 75 | 76 | if statusCode != "200" { 77 | throw BonfireError.postCreationFailed(status: Int(statusCode)!) 78 | } 79 | 80 | guard let data = json["data"] as? [String: Any] else { 81 | throw BonfireError.invalidJSON(json: response) 82 | } 83 | 84 | guard let postID = data["post_id"] as? Int else { 85 | throw BonfireError.invalidJSON(json: response) 86 | } 87 | 88 | return getPostLink(postID) 89 | } 90 | 91 | init(key: String, client: Client, host: String = "http://localhost:3000") { 92 | self.key = key 93 | self.client = client 94 | self.host = host 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Filters/CommandAddSite.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandAddSite.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 6/1/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | 12 | class CommandAddSite: Command { 13 | override class func usage() -> [String] { 14 | return ["add site * *", "add site * threshold *"] 15 | } 16 | 17 | override class func privileges() -> ChatUser.Privileges { 18 | return .owner 19 | } 20 | 21 | override func run() throws { 22 | guard arguments.count == 2 else { 23 | reply("Please enter a site and a threshold.") 24 | return 25 | } 26 | 27 | let siteName = arguments[0] 28 | guard let threshold = Int(arguments[1]) else { 29 | reply("Please enter a valid threshold.") 30 | return 31 | } 32 | 33 | guard let site = try reporter.staticDB.run( 34 | "SELECT * FROM sites WHERE domain = ? OR apiSiteParameter = ?", 35 | siteName, siteName 36 | ).first.map(Site.from) else { 37 | 38 | reply("That does not look like a site on which I run.") 39 | return 40 | } 41 | 42 | message.room.thresholds[site.id] = threshold 43 | 44 | reply("Added \(site.domain) to this room's sites with threshold \(threshold).") 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Filters/CommandBlacklist.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandBlacklist.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 7/15/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | 12 | class CommandBlacklist: Command { 13 | public class func blacklistManager(reporter: Reporter) -> BlacklistManager { 14 | return reporter.blacklistManager 15 | } 16 | 17 | override class func usage() -> [String] { 18 | return ["blacklist ...", "blacklist ..."] 19 | } 20 | 21 | override class func privileges() -> ChatUser.Privileges { 22 | return .filter 23 | } 24 | 25 | 26 | override func run() throws { 27 | guard arguments.count >= 2, 28 | let listName = arguments.first, 29 | let list = BlacklistManager.BlacklistType(rawValue: listName) else { 30 | reply("Usage: `blacklist `") 31 | return 32 | } 33 | let regex = arguments.dropFirst().joined(separator: " ") 34 | if type(of: self).blacklistManager(reporter: reporter).blacklist(ofType: list).add(item: regex) == false { 35 | reply("`\(regex) was already on the \(list.rawValue) blacklist.") 36 | } else { 37 | reply("Added regular expression `\(regex)` to the \(list.rawValue) blacklist.") 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Filters/CommandCheckSites.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandCheckSites.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 6/1/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | 12 | class CommandCheckSites: Command { 13 | override class func usage() -> [String] { 14 | return ["sites", "check sites", "get sites", "current sites"] 15 | } 16 | 17 | override func run() throws { 18 | if message.room.thresholds.isEmpty { 19 | reply("This room does not report any posts.") 20 | 21 | return 22 | } 23 | 24 | let siteNames: [String] = try message.room.thresholds.keys.map { 25 | try Site.with(id: $0, db: reporter.staticDB)?.domain ?? "" 26 | } 27 | 28 | reply("This room reports posts from \(formatArray(siteNames, conjunction: "and")).") 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Filters/CommandCheckThreshold.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandCheckThreshold.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 2/9/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | 12 | class CommandCheckThreshold: Command { 13 | override class func usage() -> [String] { 14 | return ["threshold", "check threshold", "get threshold", "current threshold", 15 | "thresholds","check thresholds","get thresholds","current thresholds" 16 | ] 17 | } 18 | 19 | override func run() throws { 20 | if message.room.thresholds.isEmpty { 21 | reply("This room does not report any posts.") 22 | } else if message.room.thresholds.count == 1 { 23 | let threshold = message.room.thresholds.first!.value 24 | reply( 25 | "The threshold for this room is \(threshold) (*higher* thresholds report more posts)." 26 | ) 27 | } else { 28 | let siteNames: [String] = try message.room.thresholds.keys.map { 29 | try Site.with(id: $0, db: reporter.staticDB)?.domain ?? "" 30 | } 31 | let siteThresholds = Array(message.room.thresholds.values.map(String.init)) 32 | 33 | reply("The thresholds for this room are: (*higher* thresholds report more posts)") 34 | post(makeTable(["Site", "Threshold"], contents: siteNames, siteThresholds)) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Filters/CommandGetBlacklist.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandGetBlacklist.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 7/15/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | 12 | class CommandGetBlacklist: Command { 13 | public class func blacklistManager(reporter: Reporter) -> BlacklistManager { 14 | return reporter.blacklistManager 15 | } 16 | 17 | override class func usage() -> [String] { 18 | return ["blacklisted *"] 19 | } 20 | 21 | override func run() throws { 22 | guard let listName = arguments.first, 23 | let listType = BlacklistManager.BlacklistType(name: listName) else { 24 | reply("Usage: `blacklisted `") 25 | return 26 | } 27 | 28 | let list = type(of: self).blacklistManager(reporter: reporter).blacklist(ofType: listType) 29 | if list.items.count == 0 { 30 | reply("No \(listType.rawValue)s are blacklisted.") 31 | return 32 | } 33 | reply("Blacklisted \(listType.rawValue)s:") 34 | post(" " + (list.items.joined(separator: "\n "))) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Filters/CommandRemoveSite.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandRemoveSite.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 6/1/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | 12 | class CommandRemoveSite: Command { 13 | override class func usage() -> [String] { 14 | return ["remove site *"] 15 | } 16 | 17 | override class func privileges() -> ChatUser.Privileges { 18 | return .owner 19 | } 20 | 21 | override func run() throws { 22 | guard 23 | let siteName = arguments.first, 24 | let site = try reporter.staticDB.run( 25 | "SELECT * FROM sites WHERE domain = ? OR apiSiteParameter = ?", 26 | siteName, siteName 27 | ).first.map(Site.from), 28 | message.room.thresholds[site.id] != nil else { 29 | 30 | reply("Please enter a site and a threshold.") 31 | return 32 | } 33 | 34 | message.room.thresholds[site.id] = nil 35 | 36 | reply("Removed \(site.domain) from this room's sites.") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Filters/CommandSetThreshold.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandSetThreshold.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 2/9/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | 12 | class CommandSetThreshold: Command { 13 | override public class func usage() -> [String] { 14 | return [ 15 | "set threshold to * for *", 16 | "set threshold to *", 17 | "change threshold to * for *", 18 | "change threshold to *", 19 | "set threshold ...", 20 | "change threshold ..." 21 | ] 22 | } 23 | 24 | override public class func privileges() -> ChatUser.Privileges { 25 | return .filter 26 | } 27 | 28 | override func run() throws { 29 | guard let newThreshold = (arguments.first.map({Int($0)}) ?? nil) else { 30 | message.reply("Please specify a valid threshold.") 31 | return 32 | } 33 | 34 | if arguments.count < 2 { 35 | if message.room.thresholds.count == 1 { 36 | message.room.thresholds[message.room.thresholds.first!.key] = newThreshold 37 | } else { 38 | message.reply("Which site would you like to change threshold for?") 39 | } 40 | } else { 41 | guard let site = try reporter.staticDB.run("SELECT * FROM sites " + 42 | "WHERE apiSiteParameter = ? OR domain = ?", 43 | arguments.last!, arguments.last! 44 | ).first.map(Site.from) else { 45 | 46 | message.reply("That does not look like a site on which I run.") 47 | return 48 | } 49 | guard message.room.thresholds[site.id] != nil else { 50 | message.reply("I do not report posts from that site here.") 51 | return 52 | } 53 | 54 | message.room.thresholds[site.id] = newThreshold 55 | reply("Set threshold for `\(site.domain)` to \(newThreshold).") 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Filters/CommandTestBayesian.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandTestBayesian.swift 3 | // FireAlarm 4 | // 5 | // Created by Ashish Ahuja on 27/03/17. 6 | // Copyright © 2017 Ashish Ahuja. All right reserved 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | import SwiftStack 12 | 13 | class CommandTestBayesian: Command { 14 | override class func usage() -> [String] { 15 | return ["test ..."] 16 | } 17 | 18 | override func run () throws { 19 | /*guard let body = arguments.map({"\($0)"}).joined(separator: " ") ?? nil else { 20 | message.reply ("Please enter a valid body") 21 | return 22 | }*/ 23 | 24 | //Under development ... 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Filters/CommandTestPost.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandTestPost.swift 3 | // FireAlarm 4 | // 5 | // Created by Ashish Ahuja on 05/05/17. 6 | // 7 | // 8 | 9 | /*import Foundation 10 | import SwiftChatSE 11 | 12 | class CommandTestPost: Command { 13 | override class func usage() -> [String] { 14 | return ["test post *", "test *"] 15 | } 16 | 17 | override func run() throws { 18 | var questionID: Int! 19 | if let id = Int(arguments[0]) { 20 | questionID = id 21 | } 22 | else if let url = URL(string: arguments[0]), let id = postIDFromURL(url) { 23 | questionID = id 24 | } 25 | else { 26 | reply("Please enter a valid post ID or URL.") 27 | return 28 | } 29 | 30 | if reporter == nil { 31 | reply("Waiting for the filter to load...") 32 | repeat { 33 | sleep(1) 34 | } while reporter == nil 35 | } 36 | 37 | guard let question = try apiClient.fetchQuestion(questionID).items?.first else { 38 | reply("Could not fetch the question!") 39 | return 40 | } 41 | 42 | let result = reporter.checkPost(post: question) 43 | 44 | switch result { 45 | case 46 | } 47 | } 48 | }*/ 49 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Filters/CommandUnblacklist.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandUnblacklistKeyword.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 7/15/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | 12 | class CommandUnblacklist: Command { 13 | public class func blacklistManager(reporter: Reporter) -> BlacklistManager { 14 | return reporter.blacklistManager 15 | } 16 | 17 | override class func usage() -> [String] { 18 | return ["unblacklist ..."] 19 | } 20 | 21 | override class func privileges() -> ChatUser.Privileges { 22 | return .filter 23 | } 24 | 25 | override func run() throws { 26 | guard arguments.count >= 2, 27 | let listName = arguments.first, 28 | let list = BlacklistManager.BlacklistType(rawValue: listName) else { 29 | reply("Usage: `blacklist `") 30 | return 31 | } 32 | let regex = arguments.dropFirst().joined(separator: " ") 33 | if type(of: self).blacklistManager(reporter: reporter).blacklist(ofType: list).remove(item: regex) == false { 34 | reply("`\(regex)` was not blacklisted.") 35 | } else { 36 | reply("`\(regex)` has been removed from the \(list.rawValue) blacklist.") 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Filters/TrollCommandBlacklist.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandBlacklist.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 7/15/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | 12 | class TrollCommandBlacklist: CommandBlacklist { 13 | override class func blacklistManager(reporter: Reporter) -> BlacklistManager { 14 | return reporter.trollBlacklistManager 15 | } 16 | } 17 | 18 | class TrollCommandGetBlacklist: CommandGetBlacklist { 19 | override class func blacklistManager(reporter: Reporter) -> BlacklistManager { 20 | return reporter.trollBlacklistManager 21 | } 22 | } 23 | 24 | class TrollCommandUnblacklist: CommandUnblacklist { 25 | override class func blacklistManager(reporter: Reporter) -> BlacklistManager { 26 | return reporter.trollBlacklistManager 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Filters/TrollCommandDisable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrollCommandDisable.swift 3 | // FireAlarm 4 | // 5 | // Created by Jonathan Keller on 3/23/18. 6 | // 7 | 8 | import Foundation 9 | import SwiftChatSE 10 | 11 | class TrollCommandDisable: Command { 12 | override class func usage() -> [String] { 13 | return ["untroll ..."] 14 | } 15 | 16 | override func run() throws { 17 | if case .sites(let sites) = reporter.trollSites, !arguments.isEmpty { 18 | let filtered = sites.filter { !(arguments.contains($0.domain) || arguments.contains($0.apiSiteParameter)) } 19 | reporter.trollSites = .sites(filtered) 20 | reply("Only " + formatArray(filtered.map { "`\($0.domain)`" }, conjunction: "and") + " will be monitored.") 21 | return 22 | } else if !arguments.isEmpty { 23 | reply("All sites are monitored; run `@FireAlarm troll ` to monitor only a select group of sites.") 24 | return 25 | } 26 | 27 | //Arguments is empty; disable all sites 28 | reporter.trollSites = .sites([]) 29 | reply("Stopped monitoring on all sites.") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Filters/TrollCommandEnable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrollCommandEnable.swift 3 | // FireAlarm 4 | // 5 | // Created by Jonathan Keller on 3/23/18. 6 | // 7 | 8 | import Foundation 9 | import SwiftChatSE 10 | import SwiftStack 11 | 12 | class TrollCommandEnable: Command { 13 | override class func usage() -> [String] { 14 | return ["troll ..."] 15 | } 16 | 17 | override func run() throws { 18 | if arguments.isEmpty { 19 | reporter.trollSites = .all 20 | reply("Watching a troll on all sites.") 21 | return 22 | } 23 | 24 | var allSites = [SwiftStack.Site]() 25 | allSites.reserveCapacity(200) 26 | 27 | var hasMore = false 28 | var page = 1 29 | let pageSize = 100 30 | repeat { 31 | let response = try apiClient.fetchSites(parameters: ["per_page": String(pageSize), "page": String(page)]) 32 | guard let sites = response.items else { 33 | reply("Failed to fetch sites!") 34 | return 35 | } 36 | 37 | allSites.append(contentsOf: sites) 38 | 39 | hasMore = response.has_more ?? false 40 | page += 1 41 | } while hasMore 42 | 43 | var sites = [Site]() 44 | sites.reserveCapacity(arguments.count) 45 | 46 | for siteName in arguments { 47 | guard let index = allSites.firstIndex(where: { $0.api_site_parameter == siteName || $0.site_url?.host == siteName }) else { 48 | reply("Unknown site \(siteName).") 49 | return 50 | } 51 | 52 | let apiSite = allSites[index] 53 | 54 | guard let apiSiteParameter = apiSite.api_site_parameter, let domain = apiSite.site_url?.host else { 55 | reply("Failed to retrieve site information!") 56 | return 57 | } 58 | 59 | sites.append(Site(id: -1, apiSiteParameter: apiSiteParameter, domain: domain, initialProbability: 0)) 60 | } 61 | 62 | sites.forEach { reporter.trollSites.add($0) } 63 | switch reporter.trollSites { 64 | case .all: reply("Watching a troll on all sites.") 65 | case .sites(let sites): reply("Watching a troll on " + formatArray(sites.map { $0.domain }, conjunction: "and") + ".") 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Reports/CommandCheckNotification.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandCheckNotification.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 10/1/16. 6 | // Copyright © 2016 NobodyNada. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | 12 | class CommandCheckNotification: Command { 13 | override class func usage() -> [String] { 14 | return ["notified", "aminotified", "am i notified", "will i be notified", "opted-in", "opted in", "am i opted in", "have i opted in"] 15 | } 16 | 17 | override func run() throws { 18 | if message.user.notificationReasons.isEmpty { 19 | reply("You will not be notified of any reports.") 20 | } 21 | else if message.user.notificationReasons.contains(where: { if case .all = $0 { return true } else { return false } }) { 22 | reply("You will be notified of all reports.") 23 | } 24 | else { 25 | var reasons: [String] = [] 26 | 27 | let tags = message.user.notificationReasons.compactMap { (reason: ChatUser.NotificationReason) -> String? in 28 | if case .tag(let tag) = reason { return tag } 29 | else { return nil } 30 | } 31 | 32 | let blacklists = message.user.notificationReasons.compactMap { 33 | if case .blacklist(let list) = $0 { return list } 34 | else { return nil } 35 | } as [BlacklistManager.BlacklistType] 36 | 37 | let containsMisleadingLink = message.user.notificationReasons.contains { 38 | if case .misleadingLinks = $0 { return true } 39 | else { return false } 40 | } 41 | 42 | 43 | if !tags.isEmpty { 44 | let formattedTags = formatArray(tags.map { "[tag:\($0)]" }, conjunction: "or") 45 | reasons.append("reports tagged \(formattedTags)") 46 | } 47 | if !blacklists.isEmpty { 48 | let formattedBlacklists = formatArray(blacklists.map { "\($0.rawValue)s" }, conjunction: "and") 49 | reasons.append("blacklisted \(formattedBlacklists)") 50 | } 51 | if containsMisleadingLink { 52 | reasons.append("misleading links") 53 | } 54 | let formatted = formatArray(reasons, conjunction: "and") 55 | reply("You will be notified of \(formatted).") 56 | } 57 | 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Reports/CommandCheckPost.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandCheckPost.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 9/30/16. 6 | // Copyright © 2016 NobodyNada. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | import FireAlarmCore 12 | 13 | class CommandCheckPost: Command { 14 | override class func usage() -> [String] { 15 | return ["check post *", "check *"] 16 | } 17 | 18 | override func run() throws { 19 | guard 20 | let url = URL(string: arguments[0]), 21 | let questionID = postIDFromURL(url), 22 | let siteDomain = url.host 23 | else { 24 | reply("Please enter a valid post URL.") 25 | return 26 | } 27 | 28 | if reporter == nil { 29 | reply("Waiting for the filter to load...") 30 | repeat { 31 | sleep(1) 32 | } while reporter == nil 33 | } 34 | 35 | guard 36 | let site = try Site.with(domain: siteDomain, db: reporter.staticDB) else { 37 | reply("That does not look like a site on which I run.") 38 | return 39 | } 40 | 41 | guard 42 | let question = try apiClient.fetchQuestion( 43 | questionID, 44 | parameters: ["site":siteDomain] 45 | ).items?.first else { 46 | 47 | reply("Could not fetch the question!") 48 | return 49 | } 50 | 51 | 52 | let result = try reporter.checkAndReport(post: question, site: site.apiSiteParameter) 53 | let score: Int? = result.filterResults.compactMap { reason in 54 | if case .bayesianFilter(let score) = reason.type { 55 | return score 56 | } else { 57 | return nil 58 | } 59 | }.first 60 | 61 | let otherFilters = result.filterResults.compactMap { reason -> (FilterResult?) in 62 | if case .bayesianFilter = reason.type { 63 | return nil 64 | } else { 65 | return reason 66 | } 67 | } 68 | 69 | //"" if score is nil, " (score )" otherwise 70 | let scoreString = score != nil ? " (score \(score!))" : "" 71 | 72 | switch result.status { 73 | case .alreadyClosed: 74 | if otherFilters.isEmpty { 75 | reply ("That post is already closed\(scoreString).") 76 | } else { 77 | reply("That post is already closed, but was caught by " + 78 | formatArray(otherFilters.map { $0.header }, conjunction: "and") + 79 | scoreString 80 | ) 81 | } 82 | case .notBad: 83 | reply("That post was not caught by the filter\(scoreString).") 84 | case .alreadyReported: 85 | reply("That post was already reported.") 86 | case .reported: 87 | break 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Reports/CommandOptIn.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandOptIn.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 10/1/16. 6 | // Copyright © 2016 NobodyNada. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | 12 | class CommandOptIn: Command { 13 | override class func usage() -> [String] { 14 | return ["notify ...", "opt in ...", "opt-in ..."] 15 | } 16 | 17 | func addNotificationTags() { 18 | if arguments.count < 2 { 19 | reply("Please specify which tags.") 20 | return 21 | } 22 | 23 | let existingTags = message.user.notificationReasons.compactMap { (reason: ChatUser.NotificationReason) -> String? in 24 | if case .tag(let t) = reason { return t } 25 | return nil 26 | } 27 | 28 | for tag in arguments.dropFirst() { 29 | if !existingTags.contains(tag) { 30 | message.user.notificationReasons.append(.tag(tag)) 31 | } 32 | } 33 | 34 | let newTags = message.user.notificationReasons.compactMap { (reason: ChatUser.NotificationReason) -> String? in 35 | if case .tag(let t) = reason { return t } 36 | return nil 37 | } 38 | 39 | let formatted = formatArray(newTags.map { "[tag:\($0)]" }, conjunction: "or") 40 | reply("You will be notified of reports tagged \(formatted).") 41 | } 42 | 43 | func addNotificationBlacklist() { 44 | if arguments.count < 2 { 45 | reply("Please specify which blacklists; recognized blacklists are `keywords`, `tags`, and `usernames`..") 46 | return 47 | } 48 | 49 | let existingBlacklists = message.user.notificationReasons.compactMap { (reason: ChatUser.NotificationReason) -> String? in 50 | if case .blacklist(let l) = reason { return l.rawValue } 51 | return nil 52 | } 53 | 54 | var reasonsToAdd = [ChatUser.NotificationReason]() 55 | for blacklist in arguments.dropFirst() { 56 | if !existingBlacklists.contains(blacklist) { 57 | guard let list = BlacklistManager.BlacklistType(name: blacklist) else { 58 | reply("Unrecognized blacklist \(blacklist); recognized blacklists are `keywords`, `tags`, and `usernames`.") 59 | return 60 | } 61 | reasonsToAdd.append(.blacklist(list)) 62 | } 63 | } 64 | message.user.notificationReasons += reasonsToAdd 65 | 66 | let newBlacklists = message.user.notificationReasons.compactMap { (reason: ChatUser.NotificationReason) -> String? in 67 | if case .blacklist(let l) = reason { return l.rawValue + "s" } 68 | return nil 69 | } 70 | 71 | let formatted = formatArray(newBlacklists, conjunction: "or") 72 | reply("You will be notified of posts containing blacklisted \(formatted).") 73 | } 74 | 75 | func addNotificationLinks() { 76 | let containsMisleadingLinks = message.user.notificationReasons.contains { 77 | if case .misleadingLinks = $0 { return true } 78 | else { return false } 79 | } 80 | if containsMisleadingLinks { 81 | reply("You're already notified of misleading link reports.") 82 | } else { 83 | message.user.notificationReasons.append(.misleadingLinks) 84 | reply("You will now be notified of misleading link reports.") 85 | } 86 | } 87 | 88 | override func run() throws { 89 | 90 | if arguments.count == 0 { 91 | message.user.notificationReasons = [.all] 92 | reply("You will now be notified of all reports.") 93 | } 94 | else { 95 | if ["misleading links", "misleading link", "misleadinglink", "misleadinglinks", "link", "links"] 96 | .contains(arguments.joined(separator: " ")) { 97 | addNotificationLinks() 98 | } else { 99 | switch arguments.first! { 100 | case "tag", "tags": 101 | addNotificationTags() 102 | case "blacklist", "blacklists", "blacklisted": 103 | addNotificationBlacklist() 104 | default: 105 | reply("Unrecognized reason \"\(arguments.first!)\"; allowed reasons are `tags`, `misleading links`, and `blacklists`.") 106 | } 107 | } 108 | } 109 | 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Reports/CommandOptOut.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandOptOut.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 10/1/16. 6 | // Copyright © 2016 NobodyNada. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | 12 | class CommandOptOut: Command { 13 | override class func usage() -> [String] { 14 | return ["opt out ...", "opt-out ...", "unnotify"] 15 | } 16 | 17 | func removeNotificationTags() { 18 | message.user.notificationReasons = message.user.notificationReasons.filter { 19 | if case .tag(let tag) = $0 { 20 | return !arguments.contains(tag) 21 | } 22 | return true 23 | } 24 | 25 | let formatted = formatArray(arguments.dropFirst().map { "[tag:\($0)]" }, conjunction: "or") 26 | reply("You will not be notified of posts tagged \(formatted).") 27 | } 28 | 29 | func removeNotificationBlacklists() { 30 | message.user.notificationReasons = message.user.notificationReasons.filter { 31 | if case .blacklist(let list) = $0 { 32 | return !(arguments.contains(list.rawValue) || arguments.contains(list.rawValue + "s")) 33 | } 34 | return true 35 | } 36 | 37 | let formatted = formatArray(Array(arguments.dropFirst()), conjunction: "or") 38 | reply("You will not be notified of blacklisted \(formatted) reports.") 39 | } 40 | 41 | func removeNotificationLinks() { 42 | guard let index = message.user.notificationReasons.firstIndex(where: { 43 | if case .misleadingLinks = $0 { return true } 44 | else { return false } 45 | }) else { 46 | reply("You are not currently notified of misleading link reports.") 47 | return 48 | } 49 | message.user.notificationReasons.remove(at: index) 50 | reply("You will not be notified of misleading link reports.") 51 | } 52 | 53 | override func run() throws { 54 | if arguments.isEmpty || message.user.notificationReasons.isEmpty { 55 | message.user.notificationReasons = [] 56 | reply("You will not be notified of any reports.") 57 | } 58 | else { 59 | switch arguments.first! { 60 | case "tag", "tags": 61 | removeNotificationTags() 62 | case "blacklisted", "blacklist", "blacklists": 63 | removeNotificationBlacklists() 64 | case "misleading", "misleadinglink", "misleadinglinks", "link", "links": 65 | removeNotificationLinks() 66 | default: 67 | reply("Unrecognized reason \"\(arguments.first!)\"; allowed reasons are `tags`, `blacklists`, and `misleading links`.") 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Reports/CommandReport.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandReport.swift 3 | // FireAlarm 4 | // 5 | // Created by Ashish Ahuja on 2/26/17. 6 | // Copyright © 2017 Ashish Ahuja (Fortunate-MAN). All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | import SwiftStack 12 | import Dispatch 13 | import FireAlarmCore 14 | 15 | class CommandReport: Command { 16 | override class func usage() -> [String] { 17 | return ["report ..."] 18 | } 19 | 20 | override class func privileges() -> ChatUser.Privileges { 21 | return .owner 22 | } 23 | 24 | override func run() throws { 25 | guard 26 | let url = URL(string: arguments[0]), 27 | let questionID = postIDFromURL(url), 28 | let siteDomain = url.host 29 | else { 30 | reply("Please enter a valid post URL.") 31 | return 32 | } 33 | 34 | if reporter == nil { 35 | reply("Waiting for the filter to load...") 36 | repeat { 37 | sleep(1) 38 | } while reporter == nil 39 | } 40 | 41 | guard 42 | let site = try Site.with(domain: siteDomain, db: reporter.staticDB) else { 43 | reply("That does not look like a site on which I run.") 44 | return 45 | } 46 | 47 | 48 | guard 49 | let question = try apiClient.fetchQuestion( 50 | questionID, 51 | parameters: ["site":siteDomain] 52 | ).items?.first else { 53 | 54 | reply("Could not fetch the question!") 55 | return 56 | } 57 | 58 | 59 | 60 | var filterResult = try reporter.check(post: question, site: site.apiSiteParameter) 61 | 62 | filterResult.append(FilterResult (type: .manuallyReported, header: "Manually reported question", details: "Question manually reported by \(message.user): https://\(message.room.host.chatDomain)/transcript/message/\(message.id ?? -1)#\(message.id ?? -1)")) 63 | 64 | switch try reporter.report(post: question, site: site.apiSiteParameter, reasons: filterResult).status { 65 | case .alreadyClosed: 66 | reply("That post is already closed.") 67 | case .alreadyReported: 68 | reply("That post has already been reported.") 69 | case .notBad: 70 | reply("this should never happen (cc @NobodyNada): `\(#file)`, line \(#line)") 71 | case .reported: 72 | break 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Reports/CommandUnclosed.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandUnclosed.swift 3 | // FireAlarm 4 | // 5 | // Created by Ashish Ahuja on 05/03/17. 6 | // Copyright © 2017 Ashish Ahuja. All right reserved 7 | // 8 | 9 | //TODO: 10 | // 1. Instead of printing the reports in chat send them to Sam's server 11 | 12 | import Foundation 13 | import SwiftStack 14 | import SwiftChatSE 15 | import Dispatch 16 | 17 | class CommandUnclosed: Command { 18 | override class func usage() -> [String] { 19 | return ["unclosed reports ..."] 20 | } 21 | 22 | override func run() throws { 23 | 24 | if (reportedPosts.isEmpty) 25 | { 26 | reply ("no reports!") 27 | return 28 | } 29 | 30 | if (arguments.count == 0) { 31 | reply ("Please specify the number of posts to be checked.") 32 | return 33 | } 34 | 35 | var totalToCheck: Int! 36 | let maxThreshold = 100 37 | var threshold: Int! = maxThreshold 38 | var maxCV: Int! = 4 39 | var minCV: Int! = 0 40 | 41 | if let total = Int(arguments[0]) { 42 | if (total > 40) 43 | { 44 | reply ("You cannot specify to check more than the last 40 reports!") 45 | return 46 | } 47 | totalToCheck = total 48 | } else { 49 | reply ("Please enter a valid number for the total reports to be checked.") 50 | return 51 | } 52 | 53 | if (arguments.count > 1) { 54 | for i in 1.. maxThreshold) 63 | { 64 | threshold = maxThreshold 65 | } 66 | } 67 | 68 | if (arguments [i].range(of: "cv=") != nil) 69 | { 70 | maxCV = Int (arguments[i].replacingOccurrences(of: "cv=", with: "")) 71 | minCV = maxCV 72 | } else if (arguments [i].range(of: "cv<") != nil) { 73 | maxCV = Int(arguments[i].replacingOccurrences(of: "cv<", with: "")) ?? 5 - 1 74 | } else if (arguments [i].range(of: "cv>") != nil){ 75 | minCV = Int(arguments[i].replacingOccurrences(of: "cv>", with: "")) ?? -1 + 1 76 | } else if (arguments [i].range(of: "cv<=") != nil) { 77 | maxCV = Int(arguments[i].replacingOccurrences(of: "cv<=", with: "")) 78 | } else if (arguments[i].range(of: "cv>=") != nil) { 79 | minCV = Int(arguments [i].replacingOccurrences(of: "cv>=", with: "")) 80 | } 81 | } 82 | } 83 | 84 | var postsToCheck = [Int]() 85 | var postDifferences = [Int?]() 86 | 87 | if let minDate: Date = Calendar(identifier: .gregorian).date(byAdding: DateComponents(hour: -10), to: Date()) { 88 | let recentlyReportedPosts = reportedPosts.filter { 89 | $0.when > minDate 90 | } 91 | 92 | if (recentlyReportedPosts.count == 0) 93 | { 94 | reply ("There are no reports made by me recently, thus I have nothing to check!") 95 | return 96 | } 97 | 98 | if (totalToCheck > recentlyReportedPosts.count) 99 | { 100 | totalToCheck = recentlyReportedPosts.count 101 | } 102 | 103 | var i = 0 104 | while postsToCheck.count < recentlyReportedPosts.count { 105 | postsToCheck.append(recentlyReportedPosts[i].id) 106 | postDifferences.append(recentlyReportedPosts[i].difference) 107 | i = i + 1 108 | } 109 | } 110 | else { 111 | message.room.postMessage("Failed to calculate minimum report date!") 112 | } 113 | 114 | var messageClosed = "" 115 | var totalPosts = 0 116 | var i = 0 117 | 118 | //Now fetch the posts from the API 119 | for post in try apiClient.fetchQuestions(postsToCheck).items ?? [] { 120 | if totalPosts < totalToCheck && 121 | postDifferences [i] ?? 0 < threshold && post.closed_reason == nil && 122 | post.close_vote_count ?? 0 >= minCV && 123 | post.close_vote_count ?? 0 <= maxCV 124 | { 125 | messageClosed = messageClosed + "\nCV:\(post.close_vote_count ?? 0) \(post.link?.absoluteString ?? "https://example.com")" 126 | totalPosts = totalPosts + 1 127 | } 128 | i = i + 1 129 | } 130 | 131 | message.room.postMessage (messageClosed) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Reports/CommandWhy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandWhy.swift 3 | // FireAlarm 4 | // 5 | // Created by Ashish Ahuja on 06/05/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | 12 | class CommandWhy: Command { 13 | override class func usage() -> [String] { 14 | return ["why"] 15 | } 16 | 17 | override func run() throws { 18 | let matchingReport: Report 19 | 20 | if let replyID = message.replyID { 21 | 22 | //This is a reply. 23 | //Get the last report whose message matches this one. 24 | if let report = reportedPosts 25 | .reversed() 26 | .first(where: { report in 27 | report.messages.contains { $0.messageID == replyID && $0.host == message.room.host} 28 | }) { 29 | 30 | matchingReport = report 31 | } else { 32 | reply("That message is not in the report list.") 33 | return 34 | } 35 | } else { 36 | //This is not a reply. 37 | //Get the last report in this room. 38 | if let report = reportedPosts 39 | .reversed() 40 | .first(where: { report in 41 | report.messages.contains { $0.roomID == message.room.roomID } 42 | }) { 43 | 44 | matchingReport = report 45 | } else { 46 | reply("No reports have been posted in this room.") 47 | return 48 | } 49 | } 50 | 51 | if let details = matchingReport.details { 52 | reply("Detected with likelihood \(matchingReport.likelihood): \(details)") 53 | } else { 54 | reply("Details for this post are not available (cc @AshishAhuja @NobodyNada).") 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Utilities/CommandGitStatus.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandGitStatus.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 9/30/17. 6 | // 7 | 8 | import Foundation 9 | import SwiftChatSE 10 | 11 | class CommandGitStatus: Command { 12 | override class func usage() -> [String] { 13 | return ["git status", "gitstatus"] 14 | } 15 | 16 | override func run() throws { 17 | let result = Frontend.run(command: "git status") 18 | if result.exitCode != 0 { 19 | if result.combinedOutput != nil { 20 | reply("`git status` returned exit code \(result.exitCode):") 21 | } else { 22 | reply("`git status` returned exit code \(result.exitCode).") 23 | } 24 | } else { 25 | if result.combinedOutput == nil { 26 | reply("The output of `git status` was not valid UTF-8.") 27 | } 28 | } 29 | if let output = result.combinedOutput { 30 | post(" " + output.components(separatedBy: .newlines).joined(separator: "\n ")) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Utilities/CommandHelp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandHelp.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 8/28/16. 6 | // Copyright © 2016 NobodyNada. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | 12 | class CommandHelp: Command { 13 | override class func usage() -> [String] { 14 | return ["help", "command", "commands"] 15 | } 16 | 17 | override func run() throws { 18 | reply("I'm [\(botName)](\(stackAppsLink)), a bot which detects questions that need closing. [My command list is available here](https://github.com/SOBotics/FireAlarm/wiki/CommandsSwift).") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Utilities/CommandLeaveRoom.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandLeaveRoom.swift 3 | // FireAlarm 4 | // 5 | // Created by Ashish Ahuja on 02/19/17. 6 | // Copyright © 2017 Ashish Ahuja (Fortunate-MAN). All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | 12 | class CommandLeaveRoom: Command { 13 | override class func usage() -> [String] { 14 | return ["leave room", "leave", "go out"] 15 | } 16 | 17 | override class func privileges() -> ChatUser.Privileges { 18 | return .owner 19 | } 20 | 21 | override func run() throws { 22 | [message.room].forEach { $0.leave() } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Utilities/CommandLocation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandLocation.swift 3 | // FireAlarm 4 | // 5 | // Created by Ashish Ahuja on 25/02/17. 6 | // Copyright © 2017 Ashish Ahuja. All rights reserved 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | import SwiftStack 12 | 13 | class CommandLocation: Command { 14 | override class func usage() -> [String] { 15 | return ["location"] 16 | } 17 | 18 | override func run() throws { 19 | //Get the location 20 | let message = "I'm running on \(location)" 21 | reply (message) 22 | } 23 | } 24 | 25 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Utilities/CommandPingOnError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandPingOnError.swift 3 | // FireAlarm 4 | // 5 | // Created by Ashish Ahuja on 09/04/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | 12 | class CommandPingOnError: Command { 13 | override class func usage() -> [String] { 14 | return ["pingonerror ..."] 15 | } 16 | 17 | override class func privileges() -> ChatUser.Privileges { 18 | return .owner 19 | } 20 | 21 | override func run() throws { 22 | if (arguments.count == 0) { 23 | message.reply ("Usage: `@fire pingonerror `") 24 | return 25 | } 26 | 27 | if (arguments [0] == "true") { 28 | pingonerror = true 29 | message.reply ("The owner of this instance will now be pinged during errors.") 30 | } else if (arguments [0] == "false") { 31 | pingonerror = false 32 | message.reply ("The owner of this instance will now not be pinged during errors.") 33 | } else { 34 | message.reply ("Usage: `@fire pingonerror `") 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Utilities/CommandQuota.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandQuota.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 12/20/16. 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | import SwiftStack 12 | 13 | class CommandQuota: Command { 14 | override class func usage() -> [String] { 15 | return ["quota", "api quota", "api-quota", "apiquota"] 16 | } 17 | 18 | override func run() throws { 19 | reply("API quota is \(apiClient.quota.map { String($0) } ?? "unknown").") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Utilities/CommandStatus.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandStatus.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 10/11/16. 6 | // Copyright © 2016 NobodyNada. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Dispatch 11 | import SwiftChatSE 12 | 13 | open class CommandStatus: Command { 14 | override open class func usage() -> [String] { 15 | return ["alive", "status", "version", "ver", "rev", "revision", "uptime"] 16 | } 17 | 18 | override open func run() throws { 19 | var uptime = Int(Date().timeIntervalSinceReferenceDate - startTime.timeIntervalSinceReferenceDate) 20 | let seconds = uptime % 60 21 | uptime /= 60 22 | let minutes = uptime % 60 23 | uptime /= 60 24 | let hours = uptime % 24 25 | uptime /= 24 26 | let days = uptime % 7 27 | uptime /= 7 28 | let weeks = uptime 29 | 30 | var uptimeStrings: [String] = [] 31 | if weeks != 0 { uptimeStrings.append("\(weeks) \(pluralize(weeks, "week"))") } 32 | if days != 0 { uptimeStrings.append("\(days) \(pluralize(days, "day"))") } 33 | if hours != 0 { uptimeStrings.append("\(hours) \(pluralize(hours, "hour"))") } 34 | if minutes != 0 { uptimeStrings.append("\(minutes) \(pluralize(minutes, "minute"))") } 35 | if seconds != 0 { uptimeStrings.append("\(seconds) \(pluralize(seconds, "second"))") } 36 | 37 | 38 | let status = "[\(botName)](\(stackAppsLink)) version [\(shortVersion)](\(versionLink)), " + 39 | "running for \(uptimeStrings.joined(separator: " ")) on \(location)" 40 | 41 | reply(status) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Utilities/CommandUnprivilege.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandUnprivilege.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 11/21/16. 6 | // Copyright © 2016 NobodyNada. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | 12 | class CommandUnprivilege: Command { 13 | override class func usage() -> [String] { 14 | return ["unprivilege * *", "remove privilege * *"] 15 | } 16 | 17 | override class func privileges() -> ChatUser.Privileges { 18 | return .owner 19 | } 20 | 21 | private func usage() { 22 | if usageIndex == 0 { 23 | reply("Usage: unprivilege ") 24 | } else { 25 | reply("Usage: remove privilege ") 26 | } 27 | } 28 | 29 | override func run() throws { 30 | if arguments.count != 2 { 31 | return usage() 32 | } 33 | 34 | let user = arguments.first! 35 | let privilegeName = arguments.last! 36 | 37 | var privilege: ChatUser.Privileges? 38 | 39 | for (priv, name) in ChatUser.Privileges.privilegeNames { 40 | if name.lowercased() == privilegeName.lowercased() { 41 | privilege = ChatUser.Privileges(rawValue: priv) 42 | break 43 | } 44 | } 45 | 46 | guard let priv = privilege else { 47 | reply("\(privilegeName) is not a valid privilege") 48 | return 49 | } 50 | 51 | var targetUser: ChatUser? 52 | let idFromURL: Int? 53 | if let url = URL(string: user), let id = postIDFromURL(url, isUser: true) { 54 | idFromURL = id 55 | } else { 56 | idFromURL = nil 57 | } 58 | 59 | //search for the user in the user database 60 | for chatUser in message.room.userDB { 61 | if chatUser.id == Int(user) || 62 | chatUser.name.replacingOccurrences(of: " ", with: "").lowercased() == user.lowercased() || 63 | chatUser.id == idFromURL { 64 | 65 | targetUser = chatUser 66 | break 67 | } 68 | } 69 | 70 | guard let u = targetUser else { 71 | reply("I don't know that user.") 72 | return 73 | } 74 | 75 | guard u.privileges.contains(priv) else { 76 | reply("That user doesn't have that privilege.") 77 | return 78 | } 79 | 80 | u.privileges.subtract(priv) 81 | reply("Removed \(ChatUser.Privileges.name(of: priv)) privileges from [\(u.name)](//stackoverflow.com/u/\(u.id)).") 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/Frontend/Commands/Utilities/CommandUpdate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandUpdate.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 9/30/16. 6 | // Copyright © 2016 NobodyNada. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | 12 | class CommandUpdate: Command { 13 | fileprivate let FORCE_INDEX = 2 14 | override class func usage() -> [String] { 15 | return ["update force ...", "pull force ...", "update ...", "pull ..."] 16 | } 17 | 18 | override class func privileges() -> ChatUser.Privileges { 19 | return .owner 20 | } 21 | 22 | override func run() throws { 23 | if noUpdate { 24 | reply("Updates are disabled for this instance.") 25 | return 26 | } 27 | 28 | let commit: String? 29 | if arguments.count > 1 { 30 | reply("Usage: update [force] [revision]") 31 | return 32 | } 33 | else if arguments.count == 1 { 34 | commit = arguments.first! 35 | } else { 36 | commit = nil 37 | } 38 | 39 | if isUpdating { 40 | reply("An update is already in progress.") 41 | return 42 | } 43 | if (!update (to: commit, listener: listener, rooms: [message.room], force: (usageIndex < FORCE_INDEX))) 44 | { 45 | reply ("No new update available.") 46 | } 47 | return 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/Frontend/Filters/BlacklistFilter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FilterBlacklistedKeywords.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 7/15/17. 6 | // Copyright © 2017 NobodyNada. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | import Dispatch 12 | import SwiftStack 13 | import FireAlarmCore 14 | 15 | class BlacklistFilter: Filter { 16 | let reporter: Reporter 17 | let troll: Bool 18 | 19 | var blacklistManager: BlacklistManager { return troll ? reporter.trollBlacklistManager : reporter.blacklistManager } 20 | 21 | required init(reporter: Reporter) { 22 | self.reporter = reporter 23 | self.troll = false 24 | } 25 | 26 | required init(reporter: Reporter, troll: Bool = false) { 27 | self.reporter = reporter 28 | self.troll = troll 29 | } 30 | 31 | 32 | var blacklistType: BlacklistManager.BlacklistType { 33 | fatalError("blacklistType must be overridden") 34 | } 35 | func content(for post: Post, site: String) -> [String?] { 36 | fatalError("content(for:site:) must be overridden") 37 | } 38 | 39 | func check(post: Post, site: String) -> FilterResult? { 40 | let content = self.content(for: post, site: site).compactMap { $0 } 41 | guard !content.isEmpty else { 42 | print("\(String(describing: type(of: self))): No content for \(post.id.map { String($0) } ?? "")!") 43 | return nil 44 | } 45 | 46 | let blacklist = blacklistManager.blacklist(ofType: blacklistType) 47 | let matches = content.map { blacklist.items(catching: $0) }.joined() 48 | let uppercasedBlacklistName = String(blacklistType.rawValue.first!).uppercased() + blacklistType.rawValue.dropFirst() 49 | 50 | if matches.count == 0 { 51 | return nil 52 | } else { 53 | let details: String 54 | if matches.count == 1 { 55 | details = "\(uppercasedBlacklistName) matched regex `\(matches.first!)`" 56 | } else { 57 | details = "\(uppercasedBlacklistName)s matched regular expressions \(formatArray(matches.map { "`\($0)`" }, conjunction: "and"))" 58 | } 59 | return FilterResult( 60 | type: .customFilter(filter: self), 61 | header: "Blacklisted \(blacklistType.rawValue)", 62 | details: details 63 | ) 64 | } 65 | } 66 | 67 | func save() throws { 68 | 69 | } 70 | } 71 | 72 | 73 | class FilterBlacklistedKeyword: BlacklistFilter { 74 | override var blacklistType: BlacklistManager.BlacklistType { return .keyword } 75 | override func content(for post: Post, site: String) -> [String?] { return [post.body] } 76 | } 77 | 78 | class FilterBlacklistedUsername: BlacklistFilter { 79 | override var blacklistType: BlacklistManager.BlacklistType { return .username } 80 | override func content(for post: Post, site: String) -> [String?] { return [post.owner?.display_name] } 81 | } 82 | 83 | class FilterBlacklistedTag: BlacklistFilter { 84 | override var blacklistType: BlacklistManager.BlacklistType { return .tag } 85 | override func content(for post: Post, site: String) -> [String?] { 86 | return (post as? Question)?.tags ?? (post as? Answer)?.tags ?? [] 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Sources/Frontend/Filters/FilterCodeWithoutExplanation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FilterCodeWithoutExplanation.swift 3 | // FireAlarm 4 | // 5 | // Created by Ashish Ahuja on 16/05/18. 6 | // 7 | 8 | import Foundation 9 | import SwiftStack 10 | import SwiftChatSE 11 | import Dispatch 12 | import FireAlarmCore 13 | 14 | //Detects post where code exists but no explanation, i.e, code dump questions. 15 | class FilterCodeWithoutExplanation: Filter { 16 | init() {} 17 | 18 | func check(post: Post, site: String) -> FilterResult? { 19 | if post.body!.contains("") && !post.body!.contains("

") { 20 | let header = "Code without explanation" 21 | let details = header 22 | return FilterResult ( 23 | type: .customFilter(filter: self), 24 | header: header, 25 | details: details 26 | ) 27 | } else { 28 | return nil 29 | } 30 | } 31 | 32 | func save() throws {} 33 | } 34 | -------------------------------------------------------------------------------- /Sources/Frontend/Filters/FilterImageWithoutCode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FilterImageWithoutCode.swift 3 | // FireAlarm 4 | // 5 | // Created by Ashish Ahuja on 04/05/18. 6 | // 7 | 8 | import Foundation 9 | import SwiftStack 10 | import SwiftChatSE 11 | import Dispatch 12 | import FireAlarmCore 13 | 14 | class FilterImageWithoutCode: Filter { 15 | init() {} 16 | 17 | func check(post: Post, site: String) -> FilterResult? { 18 | //Filter weight; increase this is the filter is very accurate, decrease otherwise. Will get subtracted from Naive Bayes difference. 19 | let reasonWeight = 13 20 | 21 | if (post.body!.contains("") { 22 | let header = "Image without code" 23 | let details = header + " (weight \(reasonWeight))" 24 | return FilterResult ( 25 | type: .customFilterWithWeight(filter: self, weight: reasonWeight), 26 | header: header, 27 | details: details 28 | ) 29 | } else { 30 | return nil 31 | } 32 | } 33 | 34 | func save() throws {} 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Frontend/Filters/FilterLowLength.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FilterLowLength.swift 3 | // FireAlarm 4 | // 5 | // Created by Ashish Ahuja on 06/05/18. 6 | // 7 | 8 | import Foundation 9 | import SwiftStack 10 | import SwiftChatSE 11 | import Dispatch 12 | import FireAlarmCore 13 | 14 | class FilterLowLength: Filter { 15 | init() {} 16 | 17 | func check(post: Post, site: String) -> FilterResult? { 18 | //Filter weight; increase this is the filter is very accurate, decrease otherwise. Will get subtracted from Naive Bayes difference. 19 | var reasonWeight = 0 20 | 21 | if post.body!.count < 100 { 22 | reasonWeight = 15 23 | } else if post.body!.count < 150 { 24 | reasonWeight = 5 25 | } 26 | 27 | if reasonWeight > 0 { 28 | let header = "Low length" 29 | let details = header + " (weight \(reasonWeight))" 30 | return FilterResult ( 31 | type: .customFilterWithWeight(filter: self, weight: reasonWeight), 32 | header: header, 33 | details: details 34 | ) 35 | } else { 36 | return nil 37 | } 38 | } 39 | 40 | func save() throws {} 41 | } 42 | -------------------------------------------------------------------------------- /Sources/Frontend/Filters/FilterMisleadingLinks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FilterMisleadingLinks.swift 3 | // FireAlarm 4 | // 5 | // Created by Ashish Ahuja on 23/04/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | import SwiftStack 12 | import Dispatch 13 | import FireAlarmCore 14 | 15 | class FilterMisleadingLinks: Filter { 16 | init() {} 17 | 18 | func check(post: Post, site: String) -> FilterResult? { 19 | do { 20 | let regex = try NSRegularExpression(pattern: 21 | "\\s*([^<\\s]*)(?=\\s*)", options: [] 22 | ) 23 | 24 | guard let body = post.body else { 25 | print("No body for \(post.id.map { String($0) } ?? "")!") 26 | return nil 27 | } 28 | 29 | #if os(Linux) 30 | let nsString = body._bridgeToObjectiveC() 31 | #else 32 | let nsString = body as NSString 33 | #endif 34 | for match in regex.matches(in: body, options: [], range: NSMakeRange(0, nsString.length)) { 35 | 36 | 37 | let linkString = nsString.substring(with: match.range(at: 1)) 38 | let textString = nsString.substring(with: match.range(at: 2)) 39 | guard 40 | let link = URL(string: linkString), 41 | let text = URL(string: textString), 42 | let linkHost = link.host?.lowercased(), 43 | let textHost = text.host?.lowercased() else { 44 | continue 45 | } 46 | 47 | 48 | if (!textHost.isEmpty && 49 | textHost != linkHost && 50 | !linkHost.contains("rads.stackoverflow.com") && 51 | "www." + textHost != linkHost && 52 | "www." + linkHost != textHost && 53 | linkHost.contains(".") && 54 | textHost.contains(".") && 55 | !linkHost.trimmingCharacters(in: .whitespaces).contains(" ") && 56 | !textHost.trimmingCharacters(in: .whitespaces).contains(" ") && 57 | !linkHost.contains("//http") && 58 | !textHost.contains("//http")) { 59 | 60 | return FilterResult( 61 | type: .customFilter(filter: self), 62 | header: "Misleading link", 63 | details: "Link appears to go to `\(textHost)` but actually goes to `\(linkHost)`" 64 | ) 65 | } 66 | 67 | 68 | } 69 | return nil 70 | 71 | } catch { 72 | handleError(error, "while checking for misleading links") 73 | return nil 74 | } 75 | } 76 | 77 | func save() throws { 78 | 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/Frontend/Filters/FilterNaiveBayes.swift: -------------------------------------------------------------------------------- 1 | // FilterNaiveBayes.swift 2 | // FireAlarm 3 | // 4 | // Created by AshishAhuja on 23/04/17. 5 | // Copyright © 2017 Ashish Ahuja (Fortunate-MAN). All rights reserved. 6 | // 7 | 8 | import Foundation 9 | import SwiftChatSE 10 | import Dispatch 11 | import SwiftStack 12 | import FireAlarmCore 13 | 14 | class Word { 15 | let text: String 16 | let trueProbability: Double 17 | let falseProbability: Double 18 | 19 | init(_ text: String, _ pTrue: Double, _ pFalse: Double) { 20 | self.text = text 21 | trueProbability = pTrue 22 | falseProbability = pFalse 23 | } 24 | 25 | convenience init(row: Row) { 26 | self.init( 27 | row.column(named: "word")!, 28 | row.column(named: "true")!, 29 | row.column(named: "false")! 30 | ) 31 | } 32 | } 33 | 34 | class FilterNaiveBayes: Filter { 35 | let reporter: Reporter 36 | required init(reporter: Reporter) { 37 | self.reporter = reporter 38 | } 39 | 40 | func check(post: Post, site: String) throws -> FilterResult? { 41 | guard let site = try Site.with(apiSiteParameter: site, db: reporter.staticDB) else { 42 | return nil 43 | } 44 | 45 | var trueProbability = Double(site.initialProbability) 46 | var falseProbability = Double(1 - trueProbability) 47 | var postWords = [String]() 48 | var checkedWords = [String]() 49 | 50 | guard let body = post.body else { 51 | print("No body for \(post.id.map { String($0) } ?? "")") 52 | return nil 53 | } 54 | 55 | var currentWord: String = "" 56 | let set = CharacterSet.alphanumerics.inverted 57 | for character in body.lowercased() { 58 | if !set.contains(String(character).unicodeScalars.first!) { 59 | currentWord.append(character) 60 | } 61 | else if !currentWord.isEmpty { 62 | postWords.append(currentWord) 63 | currentWord = "" 64 | } 65 | } 66 | 67 | if !currentWord.isEmpty { 68 | postWords.append(currentWord) 69 | } 70 | 71 | for postWord in postWords { 72 | if postWord.isEmpty { 73 | continue 74 | } 75 | guard let word = try reporter.staticDB.run( 76 | "SELECT * FROM words " + 77 | "WHERE site = ? " + 78 | "AND word = ?;", 79 | site.id, postWord 80 | ).first.map(Word.init) else { 81 | 82 | continue 83 | } 84 | checkedWords.append(postWord) 85 | 86 | let pTrue = word.trueProbability 87 | let pFalse = word.falseProbability 88 | 89 | 90 | let newTrue = trueProbability * Double(pTrue) 91 | let newFalse = falseProbability * Double(pFalse) 92 | if newTrue != 0.0 && newFalse != 0.0 { 93 | trueProbability = newTrue 94 | falseProbability = newFalse 95 | } 96 | } 97 | 98 | let difference = -log10(falseProbability - trueProbability) 99 | 100 | if difference.isNormal { 101 | return FilterResult( 102 | type: .bayesianFilter(difference: Int(difference)), 103 | header: "Potentially bad question", 104 | details: "Naive Bayes score \(Int(difference))" 105 | ) 106 | } 107 | 108 | return nil 109 | } 110 | 111 | func save() throws {} 112 | } 113 | -------------------------------------------------------------------------------- /Sources/Frontend/Filters/FilterNonEnglishPost.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FilterNonEnglishPost.swift 3 | // FireAlarm 4 | // 5 | // Created by Ashish Ahuja on 18/05/18. 6 | // 7 | 8 | import Foundation 9 | import SwiftStack 10 | import SwiftChatSE 11 | import Dispatch 12 | import FireAlarmCore 13 | 14 | class FilterNonEnglishPost: Filter { 15 | init() {} 16 | 17 | //Take from https://stackoverflow.com/a/27880748/4688119 18 | func regexMatches(for regex: String, in text: String) -> [String] { 19 | do { 20 | let regex = try NSRegularExpression(pattern: regex, options: .caseInsensitive) 21 | let results = regex.matches(in: text, 22 | range: NSRange(text.startIndex..., in: text)) 23 | return results.map { 24 | String(text[Range($0.range, in: text)!]) 25 | } 26 | } catch let error { 27 | print("invalid regex: \(error.localizedDescription)") 28 | return [] 29 | } 30 | } 31 | 32 | func extractParagraphs(in text: String) -> String { 33 | //Removing html tags using https://stackoverflow.com/q/25983558/4688119; kinda hacky, but works. 34 | return regexMatches(for: "

.*?

", in: text).joined(separator: " ").replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil) 35 | } 36 | 37 | func check(post: Post, site: String) -> FilterResult? { 38 | #if os(macOS) 39 | if #available(OSX 10.13, *) { 40 | if NSLinguisticTagger.dominantLanguage(for: extractParagraphs(in: post.body!)) != "en" { 41 | let header = "Non English Post" 42 | let details = header 43 | return FilterResult ( 44 | type: .customFilter(filter: self), 45 | header: header, 46 | details: details 47 | ) 48 | } else { 49 | return nil 50 | } 51 | } 52 | #endif 53 | 54 | return nil 55 | } 56 | 57 | func save() throws {} 58 | } 59 | -------------------------------------------------------------------------------- /Sources/Frontend/Models/Site.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Site.swift 3 | // FireAlarm 4 | // 5 | // Created by Jonathan Keller on 10/7/17. 6 | // 7 | 8 | import Foundation 9 | import SwiftChatSE 10 | 11 | struct Site: Codable, Hashable { 12 | var id: Int 13 | var apiSiteParameter: String 14 | var domain: String 15 | var initialProbability: Float 16 | 17 | static func from(row: Row) throws -> Site { 18 | return try RowDecoder().decode(Site.self, from: row) 19 | } 20 | 21 | ///Helper function used by methods such as with(id:db:). 22 | ///- warning: The `name` parameter is vulnerable to SQL injection; make sure it's valid! 23 | static private func with(parameter: T, name: String, db: DatabaseConnection) throws -> Site? { 24 | return try db.run("SELECT * FROM sites WHERE \(name) = ?;", parameter).first.map(Site.from) 25 | } 26 | 27 | ///Returns the Site with the specified ID. 28 | static func with(id: Int, db: DatabaseConnection) throws -> Site? { 29 | return try with(parameter: id, name: "id", db: db) 30 | } 31 | 32 | ///Returns the Site with the specified domain. 33 | static func with(domain: String, db: DatabaseConnection) throws -> Site? { 34 | return try with(parameter: domain, name: "domain", db: db) 35 | } 36 | 37 | ///Returns the Site with the specified apiSiteParameter. 38 | static func with(apiSiteParameter: String, db: DatabaseConnection) throws -> Site? { 39 | return try with(parameter: apiSiteParameter, name: "apiSiteParameter", db: db) 40 | } 41 | 42 | func hash(into hasher: inout Hasher) { 43 | apiSiteParameter.hash(into: &hasher) 44 | } 45 | static func ==(lhs: Site, rhs: Site) -> Bool { return lhs.apiSiteParameter == rhs.apiSiteParameter } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/Frontend/Reporter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Reports.swift 3 | // FireAlarm 4 | // 5 | // Created by Ashish Ahuja on 24/04/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftStack 11 | import SwiftChatSE 12 | import Dispatch 13 | import FireAlarmCore 14 | 15 | struct Report { 16 | let id: Int 17 | let when: Date 18 | let difference: Int? 19 | let likelihood: Int 20 | 21 | let messages: [(host: ChatRoom.Host, roomID: Int, messageID: Int)] 22 | let details: String? 23 | 24 | init( 25 | id: Int, 26 | when: Date, 27 | likelihood: Int, 28 | difference: Int?, 29 | messages: [(host: ChatRoom.Host, roomID: Int, messageID: Int)] = [], 30 | details: String? = nil 31 | ) { 32 | 33 | self.id = id 34 | self.when = when 35 | self.likelihood = likelihood 36 | self.difference = difference 37 | self.messages = messages 38 | self.details = details 39 | } 40 | 41 | init?(json: [String:Any]) { 42 | guard let id = json["id"] as? Int, let when = json["t"] as? Int else { 43 | return nil 44 | } 45 | 46 | let messages = (json["m"] as? [[String:Any]])?.compactMap { messageJSON in 47 | guard let host = (messageJSON["h"] as? Int).map ({ChatRoom.Host(rawValue: $0)}) ?? nil, 48 | let room = messageJSON["r"] as? Int, 49 | let message = messageJSON["m"] as? Int else { 50 | return nil 51 | } 52 | 53 | return (host: host, roomID: room, messageID: message) 54 | } as [(host: ChatRoom.Host, roomID: Int, messageID: Int)]? ?? [] 55 | 56 | let likelihood: Int 57 | 58 | if let l = json["l"] as? Int { 59 | likelihood = l 60 | } else { 61 | likelihood = (json["d"] as? Int) ?? -1 62 | } 63 | 64 | let why = json["w"] as? String 65 | 66 | self.init( 67 | id: id, 68 | when: Date(timeIntervalSince1970: TimeInterval(when)), 69 | likelihood: likelihood, 70 | difference: (json["d"] as? Int), 71 | messages: messages, 72 | details: why 73 | ) 74 | } 75 | 76 | var json: [String:Any] { 77 | var result = [String:Any]() 78 | result["id"] = id 79 | result["t"] = Int(when.timeIntervalSince1970) 80 | result["l"] = likelihood 81 | if let d = difference { 82 | result["d"] = d 83 | } 84 | 85 | if let w = details { 86 | result["w"] = w 87 | } 88 | 89 | 90 | result["m"] = messages.map { 91 | ["h":$0.host.rawValue, "r":$0.roomID, "m":$0.messageID] 92 | } 93 | 94 | return result 95 | } 96 | } 97 | 98 | var reportedPosts = [Report]() 99 | 100 | class Reporter { 101 | let rooms: [ChatRoom] 102 | let trollRooms: [ChatRoom] 103 | 104 | enum TrollSites { 105 | case sites([Site]) 106 | case all 107 | 108 | mutating func add(_ site: Site) { 109 | switch self { 110 | case .all: break 111 | case .sites(let sites): self = .sites(sites + [site]) 112 | } 113 | } 114 | 115 | func contains(_ site: Site) -> Bool { 116 | switch self { 117 | case .all: return true 118 | case .sites(let sites): return sites.contains(site) 119 | } 120 | } 121 | 122 | func contains(apiSiteParameter: String) -> Bool { 123 | switch self { 124 | case .all: return true 125 | case .sites(let sites): return sites.contains { $0.apiSiteParameter == apiSiteParameter } 126 | } 127 | } 128 | } 129 | 130 | var trollSites: TrollSites = .sites([]) 131 | 132 | var staticDB: DatabaseConnection 133 | 134 | var blacklistManager: BlacklistManager 135 | var trollBlacklistManager: BlacklistManager 136 | 137 | let postFetcher: PostFetcher 138 | let postScanner: PostScanner 139 | let trollScanner: PostScanner 140 | 141 | private let queue = DispatchQueue(label: "Reporter queue") 142 | 143 | 144 | /*func filter(ofType type: T.Type) -> T? { 145 | for filter in filters { 146 | if let f = filter as? T { 147 | return f 148 | } 149 | } 150 | return nil 151 | }*/ 152 | 153 | init(rooms: [ChatRoom], trollRooms: [ChatRoom] = []) { 154 | print ("Reporter loading...") 155 | 156 | self.rooms = rooms 157 | self.trollRooms = trollRooms 158 | 159 | let blacklistURL = saveDirURL.appendingPathComponent("blacklists.json") 160 | trollBlacklistManager = BlacklistManager() 161 | do { 162 | blacklistManager = try BlacklistManager(url: blacklistURL) 163 | } catch { 164 | handleError(error, "while loading blacklists") 165 | print("Loading an empty blacklist.") 166 | blacklistManager = BlacklistManager() 167 | if FileManager.default.fileExists(atPath: blacklistURL.path) { 168 | print("Backing up blacklists.json.") 169 | do { 170 | try FileManager.default.moveItem(at: blacklistURL, to: saveDirURL.appendingPathComponent("blacklist.json.bak")) 171 | } catch { 172 | handleError(error, "while backing up the blacklists") 173 | } 174 | } 175 | } 176 | 177 | let reportsURL = saveDirURL.appendingPathComponent("reports.json") 178 | let usernameURL = saveDirURL.appendingPathComponent("blacklisted_users.json") 179 | do { 180 | let reportData = try Data(contentsOf: reportsURL) 181 | guard let reports = try JSONSerialization.jsonObject(with: reportData, options: []) as? [[String:Any]] else { 182 | throw ReportsLoadingError.ReportsNotArrayOfDictionaries 183 | } 184 | 185 | reportedPosts = try reports.map { 186 | guard let report = Report(json: $0) else { 187 | throw ReportsLoadingError.InvalidReport(report: $0) 188 | } 189 | return report 190 | } 191 | 192 | } catch { 193 | handleError(error, "while loading reports") 194 | print("Loading an empty report list.") 195 | if FileManager.default.fileExists(atPath: reportsURL.path) { 196 | print("Backing up reports.json.") 197 | do { 198 | try FileManager.default.moveItem(at: usernameURL, to: saveDirURL.appendingPathComponent("reports.json.bak")) 199 | } catch { 200 | handleError(error, "while backing up the reports") 201 | } 202 | } 203 | } 204 | 205 | do { 206 | staticDB = try DatabaseConnection("filter_static.sqlite") 207 | } catch { 208 | fatalError("Could not load filter_static.sqlite:\n\(error)") 209 | } 210 | 211 | postFetcher = PostFetcher(apiClient: apiClient, callback: {_,_ in }) 212 | postScanner = PostScanner(filters: []) 213 | trollScanner = PostScanner(filters: []) 214 | 215 | postFetcher.callback = { [weak self] in try self?.checkAndReport(post: $0, site: $1) } 216 | 217 | postScanner.filters = [ 218 | //FilterNaiveBayes(reporter: self), 219 | //FilterNonEnglishPost(), 220 | //FilterMisleadingLinks(), 221 | FilterBlacklistedKeyword(reporter: self), 222 | FilterBlacklistedUsername(reporter: self), 223 | FilterBlacklistedTag(reporter: self), 224 | //FilterImageWithoutCode(), 225 | //FilterLowLength(), 226 | //FilterCodeWithoutExplanation() 227 | ] 228 | trollScanner.filters = [ 229 | FilterBlacklistedKeyword(reporter: self, troll: true), 230 | FilterBlacklistedUsername(reporter: self, troll: true), 231 | FilterBlacklistedTag(reporter: self, troll: true) 232 | ] 233 | } 234 | 235 | func check(post: Post, site: String) throws -> [FilterResult] { 236 | let scanner = trollSites.contains(apiSiteParameter: site) ? trollScanner : postScanner 237 | return try scanner.scan(post: post, site: site) 238 | } 239 | 240 | @discardableResult func checkAndReport(post: Post, site: String) throws -> ReportResult { 241 | let results = try check(post: post, site: site) 242 | 243 | return try report(post: post, site: site, reasons: results) 244 | } 245 | 246 | struct ReportResult { 247 | enum Status { 248 | case notBad //the post was not bad 249 | case alreadyClosed //the post is already closed 250 | case alreadyReported //the post was recently reported 251 | case reported 252 | } 253 | var status: Status 254 | var filterResults: [FilterResult] 255 | } 256 | 257 | enum ReportsLoadingError: Error { 258 | case ReportsNotArrayOfDictionaries 259 | case InvalidReport(report: [String:Any]) 260 | } 261 | 262 | func saveReports() throws { 263 | let data = try JSONSerialization.data( 264 | withJSONObject: reportedPosts.map { $0.json } 265 | ) 266 | 267 | try data.write(to: saveDirURL.appendingPathComponent("reports.json")) 268 | } 269 | 270 | enum ReportError: Error { 271 | case missingSite(id: Int) 272 | } 273 | 274 | ///Reports a post if it has not been recently reported. Returns either .reported or .alreadyReported. 275 | func report(post: Post, site apiSiteParameter: String, reasons: [FilterResult]) throws -> ReportResult { 276 | /*guard let site = try Site.with(apiSiteParameter: apiSiteParameter, db: staticDB) else { 277 | return ReportResult(status: .notBad, filterResults: reasons) 278 | }*/ 279 | 280 | var status: ReportResult.Status = .notBad 281 | 282 | queue.sync { 283 | guard let id = post.id else { 284 | print("No post ID!") 285 | status = .notBad 286 | return 287 | } 288 | 289 | let isManualReport = reasons.contains { 290 | if case .manuallyReported = $0.type { 291 | return true 292 | } else { 293 | return false 294 | } 295 | } 296 | 297 | if !isManualReport && reportedPosts.lazy.reversed().contains(where: { $0.id == id }) { 298 | print("Not reporting \(id) because it was recently reported.") 299 | status = .alreadyReported 300 | return 301 | } 302 | 303 | guard let link = post.share_link else { 304 | print("Not reporting \(id) because it has no link.") 305 | return 306 | } 307 | 308 | /*if !isManualReport && post.closed_reason != nil { 309 | print ("Not reporting \(post.id ?? 0) as it is closed.") 310 | status = .alreadyClosed 311 | return 312 | }*/ 313 | 314 | var reported = false 315 | var postDetails = "Details unknown." 316 | var bayesianDifference: Int? 317 | 318 | var title = "\(post.title ?? "")" 319 | .replacingOccurrences(of: "[", with: "\\[") 320 | .replacingOccurrences(of: "]", with: "\\]") 321 | 322 | while title.hasSuffix("\\") { 323 | title = String(title.dropLast()) 324 | } 325 | 326 | let tags = (post as? Question)?.tags ?? [] 327 | postDetails = reasons.map {$0.details ?? "Details unknown."}.joined (separator: ", ") 328 | 329 | var messages: [(host: ChatRoom.Host, roomID: Int, messageID: Int)] = [] 330 | 331 | let sema = DispatchSemaphore(value: 0) 332 | 333 | let rooms = self.rooms //let rooms: [ChatRoom] = trollSites.contains(site) ? self.trollRooms : self.rooms 334 | var bonfireLink: String? 335 | 336 | //Post weight including custom filter weight subtracted from Naive Bayes difference. 337 | var combinedPostWeight = 0 338 | var customFilterExists = false 339 | for reason in reasons { 340 | if case .bayesianFilter(let difference) = reason.type { 341 | bayesianDifference = difference 342 | combinedPostWeight += difference 343 | } else if case .customFilterWithWeight(_, let weight) = reason.type { 344 | combinedPostWeight -= weight 345 | } else if case .customFilter(_) = reason.type { 346 | customFilterExists = true 347 | } 348 | } 349 | 350 | //I've put some restriction in Bonfire so that erroneous data is caught. 351 | if combinedPostWeight < -1 || customFilterExists { 352 | combinedPostWeight = -1 353 | } 354 | 355 | for room in rooms { 356 | //Filter out weights which are less than this room's threshold. 357 | /*let reasons = reasons.filter { 358 | if case .bayesianFilter(_) = $0.type { 359 | return combinedPostWeight < room.thresholds[site.id] ?? Int.min 360 | } else if case .customFilterWithWeight(_, _) = $0.type { 361 | return combinedPostWeight < room.thresholds[site.id] ?? Int.min 362 | } 363 | return true 364 | }*/ 365 | 366 | if reasons.isEmpty { 367 | sema.signal() 368 | continue 369 | } 370 | 371 | reported = true 372 | 373 | if bonfireLink == nil { 374 | do { 375 | bonfireLink = try bonfire?.uploadPost(post: post, postDetails: postDetails, likelihood: combinedPostWeight) 376 | } catch { 377 | print("Could not upload the post to Bonfire!") 378 | print(error) 379 | } 380 | } 381 | 382 | let header = reasons.map { $0.header }.joined(separator: ", ") 383 | let message: String 384 | 385 | var tagStr: String 386 | if let tag = tags.first { tagStr = "[tag:\(tag)] " } 387 | else { tagStr = "" } 388 | 389 | if let bonfireLink = bonfireLink { 390 | message = "[ [\(botName)](\(stackAppsLink)) | [Bonfire](\(bonfireLink)) ] " + 391 | "\(tagStr)\(header) [\(title)](\(link)) " + 392 | room.notificationString(tags: tags, reasons: reasons) 393 | } else { 394 | message = "[ [\(botName)](\(stackAppsLink)) ] " + 395 | "\(tagStr)\(header) [\(title)](\(link)) " + 396 | room.notificationString(tags: tags, reasons: reasons) 397 | } 398 | 399 | room.postMessage(message, completion: {message in 400 | if let message = message { 401 | messages.append((host: room.host, roomID: room.roomID, messageID: message)) 402 | } 403 | sema.signal() 404 | }) 405 | } 406 | rooms.forEach { _ in sema.wait() } 407 | 408 | 409 | if reported { 410 | let report = Report( 411 | id: id, 412 | when: Date(), 413 | likelihood: combinedPostWeight, 414 | difference: bayesianDifference, 415 | messages: messages, 416 | details: postDetails 417 | ) 418 | 419 | reportedPosts.append(report) 420 | 421 | status = .reported 422 | return 423 | } else { 424 | status = .notBad 425 | return 426 | } 427 | } 428 | 429 | return ReportResult(status: status, filterResults: reasons) 430 | } 431 | } 432 | -------------------------------------------------------------------------------- /Sources/Frontend/RowDecoder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RowDecoder.swift 3 | // ORMTests 4 | // 5 | // Created by NobodyNada on 7/17/17. 6 | // 7 | 8 | import Foundation 9 | import SwiftChatSE 10 | 11 | public class RowDecoder { 12 | public enum DecodingError: Error { 13 | case unexpectedNull 14 | } 15 | 16 | public init() {} 17 | 18 | public func decode(_ type: T.Type, from row: Row) throws -> T{ 19 | return try T.init(from: _RowDecoder(row: row)) 20 | } 21 | } 22 | 23 | 24 | private class _RowDecoder: Decoder { 25 | var codingPath: [CodingKey] = [] 26 | var userInfo: [CodingUserInfoKey : Any] = [:] 27 | 28 | var row: Row 29 | init(row: Row) { 30 | self.row = row 31 | } 32 | 33 | func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key : CodingKey { 34 | return KeyedDecodingContainer(KeyedDecoder(decoder: self, codingPath: codingPath)) 35 | } 36 | 37 | func unkeyedContainer() throws -> UnkeyedDecodingContainer { 38 | nestedObject() 39 | } 40 | 41 | func singleValueContainer() throws -> SingleValueDecodingContainer { 42 | guard !codingPath.isEmpty else { 43 | fatalError("Root object for a database row must be a dictionary") 44 | } 45 | 46 | return SingleValueDecoder(decoder: self, key: codingPath.last!) 47 | } 48 | } 49 | 50 | 51 | private class KeyedDecoder: KeyedDecodingContainerProtocol { 52 | var codingPath: [CodingKey] 53 | var allKeys: [K] { 54 | return decoder.row.columnNames.keys.compactMap { K(stringValue: $0) } 55 | } 56 | 57 | let decoder: _RowDecoder 58 | init(decoder: _RowDecoder, codingPath: [CodingKey]) { 59 | self.decoder = decoder 60 | self.codingPath = codingPath 61 | } 62 | 63 | 64 | func contains(_ key: K) -> Bool { 65 | return allKeys.contains { $0.stringValue == key.stringValue } 66 | } 67 | 68 | func decodeNil(forKey key: K) throws -> Bool { 69 | guard let columnIndex = decoder.row.columnNames[key.stringValue] else { 70 | throw DecodingError.keyNotFound(key, .init( 71 | codingPath: codingPath, 72 | debugDescription: "Key \(key) not found (valid keys are \(decoder.row.columnNames.keys)" 73 | ) 74 | ) 75 | } 76 | return decoder.row.columns[columnIndex] == nil 77 | } 78 | 79 | func decode(_ type: T.Type, forKey key: K) throws -> T { 80 | guard let result = decoder.row.column(named: key.stringValue) as T? else { 81 | throw DecodingError.valueNotFound(T.self, DecodingError.Context(codingPath: codingPath, debugDescription: "key \(key) has a null value")) 82 | } 83 | return result 84 | } 85 | 86 | func decode(_ type: T.Type, forKey key: K) throws -> T { 87 | guard let result = decoder.row.column(named: key.stringValue) as T? else { 88 | throw DecodingError.valueNotFound(T.self, DecodingError.Context(codingPath: codingPath, debugDescription: "key \(key) has a null value")) 89 | } 90 | return result 91 | } 92 | 93 | func decode(_ type: T.Type, forKey key: K) throws -> T { 94 | if T.self == Data.self { //Not sure why this is needed; seems like Data will only call this overload 95 | return try decode(Data.self, forKey: key) as! T 96 | } 97 | 98 | decoder.codingPath.append(key) 99 | let result = try type.init(from: decoder) 100 | decoder.codingPath.removeLast() 101 | return result 102 | } 103 | 104 | func nestedContainer(keyedBy type: NestedKey.Type, forKey key: K) throws -> KeyedDecodingContainer where NestedKey : CodingKey { 105 | nestedObject() 106 | } 107 | 108 | func nestedUnkeyedContainer(forKey key: K) throws -> UnkeyedDecodingContainer { 109 | nestedObject() 110 | } 111 | 112 | func superDecoder() throws -> Decoder { 113 | nestedObject() 114 | } 115 | 116 | func superDecoder(forKey key: K) throws -> Decoder { 117 | nestedObject() 118 | } 119 | 120 | typealias Key = K 121 | } 122 | 123 | 124 | private class SingleValueDecoder: SingleValueDecodingContainer { 125 | let decoder: _RowDecoder 126 | let key: CodingKey 127 | var codingPath: [CodingKey] { return decoder.codingPath } 128 | 129 | init(decoder: _RowDecoder, key: CodingKey) { 130 | self.decoder = decoder 131 | self.key = key 132 | } 133 | 134 | func decodeNil() -> Bool { 135 | if let index = decoder.row.columnNames[key.stringValue] { 136 | return decoder.row.columns[index] != nil 137 | } 138 | return true 139 | } 140 | 141 | func decode(_ type: T.Type) throws -> T { 142 | guard let result = decoder.row.column(named: key.stringValue) as T? else { 143 | throw RowDecoder.DecodingError.unexpectedNull 144 | } 145 | 146 | return result 147 | } 148 | 149 | func decode(_ type: T.Type) throws -> T { 150 | fatalError("Single-value containers may only decode DatabaseTypes") 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /Sources/Frontend/RowEncoder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RowEncoder.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 7/14/17. 6 | // 7 | 8 | import Foundation 9 | import SwiftChatSE 10 | 11 | internal func nestedObject() -> Never { 12 | fatalError("Arrays and nested dictionaries may not be encoded into database rows") 13 | } 14 | 15 | public class RowEncoder { 16 | public init() {} 17 | 18 | public func encode(_ value: Codable) throws -> Row { 19 | let encoder = _RowEncoder() 20 | try value.encode(to: encoder) 21 | 22 | var columns = [DatabaseNativeType?]() 23 | var columnIndices = [String:Int]() 24 | for (key, value) in encoder.storage { 25 | columnIndices[key] = columns.count 26 | columns.append(value?.asNative) 27 | } 28 | 29 | return Row(columns: columns, columnNames: columnIndices) 30 | } 31 | } 32 | 33 | 34 | private class _RowEncoder: Encoder { 35 | var codingPath: [CodingKey] = [] 36 | var userInfo: [CodingUserInfoKey : Any] = [:] 37 | 38 | var storage: [String:DatabaseType?] = [:] 39 | 40 | func container(keyedBy type: Key.Type) -> KeyedEncodingContainer where Key : CodingKey { 41 | return KeyedEncodingContainer(_KeyedEncoder(encoder: self, codingPath: codingPath)) 42 | } 43 | 44 | func unkeyedContainer() -> UnkeyedEncodingContainer { 45 | nestedObject() 46 | } 47 | 48 | func singleValueContainer() -> SingleValueEncodingContainer { 49 | guard !codingPath.isEmpty else { 50 | fatalError("Root object for a database row must be a dictionary") 51 | } 52 | 53 | return _SingleValueContainer(encoder: self, key: codingPath.last!, codingPath: codingPath) 54 | } 55 | } 56 | 57 | 58 | private class _KeyedEncoder: KeyedEncodingContainerProtocol { 59 | typealias Key = K 60 | let encoder: _RowEncoder 61 | let codingPath: [CodingKey] 62 | 63 | init(encoder: _RowEncoder, codingPath: [CodingKey]) { 64 | self.encoder = encoder 65 | self.codingPath = codingPath 66 | } 67 | 68 | func encodeNil(forKey key: K) throws { 69 | encoder.storage[key.stringValue] = nil as DatabaseType? 70 | } 71 | 72 | func encode(_ value: DatabaseType, forKey key: K) throws { 73 | encoder.storage[key.stringValue] = value 74 | } 75 | 76 | func encode(_ value: T, forKey key: K) throws where T : Encodable { 77 | if let v = value as? DatabaseType { 78 | try encode(v, forKey: key) 79 | return 80 | } 81 | 82 | encoder.codingPath.append(key) 83 | try value.encode(to: encoder) 84 | encoder.codingPath.removeLast() 85 | } 86 | 87 | func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: K) -> KeyedEncodingContainer { 88 | nestedObject() 89 | } 90 | 91 | func nestedUnkeyedContainer(forKey key: K) -> UnkeyedEncodingContainer { 92 | nestedObject() 93 | } 94 | 95 | func superEncoder() -> Encoder { 96 | nestedObject() 97 | } 98 | 99 | func superEncoder(forKey key: K) -> Encoder { 100 | nestedObject() 101 | } 102 | } 103 | 104 | 105 | private class _SingleValueContainer: SingleValueEncodingContainer { 106 | let encoder: _RowEncoder 107 | let key: CodingKey 108 | let codingPath: [CodingKey] 109 | 110 | init(encoder: _RowEncoder, key: CodingKey, codingPath: [CodingKey]) { 111 | self.encoder = encoder 112 | self.key = key 113 | self.codingPath = codingPath 114 | } 115 | 116 | 117 | func encodeNil() throws { 118 | try encode(nil as DatabaseType?) 119 | } 120 | 121 | func encode(_ value: DatabaseType?) throws { 122 | encoder.storage[key.stringValue] = value 123 | } 124 | 125 | func encode(_ value: T) throws where T : Encodable { 126 | fatalError("Single-value containers may only encode DatabaseTypes") 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /Sources/Frontend/Secrets.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Secrets.swift 3 | // FireAlarm 4 | // 5 | // Created by Jonathan Keller on 6/8/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | 11 | struct Secrets { 12 | let email: String? 13 | let password: String? 14 | let githubWebhookSecret: String? 15 | let bonfireKey: String? 16 | 17 | init?(json: [String:String]) { 18 | email = json["email"] 19 | password = json["password"] 20 | githubWebhookSecret = json["githubWebhookSecret"] 21 | bonfireKey = json["bonfireKey"] 22 | } 23 | } 24 | 25 | var secrets: Secrets! 26 | -------------------------------------------------------------------------------- /Sources/Frontend/TrainWrecker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrainWrecker.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 7/7/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | 12 | let editDistanceWeight: Float = 1.6 //How quickly edit distance causes confidence to drop. 13 | let confidenceWeight: Float = 1.1 //How quickly confidence increases the chance of wrecking a train. 14 | let minThreshold = 32 15 | let maxThreshold = 96 16 | 17 | let trains = [ //From https://github.com/Tiny-Giant/myuserscripts/blob/master/Trainwreck.user.js 18 | "https://upload.wikimedia.org/wikipedia/commons/1/19/Train_wreck_at_Montparnasse_1895.jpg", 19 | "https://s3.amazonaws.com/img.ultrasignup.com/events/raw/6a76f4a3-4ad2-4ae2-8a3b-c092e85586af.jpg", 20 | "https://kassontrainwreck.files.wordpress.com/2015/03/cropped-trainwreck.jpg", 21 | "http://web.archive.org/web/20160426074422if_/http://www.ncbam.org/images/photos/train-wreck.jpg", 22 | "http://oralfixationshow.com/wp-content/uploads/2014/09/train-wreck.jpg", 23 | "http://experiencedynamics.blogs.com/.a/6a00d8345a66bf69e201901ed419a4970b-pi", 24 | "https://timedotcom.files.wordpress.com/2015/05/150513-1943-train-wreck-02.jpg?quality=75&strip=color&w=573", 25 | "http://static6.businessinsider.com/image/5554e92369bedd8f33c45a0d/heres-everything-we-know-about-the-amtrak-train-wreck-in-philadelphia.jpg", 26 | "http://goldsilverworlds.com/wp-content/uploads/2015/07/trainwreck.jpg", 27 | "http://allthingsd.com/files/2012/06/trainwreck.jpg", 28 | "http://sailinganarchy.com/wp-content/uploads/2015/09/trainwreck.jpg", 29 | "http://iamphildodd.com/wp-content/uploads/2013/12/trainwreck1.jpg", 30 | "http://cdn.theatlantic.com/assets/media/img/posts/2013/11/758px_Train_Wreck_1922/56ae9c9fc.jpg", 31 | "http://static.messynessychic.com/wp-content/uploads/2012/10/trainwreck.jpg", 32 | "http://www.baycolonyrailtrail.org/gallery2/d/1282-3/trainwreck.jpg", 33 | "http://trainwreckwinery.com/wp-content/uploads/2012/05/trainwreckTHEN.jpg", 34 | "http://www.skvarch.com/images/trains/trainwreck.jpg", 35 | "http://conselium.com/wp-content/uploads/train-wreck.jpg", 36 | "http://imgs.sfgate.com/blogs/images/sfgate/bmangan/2010/10/18/trainwreck.jpg", 37 | "https://img1.etsystatic.com/043/0/7724935/il_fullxfull.613833437_37a5.jpg", 38 | "http://ncpedia.org/sites/default/files//bostian_wreck.jpg", 39 | "https://upload.wikimedia.org/wikipedia/commons/9/9f/NewMarketTrainWreck.jpg", 40 | "https://ethicsalarms.files.wordpress.com/2015/04/train-wrecks-accidents.jpg", 41 | "http://offbeatoregon.com/Images/H1002b_General/BridgeWreck1800.jpg", 42 | "http://www3.gendisasters.com/files/newphotos/Naperville%20IL%20Train%20Wreck%204-26-1946.JPG", 43 | "http://static01.nyt.com/images/2011/07/25/world/25china-span/25china-span-articleLarge.jpg", 44 | "http://shorespeak.com/blog/wp-content/uploads/2011/01/train_wreck_2.jpg", 45 | "https://web.archive.org/web/20101105175703if_/http://cfm-fmh.org/files/QuickSiteImages/MuseumPhotos/Train_Wreck.jpg", 46 | "https://web.archive.org/web/20180320014225if_/http://www.circusesandsideshows.com/images/algbarnestrainwrecklarge.jpg", 47 | "http://www.scitechantiques.com/trainwreck/trainwreck.jpg", 48 | "http://www3.gendisasters.com/files/newphotos/nj-woodbridge-trainwreck3r.jpg", 49 | "http://travel.gunaxin.com/wp-content/uploads/2010/07/Ep9_TrainWreck.jpg" 50 | ] 51 | 52 | class TrainWrecker { 53 | let room: ChatRoom 54 | init(room: ChatRoom) { 55 | self.room = room 56 | } 57 | 58 | var lastMessage: String? = nil 59 | var confidence: Float = 1 //How confindent we are that a train is in progress, 60 | //with 1 being the lowest possible confidence. 61 | 62 | func randomNumber(min: Int, max: Int) -> Int { 63 | #if os(Linux) 64 | return (Int(rand()) % (max + 1 - min)) + min 65 | #else 66 | return Int(arc4random_uniform(UInt32(max + 1 - min))) + min 67 | #endif 68 | } 69 | 70 | func process(message: ChatMessage) { 71 | defer { lastMessage = message.content } 72 | guard let last = lastMessage else { return } 73 | 74 | let editDistance = Levenshtein.distanceBetween(message.content, and: last) 75 | confidence = max(1, confidence * (-pow(editDistanceWeight, Float(editDistance)) + 6)) 76 | 77 | let threshold = randomNumber(min: minThreshold, max: maxThreshold) 78 | 79 | if Float(threshold) < pow(confidence, confidenceWeight) { 80 | if room.roomID == 41570 { 81 | var calendar = Calendar(identifier: .gregorian) 82 | calendar.timeZone = TimeZone(abbreviation: "UTC")! 83 | 84 | if !calendar.isDateInWeekend(Date()) { 85 | print("Not wrecking train, since it is in SOCVR and it is not a weekend.") 86 | return 87 | } 88 | } 89 | 90 | print("Wrecking train in \(room.roomID) (confidence \(confidence)); threshold \(threshold).") 91 | let train = trains[randomNumber(min: 0, max: trains.count - 1)] 92 | room.postMessage("[#RekdTrain](\(train))") 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Sources/Frontend/Utilities.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Utilities.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 4/18/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftStack 11 | import SwiftChatSE 12 | import Dispatch 13 | import FireAlarmCore 14 | 15 | let saveDirURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) 16 | 17 | var startTime = Date() 18 | 19 | var botName = "FireAlarm" 20 | var githubLink = "//github.com/SOBotics/FireAlarm/tree/swift" 21 | var stackAppsLink = "//stackapps.com/q/7183" 22 | var reportHeader = "[ [\(botName)](//stackapps.com/q/7183) ]" 23 | 24 | var currentVersion = "" 25 | var shortVersion = "" 26 | var versionLink = githubLink 27 | 28 | var location = "" 29 | var user = "" 30 | var device = "" 31 | 32 | var originalWorkingDirectory: String! 33 | 34 | 35 | let updateBranch = "master" 36 | 37 | var development = false 38 | var noUpdate = false 39 | 40 | extension ChatUser { 41 | enum NotificationReason { 42 | case misleadingLinks 43 | case blacklist(BlacklistManager.BlacklistType) 44 | case tag(String) 45 | case all 46 | 47 | var asString: String { 48 | switch self { 49 | case .misleadingLinks: return "misleading_links" 50 | case .blacklist(let list): return "blacklist:\(list.rawValue)" 51 | case .tag(let tag): return "tag:\(tag)" 52 | case .all: return "all" 53 | } 54 | } 55 | init?(string: String) { 56 | if string == "misleading_links" { self = .misleadingLinks } 57 | else if string == "all" { self = .all } 58 | else { 59 | let components = string.components(separatedBy: ":") 60 | guard components.count >= 2 else { return nil } 61 | let type = components.first! 62 | let value = components.dropFirst().joined(separator: ":") 63 | switch type { 64 | case "blacklist": 65 | if let result = BlacklistManager.BlacklistType(rawValue: value).map(NotificationReason.blacklist) { 66 | self = result 67 | } else { 68 | return nil 69 | } 70 | case "tag": 71 | self = .tag(value) 72 | default: 73 | return nil 74 | } 75 | } 76 | } 77 | 78 | var description: String { 79 | switch self { 80 | case .misleadingLinks: return "misleading link" 81 | case .blacklist(let list): return "blacklisted \(list.rawValue)" 82 | case .tag(let tag): return "\(tag)" 83 | case .all: return "all reports" 84 | } 85 | } 86 | } 87 | var notificationReasons: [NotificationReason] { 88 | get { 89 | return (info["notificationReasons"] as? [String])?.compactMap { NotificationReason(string: $0) } ?? [] 90 | } set { 91 | info["notificationReasons"] = newValue.map { $0.asString } 92 | } 93 | } 94 | } 95 | 96 | extension ChatRoom { 97 | func notificationString(tags: [String], reasons: [FilterResult]) -> String { 98 | var users = [ChatUser]() 99 | for user in userDB { 100 | let shouldNotify = user.notificationReasons.contains { notificationReason in 101 | switch notificationReason { 102 | case .all: return true 103 | case .blacklist(let type): 104 | return reasons.contains { 105 | if case .customFilter(let filter) = $0.type { 106 | return (filter as? BlacklistFilter).map { $0.blacklistType == type } ?? false 107 | } else { 108 | return false 109 | } 110 | } 111 | case .misleadingLinks: 112 | return reasons.contains { 113 | if case .customFilter(let filter) = $0.type { 114 | return filter is FilterMisleadingLinks 115 | } else { 116 | return false 117 | } 118 | } 119 | case .tag(let tag): 120 | return tags.contains(tag) 121 | } 122 | } 123 | 124 | if shouldNotify { 125 | users.append(user) 126 | } 127 | } 128 | 129 | return users.map { "@" + $0.name.replacingOccurrences(of: " ", with: "") }.joined(separator: " ") 130 | } 131 | 132 | 133 | private func stringify(thresholds: [Int:Int]) -> [String:Int] { 134 | var result = [String:Int]() 135 | for (key, value) in thresholds { 136 | result[String(key)] = value 137 | } 138 | return result 139 | } 140 | 141 | private func destringify(thresholds: [String:Int]) -> [Int:Int] { 142 | var result = [Int:Int]() 143 | for (key, value) in thresholds { 144 | if let siteID = Int(key) { 145 | result[siteID] = value 146 | } 147 | } 148 | return result 149 | } 150 | 151 | ///A dictionary mapping sites to thresholds. 152 | var thresholds: [Int:Int] { 153 | get { 154 | return (info["thresholds"] as? [String:Int]).map(destringify) ?? (info["threshold"] as? Int).map { [2:$0] } ?? [:] 155 | } set { 156 | info["thresholds"] = stringify(thresholds: newValue) 157 | } 158 | } 159 | 160 | convenience init(client: Client, host: Host, roomID: Int, thresholds: [Int:Int]) { 161 | self.init(client: client, host: host, roomID: roomID) 162 | self.thresholds = thresholds 163 | } 164 | } 165 | 166 | extension ChatUser.Privileges { 167 | static let filter = ChatUser.Privileges(rawValue: 1 << 1) 168 | } 169 | 170 | func addPrivileges() { 171 | ChatUser.Privileges.add(name: "Filter", for: .filter) 172 | } 173 | 174 | func run(command: String, printOutput: Bool = true) -> (exitCode: Int, stdout: String?, stderr: String?, combinedOutput: String?) { 175 | let process = Process() 176 | process.launchPath = "/usr/bin/env" 177 | process.arguments = ["bash", "-c", command] 178 | 179 | let stdoutPipe = Pipe() 180 | let stderrPipe = Pipe() 181 | process.standardOutput = stdoutPipe 182 | process.standardError = stderrPipe 183 | 184 | var stdout = Data() 185 | var stderr = Data() 186 | var combined = Data() 187 | 188 | let queue = DispatchQueue(label: "org.SOBotics.firealarm.commandOutputQueue") 189 | let stdoutSource = DispatchSource.makeReadSource(fileDescriptor: stdoutPipe.fileHandleForReading.fileDescriptor) 190 | let stderrSource = DispatchSource.makeReadSource(fileDescriptor: stderrPipe.fileHandleForReading.fileDescriptor) 191 | 192 | stdoutSource.setEventHandler { 193 | queue.sync { 194 | let data = stdoutPipe.fileHandleForReading.availableData 195 | stdout += data 196 | combined += data 197 | if printOutput { 198 | FileHandle.standardError.write(data) 199 | } 200 | } 201 | } 202 | stderrSource.setEventHandler { 203 | queue.sync { 204 | let data = stderrPipe.fileHandleForReading.availableData 205 | stderr += data 206 | combined += data 207 | if printOutput { 208 | FileHandle.standardOutput.write(data) 209 | } 210 | } 211 | } 212 | 213 | stdoutSource.resume() 214 | stderrSource.resume() 215 | 216 | process.launch() 217 | process.waitUntilExit() 218 | 219 | queue.sync { 220 | stdoutSource.cancel() 221 | stderrSource.cancel() 222 | } 223 | queue.sync { 224 | stdoutPipe.fileHandleForReading.closeFile() 225 | stderrPipe.fileHandleForReading.closeFile() 226 | } 227 | 228 | let stdoutString = String(data: stdout, encoding: .utf8) 229 | let stderrString = String(data: stderr, encoding: .utf8) 230 | let combinedString = String(data: combined, encoding: .utf8) 231 | return (exitCode: Int(process.terminationStatus), stdout: stdoutString, stderr: stderrString, combinedOutput: combinedString) 232 | } 233 | -------------------------------------------------------------------------------- /Sources/Frontend/WebhookHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebhookHandler.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 6/8/17. 6 | // 7 | // 8 | 9 | import CryptoSwift 10 | import SwiftChatSE 11 | import FireAlarmCore 12 | 13 | class WebhookHandler { 14 | enum GithubWebhookError: Error { 15 | case invalidSignature 16 | case invalidPayload(payload: String) 17 | } 18 | enum UpdateToWebhookError: Error { 19 | case invalidToken 20 | case invalidPayload 21 | } 22 | 23 | let githubSecret: String 24 | 25 | init(githubSecret: String) { 26 | self.githubSecret = githubSecret 27 | } 28 | 29 | //A closure to be called when CI succeeds. The closure is passed the repo name, commit branhces, and commit SHA. 30 | var successHandler: ((String, [String], String) -> ())? 31 | 32 | //A closure to be called when an update_to event is recievd. The closure is passed the commit SHA. 33 | var updateHandler: ((String) -> ())? 34 | 35 | func onSuccess(_ handler: ((String, [String], String) -> ())?) { successHandler = handler } 36 | func onUpdate(_ handler: ((String) -> ())?) { updateHandler = handler } 37 | 38 | 39 | func process(event: Redunda.Event, rooms: [ChatRoom]) throws { 40 | let eventHandlers = [ 41 | "ci_status":processCIStatus, 42 | "update_to":processUpdateTo 43 | ] 44 | 45 | try eventHandlers[event.name]?(event, rooms) 46 | } 47 | 48 | 49 | 50 | private func processCIStatus(event: Redunda.Event, rooms: [ChatRoom]) throws { 51 | let hmac = try HMAC(key: githubSecret, variant: .sha1) 52 | let signature = try hmac.authenticate(event.content.data(using: .utf8)!.bytes).toHexString() 53 | guard "sha1=" + signature == event.headers["X-Hub-Signature"] else { 54 | print("Invalid event signature (expected \(signature)) for:\n\(event)") 55 | throw GithubWebhookError.invalidSignature 56 | } 57 | 58 | print("Recieved GitHub webhook event.") 59 | guard event.headers["X-Github-Event"] == "status" else { return } 60 | 61 | guard let content = try event.contentAsJSON() as? [String:Any], 62 | let commitHash = content["sha"] as? String, 63 | let state = content["state"] as? String, 64 | let repoName = content["name"] as? String, 65 | let branches = (content["branches"] as? [[String:Any]])?.compactMap({ $0["name"] as? String }) 66 | 67 | else { 68 | throw GithubWebhookError.invalidPayload(payload: event.content) 69 | } 70 | 71 | let targetURL = content["target_url"] as? String 72 | 73 | if state == "pending" { 74 | return 75 | } 76 | 77 | let repoLink = "https://github.com/\(repoName)" 78 | 79 | let header = "[ [\(repoName)](\(repoLink)) ]" 80 | let link = targetURL != nil ? "[CI](\(targetURL!))" : "CI" 81 | let status = [ 82 | "pending": "pending", 83 | "success": "succeeded", 84 | "failure": "failed", 85 | "error": "errored" 86 | ][state] ?? "status unknown" 87 | let commitLink = "[\(getShortVersion(commitHash))](\(repoLink)/commit/\(commitHash))" 88 | 89 | let message = [header, link, status, "on", commitLink].joined(separator: " ") + "." 90 | rooms.forEach { $0.postMessage(message) } 91 | 92 | if state == "success" { 93 | successHandler?(repoName, branches, commitHash) 94 | } 95 | } 96 | 97 | private func processUpdateTo(event: Redunda.Event, rooms: [ChatRoom]) throws { 98 | guard let json = try event.contentAsJSON() as? [String:Any], 99 | let commit = json["commit"] as? String else { 100 | throw UpdateToWebhookError.invalidPayload 101 | } 102 | guard json["token"] as? String == githubSecret else { 103 | throw UpdateToWebhookError.invalidToken 104 | } 105 | 106 | updateHandler?(commit) 107 | 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Sources/Frontend/main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // main.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 8/27/16. 6 | // Copyright © 2016 NobodyNada. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | import Dispatch 12 | 13 | #if os(Linux) 14 | import Glibc 15 | #endif 16 | 17 | 18 | do { 19 | try main() 20 | } catch { 21 | handleError(error, "while starting up") 22 | abort() 23 | } 24 | 25 | while true { 26 | //dispatchMain seems to be broken, so use this instead 27 | pause() 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Frontend/startup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // startup.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 4/18/17. 6 | // Copyright © 2016 NobodyNada. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Dispatch 11 | import SwiftChatSE 12 | import SwiftStack 13 | import CryptoSwift 14 | import FireAlarmCore 15 | 16 | let commands: [Command.Type] = [ 17 | CommandCheckThreshold.self, CommandSetThreshold.self, CommandCheckSites.self, CommandAddSite.self, CommandRemoveSite.self, 18 | CommandSay.self, CommandDeleteMessage.self, 19 | CommandHelp.self, CommandListRunning.self, CommandStop.self, CommandKill.self, 20 | CommandUpdate.self, CommandStatus.self, CommandPingOnError.self, CommandGitStatus.self, 21 | CommandCheckPrivileges.self, CommandPrivilege.self, CommandUnprivilege.self, 22 | CommandCheckPost.self, CommandQuota.self, 23 | CommandBlacklist.self, CommandGetBlacklist.self, CommandUnblacklist.self, 24 | CommandOptIn.self, CommandOptOut.self, CommandCheckNotification.self, CommandLeaveRoom.self, 25 | CommandLocation.self, CommandReport.self, CommandUnclosed.self, CommandTestBayesian.self, 26 | CommandWhy.self, 27 | ] 28 | 29 | let trollCommands: [Command.Type] = [ 30 | CommandSay.self, CommandDeleteMessage.self, 31 | CommandHelp.self, CommandListRunning.self, CommandStop.self, CommandKill.self, 32 | CommandUpdate.self, CommandStatus.self, CommandPingOnError.self, CommandGitStatus.self, 33 | CommandCheckPrivileges.self, CommandPrivilege.self, CommandUnprivilege.self, 34 | CommandCheckPost.self, CommandQuota.self, 35 | TrollCommandBlacklist.self, TrollCommandGetBlacklist.self, TrollCommandUnblacklist.self, 36 | TrollCommandEnable.self, TrollCommandDisable.self, 37 | CommandOptIn.self, CommandOptOut.self, CommandCheckNotification.self, CommandLeaveRoom.self, 38 | CommandLocation.self, CommandWhy.self 39 | ] 40 | 41 | 42 | fileprivate var listener: ChatListener! 43 | 44 | var reporter: Reporter! 45 | 46 | var redunda: Redunda? 47 | var bonfire: Bonfire? 48 | 49 | //var apiClient = APIClient(proxyAddress: "127.0.0.1", proxyPort: 8080) 50 | var apiClient = APIClient() 51 | 52 | func main() throws { 53 | print("FireAlarm starting...") 54 | startTime = Date() 55 | afterTooManyErrors = {} 56 | addPrivileges() 57 | 58 | noUpdate = ProcessInfo.processInfo.arguments.contains("--noupdate") 59 | 60 | saveURL = saveDirURL 61 | 62 | apiClient.key = "HNA2dbrFtyTZxeHN6rThNg((" 63 | apiClient.defaultFilter = "!*2TT1aq3F80e34)G*C84ugY4)D53V1hQSOTMtjcj5" 64 | 65 | let client = Client() 66 | 67 | #if os(Linux) 68 | srand(UInt32(time(nil))) //This is not cryptographically secure; it's just for train wrecking 69 | #endif 70 | 71 | if let redundaKey = try? loadFile("redunda_key.txt").trimmingCharacters(in: .whitespacesAndNewlines) { 72 | //standby until Redunda tells us not to 73 | redunda = Redunda(key: redundaKey, client: client, filesToSync: [ 74 | "^reports\\.json$", "^room_\\d+_[a-z\\.]+\\.json$", "^secrets.json$", 75 | "^blacklists.json" 76 | ]) 77 | 78 | var shouldStandby = false 79 | var isFirst = true 80 | repeat { 81 | do { 82 | try redunda!.sendStatusPing() 83 | if redunda!.shouldStandby { 84 | shouldStandby = true 85 | if isFirst { 86 | print("FireAlarm started in standby mode.") 87 | } 88 | isFirst = false 89 | sleep(30) 90 | } else { 91 | shouldStandby = false 92 | } 93 | } catch { 94 | handleError(error, "while sending a status ping to Redunda") 95 | } 96 | } while shouldStandby 97 | if !isFirst { 98 | print("FireAlarm activating...") 99 | } 100 | 101 | do { 102 | try redunda!.downloadFiles() 103 | } catch { 104 | print("Could not download files!") 105 | } 106 | } else { 107 | fputs("warning: Could not load redunda_key.txt; running without Redunda.\n", stderr) 108 | } 109 | 110 | 111 | do { 112 | guard let secretsJSON = (try JSONSerialization.jsonObject( 113 | with: Data(contentsOf: saveDirURL.appendingPathComponent("secrets.json")) 114 | ) as? [String:String]) else { 115 | fatalError("Could not load secrets: secrets.json has an invalid format") 116 | } 117 | secrets = Secrets(json: secretsJSON) 118 | } catch { 119 | fatalError("Could not load secrets: \(error)") 120 | } 121 | 122 | if let bonfireKey = secrets.bonfireKey { 123 | bonfire = Bonfire(key: bonfireKey, client: client, host: "https://bonfire.sobotics.org") 124 | } 125 | 126 | print("Decompressing filter...") 127 | let result = run(command: "gunzip -c filter_static.sqlite.gz > filter_static.sqlite") 128 | if result.exitCode != 0 { 129 | print("Failed to decompress filter_static.sqlite.gz: \(result.stderr ?? "")") 130 | } 131 | 132 | //Log in 133 | let env = ProcessInfo.processInfo.environment 134 | 135 | if !client.loggedIn { 136 | let email: String 137 | let password: String 138 | 139 | let envEmail = env["ChatBotEmail"] 140 | let envPassword = env["ChatBotPass"] 141 | 142 | if (envEmail ?? secrets.email) != nil { 143 | email = (envEmail ?? secrets.email)! 144 | } 145 | else { 146 | print("Email: ", terminator: "") 147 | email = readLine()! 148 | } 149 | 150 | if (envPassword ?? secrets.password) != nil { 151 | password = (envPassword ?? secrets.password)! 152 | } 153 | else { 154 | password = String(validatingUTF8: getpass("Password: "))! 155 | } 156 | 157 | do { 158 | try client.login(email: email, password: password) 159 | } 160 | catch { 161 | handleError(error, "while logging in") 162 | exit(EXIT_FAILURE) 163 | } 164 | } 165 | 166 | //Get the location 167 | if let rawLocation = redunda?.locationName { 168 | let components = rawLocation.components(separatedBy: "/") 169 | 170 | user = components.first ?? "" 171 | device = components.dropFirst().joined(separator: " ") 172 | 173 | location = "\(user)/\(device)" 174 | 175 | location = String(String.UnicodeScalarView(location.unicodeScalars.filter { !CharacterSet.newlines.contains($0) })) 176 | userLocation = location 177 | user = String(String.UnicodeScalarView(user.unicodeScalars.filter { !CharacterSet.newlines.contains($0) })) 178 | user = user.replacingOccurrences(of: " ", with: "") 179 | device = String(String.UnicodeScalarView(device.unicodeScalars.filter { !CharacterSet.newlines.contains($0) })) 180 | ping = " (cc @\(user))" 181 | userLocation = location 182 | } else if FileManager.default.fileExists (atPath: "location.txt") { 183 | do { 184 | let rawLocation = try loadFile ("location.txt") 185 | 186 | let components = rawLocation.components(separatedBy: "/") 187 | 188 | user = components.first ?? "" 189 | device = components.dropFirst().joined(separator: " ") 190 | 191 | location = "\(user)/\(device)" 192 | 193 | location = String(location.filter { !"\n".contains($0) }) 194 | userLocation = location 195 | user = String(user.filter { !"\n".contains($0) }) 196 | device = String(device.filter { !"\n".contains($0) }) 197 | ping = " (cc @\(user))" 198 | userLocation = location 199 | } catch { 200 | print ("Location could not be loaded!") 201 | } 202 | } else { 203 | print ("Location could not be loaded!") 204 | } 205 | 206 | 207 | 208 | //Join the chat room 209 | let rooms: [ChatRoom] 210 | if let devString = env["DEVELOPMENT"], let devRoom = Int(devString) { 211 | let devServer: ChatRoom.Host 212 | if let devServerString = env["DEVELOPMENT_HOST"] { 213 | switch devServerString.lowercased() { 214 | case "chat.so": 215 | devServer = .stackOverflow 216 | case "chat.se": 217 | devServer = .stackExchange 218 | case "chat.mse": 219 | devServer = .metaStackExchange 220 | default: 221 | fatalError("DEVELOPMENT_HOST contains an invalid value; accepted values are chat.SO, chat.SE, and chat.mSE (case-insensitive)") 222 | } 223 | } else { 224 | devServer = .stackOverflow 225 | } 226 | 227 | rooms = [ChatRoom(client: client, host: devServer, roomID: devRoom)] 228 | development = true 229 | } 230 | else { 231 | rooms = [ 232 | // ChatRoom(client: client, host: .stackOverflow, roomID: 123602), //FireAlarm Development 233 | // ChatRoom(client: client, host: .stackOverflow, roomID: 111347), //SOBotics 234 | // ChatRoom(client: client, host: .stackOverflow, roomID: 41570), //SO Close Vote Reviewers 235 | // ChatRoom(client: client, host: .stackExchange, roomID: 54445), //SEBotics 236 | ChatRoom(client: client, host: .stackOverflow, roomID: 167908) // SOBotics Workshop 237 | ] 238 | 239 | development = false 240 | } 241 | //let trollRooms: [ChatRoom] = [ChatRoom(client: client, host: .stackExchange, roomID: 54445)] //SEBotics 242 | 243 | try rooms.forEach {try $0.loadUserDB()} 244 | 245 | afterTooManyErrors = { 246 | print("Too many errors; aborting...") 247 | abort() 248 | } 249 | errorRoom = rooms.first! 250 | 251 | 252 | listener = ChatListener(commands: commands) 253 | listener.onShutdown { shutDown(reason: $0, rooms: rooms) } 254 | rooms.forEach { room in 255 | //let trainWrecker = TrainWrecker(room: room) 256 | room.onMessage { message, isEdit in 257 | let content = message.content.lowercased() 258 | if (content == "@bots alive") { 259 | do { 260 | try CommandStatus(listener: listener, message: message, arguments: []).run() 261 | } catch { 262 | handleError(error, "while handling '@bots alive'") 263 | } 264 | } 265 | else if message.room.roomID == 111347 && ["🚆", "🚅", "🚂", "🚊"].contains(content) { 266 | room.postMessage("[🚃](https://www.youtube.com/watch?v=dQw4w9WgXcQ)") 267 | } 268 | 269 | //if (message.room.roomID == 111347 || message.room.roomID == 123602) { 270 | // if !isEdit { trainWrecker.process(message: message) } 271 | //} 272 | 273 | listener.processMessage(room, message: message, isEdit: isEdit) 274 | } 275 | } 276 | 277 | //let trollListener = ChatListener(commands: trollCommands) 278 | //trollListener.onShutdown(listener.stop) 279 | //trollRooms.forEach { room in 280 | // room.onMessage { message, isEdit in 281 | // trollListener.processMessage(room, message: message, isEdit: isEdit) 282 | // } 283 | //} 284 | 285 | try rooms.forEach { try $0.join() } 286 | //try trollRooms.forEach { try $0.join() } 287 | 288 | currentVersion = getCurrentVersion() 289 | shortVersion = getShortVersion(currentVersion) 290 | versionLink = getVersionLink(currentVersion) 291 | 292 | rooms.first?.postMessage("[ [\(botName)](\(stackAppsLink)) ] FireAlarm started at revision [`\(shortVersion)`](\(versionLink)) on \(location).") 293 | 294 | //Load the filter 295 | reporter = Reporter(rooms: rooms, trollRooms: []) 296 | try reporter.postFetcher.start() 297 | 298 | errorsInLast30Seconds = 0 299 | afterTooManyErrors = { 300 | print("Too many errors; aborting...") 301 | abort() 302 | } 303 | 304 | 305 | scheduleBackgroundTasks(rooms: rooms, listener: listener) 306 | } 307 | 308 | private func sendUpdateBroadcast(commit: String) throws { 309 | //Send the update notification. 310 | if let token = secrets.githubWebhookSecret, let r = redunda { 311 | let payload = [ 312 | "token": token, 313 | "commit": commit, 314 | ] 315 | let data = try JSONSerialization.data(withJSONObject: payload) 316 | let (_, _) = try r.client.post( 317 | "https://redunda.sobotics.org/bots/6/events/update_to?broadcast=true", 318 | data: data, 319 | contentType: "application/json" 320 | ) 321 | } 322 | } 323 | 324 | 325 | private func shutDown(reason: ChatListener.StopReason, rooms: [ChatRoom]) { 326 | let shouldReboot = reason == .reboot || reason == .update 327 | 328 | reporter.postFetcher.stop() 329 | 330 | //Wait for pending messages to be posted. 331 | for room in rooms { 332 | while !room.messageQueue.isEmpty { 333 | sleep(1) 334 | } 335 | } 336 | 337 | save(rooms: rooms) 338 | 339 | rooms.forEach { $0.leave() } 340 | 341 | if shouldReboot { 342 | let command = "/usr/bin/env swift run -c release" 343 | let args = command.components(separatedBy: .whitespaces) 344 | let argBuffer = UnsafeMutablePointer?>.allocate(capacity: args.count + 1) 345 | for i in 0.. String { 15 | let versionResults = run(command: "git rev-parse HEAD") 16 | return (versionResults.exitCode == 0 ? versionResults.stdout : nil)?.replacingOccurrences(of: "\n", with: "") ?? "" 17 | } 18 | 19 | public func getShortVersion(_ version: String) -> String { 20 | if version == "" { 21 | return "" 22 | } 23 | return version.count > 7 ? 24 | String(version[version.startIndex.." 26 | } 27 | 28 | public func getVersionLink(_ version: String) -> String { 29 | if version == "" { 30 | return githubLink 31 | } else { 32 | return "//github.com/SOBotics/FireAlarm/commit/\(version)" 33 | } 34 | } 35 | 36 | 37 | func update(to commit: String?, listener: ChatListener, rooms: [ChatRoom], force: Bool = false) -> Bool { 38 | if noUpdate || isUpdating { 39 | return false 40 | } 41 | isUpdating = true 42 | defer { isUpdating = false } 43 | 44 | let pullResult = run(command: commit != nil ? "git fetch && git merge \(commit!)" : "git pull") 45 | if pullResult.exitCode != 0 { 46 | if let output = pullResult.combinedOutput, !output.isEmpty { 47 | let message = " " + output.components(separatedBy: .newlines).joined(separator: "\n ") 48 | rooms.forEach { 49 | $0.postMessage("\(reportHeader) Update failed:") 50 | $0.postMessage(message) 51 | } 52 | } else { 53 | rooms.forEach { $0.postMessage("\(reportHeader) Update failed!") } 54 | } 55 | } 56 | 57 | if !force && pullResult.stdout == "Already up-to-date.\n" { return false } 58 | 59 | rooms.forEach { $0.postMessage("\(reportHeader) Updating...") } 60 | 61 | let buildResult = run(command: "swift package update && swift build -c release") 62 | if buildResult.exitCode != 0 { 63 | if let output = buildResult.combinedOutput, !output.isEmpty { 64 | let message = " " + output.components(separatedBy: .newlines).joined(separator: "\n ") 65 | rooms.forEach { 66 | $0.postMessage("\(reportHeader) Update failed:") 67 | $0.postMessage(message) 68 | } 69 | } else { 70 | rooms.forEach { $0.postMessage("\(reportHeader) Update failed!") } 71 | } 72 | } else { 73 | rooms.forEach { $0.postMessage("\(reportHeader) Update complete; rebooting...") } 74 | listener.stop(.reboot) 75 | } 76 | 77 | return true 78 | } 79 | -------------------------------------------------------------------------------- /Sources/Run/main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // main.swift 3 | // FireAlarm 4 | // 5 | // Created by NobodyNada on 8/27/16. 6 | // Copyright © 2016 NobodyNada. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftChatSE 11 | import Dispatch 12 | import FireAlarm 13 | 14 | #if os(Linux) 15 | import Glibc 16 | #endif 17 | 18 | 19 | do { 20 | try FireAlarm.main() 21 | } catch { 22 | handleError(error, "while starting up") 23 | abort() 24 | } 25 | 26 | while true { 27 | //dispatchMain seems to be broken, so use this instead 28 | pause() 29 | } 30 | -------------------------------------------------------------------------------- /Tests/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SOBotics/FireAlarm/94456ffcecfb327fd95b5e8daf79ca0ce275e536/Tests/.DS_Store -------------------------------------------------------------------------------- /Tests/FireAlarmTests/FireAlarmTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import FireAlarmCore 3 | 4 | class FireAlarmTests: XCTestCase { 5 | static var allTests : [(String, (FireAlarmTests) -> () throws -> Void)] { 6 | return [ 7 | ] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import FireAlarmTests 3 | 4 | XCTMain([ 5 | testCase(FireAlarmTests.allTests), 6 | ]) 7 | -------------------------------------------------------------------------------- /blacklisted_users.json: -------------------------------------------------------------------------------- 1 | [ 2 | "dr\\.? madonna" 3 | ] -------------------------------------------------------------------------------- /build-nopm.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | #download libraries 4 | [[ -d SwiftChatSE ]] && rm -rf SwiftChatSE 5 | git clone git://github.com/NobodyNada/SwiftChatSE || exit 1 6 | 7 | [[ -d SwiftStack ]] && rm -rf SwiftStack 8 | git clone git://github.com/NobodyNada/SwiftStack || exit 1 9 | 10 | #build SwiftChatSE 11 | echo "Building SwiftChatSE..." 12 | pushd SwiftChatSE || exit 1 13 | ./build-nopm.sh || (popd; exit 1) 14 | popd 15 | 16 | #build SwiftStack 17 | echo "Building SwiftStack..." 18 | pushd SwiftStack || exit 1 19 | ./build-nopm.sh || (popd; exit 1) 20 | popd 21 | 22 | 23 | #build FireAlarm 24 | echo "Building FireAlarm..." || exit 1 25 | swiftc Sources/*.swift -L/usr/local/lib -ISwiftChatSE -ISwiftStack -LSwiftChatSE -LSwiftStack -lSwiftChatSE -lSwiftStack -lsqlite3 -o FireAlarm || exit 1 26 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | swift build -Xswiftc -lwebsockets -Xswiftc -I/usr/local/opt/openssl/include -Xswiftc -I/usr/local/include -Xlinker -lwebsockets -Xlinker -L/usr/local/lib 2 | -------------------------------------------------------------------------------- /clean.sh: -------------------------------------------------------------------------------- 1 | swift package clean 2 | rm -rf Packages 3 | rm -rf .build 4 | rm -f Package.pins 5 | rm -f Package.resolved 6 | -------------------------------------------------------------------------------- /filter_static.sqlite.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SOBotics/FireAlarm/94456ffcecfb327fd95b5e8daf79ca0ce275e536/filter_static.sqlite.gz -------------------------------------------------------------------------------- /firealarm.xcconfig: -------------------------------------------------------------------------------- 1 | OTHER_SWIFT_FLAGS = "-lwebsockets -I/usr/local/opt/openssl/include -I/usr/local/include -L/usr/local/lib -DXcode"; 2 | OTHER_LDFLAGS = -lwebsockets; 3 | HEADER_SEARCH_PATHS = /usr/local/opt/openssl/include; 4 | LIBRARY_SEARCH_PATHS = /usr/local/lib; 5 | -------------------------------------------------------------------------------- /project.sh: -------------------------------------------------------------------------------- 1 | swift package generate-xcodeproj --xcconfig-overrides firealarm.xcconfig 2 | --------------------------------------------------------------------------------