├── .gitignore ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── App │ └── main.swift ├── Chameleon │ ├── Bot │ │ ├── Authenticator │ │ │ ├── OAuthAuthenticator.swift │ │ │ ├── SlackAuthenticator.swift │ │ │ └── TokenAuthenticator.swift │ │ ├── ModelVendor │ │ │ ├── BotFromUserRequest.swift │ │ │ ├── IMFromListRequest.swift │ │ │ ├── ModelPointer+Value.swift │ │ │ ├── ModelVendor.swift │ │ │ ├── ModelWebAPIRequest.swift │ │ │ ├── ModelWebAPIRequestRepresentable.swift │ │ │ └── UserFromBotRequest.swift │ │ ├── ReconnectionStrategy.swift │ │ ├── SlackBot+Configuration.swift │ │ ├── SlackBot+Creation.swift │ │ ├── SlackBot+HTTPServer.swift │ │ ├── SlackBot+RTMAPIEvents.swift │ │ ├── SlackBot+SlashCommands.swift │ │ ├── SlackBot.swift │ │ ├── SlackBotService.swift │ │ └── SlackBotSlashCommandService.swift │ ├── Chameleon.swift │ └── Sugar │ │ ├── ChatMessageDecorator │ │ ├── ChatMessageDecorator+Attachments.swift │ │ ├── ChatMessageDecorator+Extensions.swift │ │ ├── ChatMessageDecorator+Style.swift │ │ ├── ChatMessageDecorator+Text.swift │ │ ├── ChatMessageDecorator.swift │ │ └── ChatMessageSegmentRepresentable.swift │ │ ├── Conversation │ │ ├── ActiveConversation.swift │ │ ├── Conversation+Event.swift │ │ ├── Conversation.swift │ │ ├── ConversationSegment.swift │ │ └── ConversationService.swift │ │ ├── MessageDecorator │ │ ├── MessageDecorator+Mentions.swift │ │ ├── MessageDecorator+Simple.swift │ │ ├── MessageDecorator+Source.swift │ │ ├── MessageDecorator+Targets.swift │ │ └── MessageDecorator.swift │ │ ├── ModelPointer+Matcher.swift │ │ ├── Models+Matcher.swift │ │ ├── PatternMatching │ │ ├── Match.swift │ │ ├── Matcher.swift │ │ ├── Matchers │ │ │ ├── DynamicMatcher.swift │ │ │ ├── GreedyMatcher.swift │ │ │ ├── InstanceMatcher.swift │ │ │ ├── KeyedMatcher.swift │ │ │ ├── OptionalMatcher.swift │ │ │ ├── SequenceMatcher.swift │ │ │ ├── TypeMatcher.swift │ │ │ └── ValueMatcher.swift │ │ ├── PatternCommon.swift │ │ ├── PatternMatch.swift │ │ ├── PatternRepresentable.swift │ │ └── String+PatternMatch.swift │ │ ├── SlackBot+Convenience.swift │ │ └── SlackBotServices │ │ ├── SlackBotConnectionService.swift │ │ ├── SlackBotErrorService.swift │ │ ├── SlackBotHelpService.swift │ │ ├── SlackBotMessageService.swift │ │ ├── SlackBotService+Errors.swift │ │ └── SlackBotTimedService.swift ├── Common │ ├── Codable │ │ ├── Decodable.swift │ │ ├── Decoder+Decodable.swift │ │ ├── Decoder.swift │ │ └── Encodable.swift │ ├── Collection+Extensions.swift │ ├── Dictionary+Extensions.swift │ ├── ErrorHandler.swift │ ├── KeyPathAccessible │ │ ├── KeyPathAccessible+Array.swift │ │ ├── KeyPathAccessible+Dictionary.swift │ │ ├── KeyPathAccessible.swift │ │ └── KeyPathComponent.swift │ ├── NeighborSequence.swift │ ├── OptionalType.swift │ ├── Result.swift │ ├── String+Extensions.swift │ ├── String+HTML.swift │ └── TimeInterval+Extensions.swift ├── Models │ ├── BotUser.swift │ ├── Channel.swift │ ├── ChatMessage │ │ ├── Attachment.swift │ │ ├── Author.swift │ │ ├── ChatMessage.swift │ │ ├── Field.swift │ │ ├── Footer.swift │ │ ├── Parse.swift │ │ └── Title.swift │ ├── Color.swift │ ├── Command.swift │ ├── CustomEmoji.swift │ ├── Decoder+ModelPointer.swift │ ├── Emoji.swift │ ├── Exports.swift │ ├── Group.swift │ ├── IM.swift │ ├── Message+Subtype.swift │ ├── Message.swift │ ├── MessageEdit.swift │ ├── ModelPointer.swift │ ├── Pong.swift │ ├── Protocols │ │ ├── EmojiRepresentable.swift │ │ ├── IDRepresentable.swift │ │ ├── Nameable.swift │ │ ├── TargetRepresentable.swift │ │ └── TokenRepresentable.swift │ ├── Purpose.swift │ ├── SlashCommand.swift │ ├── Targets.swift │ ├── Team.swift │ ├── Thread.swift │ ├── Topic.swift │ └── User.swift ├── RTMAPI │ ├── Events │ │ ├── Hello.swift │ │ ├── Message.swift │ │ └── Pong.swift │ ├── Exports.swift │ ├── RTMAPI+Errors.swift │ ├── RTMAPI+PingPong.swift │ ├── RTMAPI.swift │ └── RTMAPIEvent.swift ├── Services │ ├── Exports.swift │ ├── HTTPServer │ │ ├── HTTPServer.swift │ │ ├── HTTPServerProvider.swift │ │ └── HTTPServerResponse.swift │ ├── KeyValueStore │ │ ├── KeyValueStore+Environment.swift │ │ ├── KeyValueStore+Memory.swift │ │ ├── KeyValueStore+Redis.swift │ │ └── KeyValueStore.swift │ ├── Network │ │ ├── DataRepresentable.swift │ │ ├── Middleware │ │ │ └── HTTPStatusCodeMiddleware.swift │ │ ├── Network.swift │ │ ├── NetworkMiddleware.swift │ │ ├── NetworkProvider.swift │ │ ├── NetworkRequest.swift │ │ └── NetworkResponse.swift │ ├── Storage │ │ ├── Storage+Memory.swift │ │ ├── Storage+Plist.swift │ │ ├── Storage+Private.swift │ │ ├── Storage+Redis.swift │ │ └── Storage.swift │ └── WebSocket │ │ ├── WebSocket.swift │ │ └── WebSocketProvider.swift └── WebAPI │ ├── Exports.swift │ ├── Methods │ ├── BotsInfo.swift │ ├── ChannelsInfo.swift │ ├── ChatPermalink.swift │ ├── ChatPostMessage.swift │ ├── GroupsInfo.swift │ ├── IMList.swift │ ├── IMOpen.swift │ ├── RTMConnect.swift │ ├── ReactionsAdd.swift │ ├── UsersInfo.swift │ └── UsersList.swift │ ├── Middleware │ ├── RetryMiddleware.swift │ └── WebAPIMiddleware.swift │ ├── WebAPI+Errors.swift │ ├── WebAPI+Scope.swift │ ├── WebAPI.swift │ ├── WebAPIAuthenticator.swift │ └── WebAPIRequest.swift └── Tests ├── CommonTests ├── CollectionTests.swift ├── Common.swift ├── DictionaryTests.swift ├── KeyPathAccessibleTests.swift ├── NeighborSequenceTests.swift ├── ResultTests.swift ├── StringTests.swift └── TimeIntervalTests.swift └── LinuxMain.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Ian Keen 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.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "BCrypt", 6 | "repositoryURL": "https://github.com/vapor/bcrypt.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "3ee4aca16ba6ebfb1ad48cc5fd4dfb163c6d6be8", 10 | "version": "1.1.0" 11 | } 12 | }, 13 | { 14 | "package": "Bits", 15 | "repositoryURL": "https://github.com/vapor/bits.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "c32f5e6ae2007dccd21a92b7e33eba842dd80d2f", 19 | "version": "1.1.0" 20 | } 21 | }, 22 | { 23 | "package": "Console", 24 | "repositoryURL": "https://github.com/vapor/console.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "df9eb9a6afd03851abcb3d8204d04c368729776e", 28 | "version": "2.3.0" 29 | } 30 | }, 31 | { 32 | "package": "Core", 33 | "repositoryURL": "https://github.com/vapor/core.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "f9f3a585ab0ea5764b46d7a36d9c0d9d508b9c63", 37 | "version": "2.2.0" 38 | } 39 | }, 40 | { 41 | "package": "Crypto", 42 | "repositoryURL": "https://github.com/vapor/crypto.git", 43 | "state": { 44 | "branch": null, 45 | "revision": "bf4470b9da79024aab79c85de80374f6c29e3864", 46 | "version": "2.1.1" 47 | } 48 | }, 49 | { 50 | "package": "CTLS", 51 | "repositoryURL": "https://github.com/vapor/ctls.git", 52 | "state": { 53 | "branch": null, 54 | "revision": "fddec6a4643d6e85b6bb6dc54b1b5cdbabd395d2", 55 | "version": "1.1.2" 56 | } 57 | }, 58 | { 59 | "package": "Debugging", 60 | "repositoryURL": "https://github.com/vapor/debugging.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "49c5e8f0a7cb5456a8f7c72c6cd9f1553e5885a8", 64 | "version": "1.1.0" 65 | } 66 | }, 67 | { 68 | "package": "Engine", 69 | "repositoryURL": "https://github.com/vapor/engine.git", 70 | "state": { 71 | "branch": null, 72 | "revision": "decf702d774ac630dfe0441ff76b4bb68257b77a", 73 | "version": "2.2.1" 74 | } 75 | }, 76 | { 77 | "package": "JSON", 78 | "repositoryURL": "https://github.com/vapor/json.git", 79 | "state": { 80 | "branch": null, 81 | "revision": "735800d8f2e75ebe3be25559eb6a781f4666dcfc", 82 | "version": "2.2.1" 83 | } 84 | }, 85 | { 86 | "package": "Multipart", 87 | "repositoryURL": "https://github.com/vapor/multipart.git", 88 | "state": { 89 | "branch": null, 90 | "revision": "8e541b2e6fc64a3741eca2aa48ee2c3f23cbe17c", 91 | "version": "2.1.1" 92 | } 93 | }, 94 | { 95 | "package": "Node", 96 | "repositoryURL": "https://github.com/vapor/node.git", 97 | "state": { 98 | "branch": null, 99 | "revision": "642f357d08ec5aa335ae2e3c4633c72da7b5a0c4", 100 | "version": "2.1.1" 101 | } 102 | }, 103 | { 104 | "package": "Random", 105 | "repositoryURL": "https://github.com/vapor/random.git", 106 | "state": { 107 | "branch": null, 108 | "revision": "d7c4397d125caba795d14d956efacfe2a27a63d0", 109 | "version": "1.2.0" 110 | } 111 | }, 112 | { 113 | "package": "Redis", 114 | "repositoryURL": "https://github.com/vapor/redis.git", 115 | "state": { 116 | "branch": null, 117 | "revision": "525df8967447366c77a833a55b985c6c8735307e", 118 | "version": "2.1.0" 119 | } 120 | }, 121 | { 122 | "package": "Routing", 123 | "repositoryURL": "https://github.com/vapor/routing.git", 124 | "state": { 125 | "branch": null, 126 | "revision": "cb9d78aca2540c1a6b45b0ab43e5b0c50f29d216", 127 | "version": "2.2.0" 128 | } 129 | }, 130 | { 131 | "package": "Sockets", 132 | "repositoryURL": "https://github.com/vapor/sockets.git", 133 | "state": { 134 | "branch": null, 135 | "revision": "70d14c0e223257176f5ef69a595f7cad5de7a88b", 136 | "version": "2.2.1" 137 | } 138 | }, 139 | { 140 | "package": "TLS", 141 | "repositoryURL": "https://github.com/vapor/tls.git", 142 | "state": { 143 | "branch": null, 144 | "revision": "6c6eedb6761cddc6b6c87142a27eec13fa1701ec", 145 | "version": "2.1.1" 146 | } 147 | }, 148 | { 149 | "package": "Vapor", 150 | "repositoryURL": "https://github.com/vapor/vapor.git", 151 | "state": { 152 | "branch": null, 153 | "revision": "63768e7f56e58dbfa4288e16ad2e4003bfd8dcde", 154 | "version": "2.4.0" 155 | } 156 | } 157 | ] 158 | }, 159 | "version": 1 160 | } 161 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:4.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Chameleon", 8 | products: [ 9 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 10 | .library(name: "Chameleon", targets: ["Chameleon"]), 11 | .library(name: "Common", targets: ["Common"]), 12 | .library(name: "Models", targets: ["Models"]), 13 | .library(name: "RTMAPI", targets: ["RTMAPI"]), 14 | .library(name: "Services", targets: ["Services"]), 15 | .library(name: "WebAPI", targets: ["WebAPI"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | .package(url: "https://github.com/vapor/vapor.git", from: "2.4.4"), 20 | .package(url: "https://github.com/vapor/redis.git", from: "2.0.0"), 21 | ], 22 | targets: [ 23 | .target(name: "Chameleon", dependencies: ["Common", "Models", "Services", "RTMAPI", "WebAPI"]), 24 | 25 | .target(name: "Common", dependencies: []), 26 | .testTarget(name: "CommonTests", dependencies: ["Common"]), 27 | 28 | .target(name: "Models", dependencies: ["Common"]), 29 | .target(name: "RTMAPI", dependencies: ["Common", "Models", "Services"]), 30 | .target(name: "Services", dependencies: ["Common", "Vapor", "Redis"]), 31 | .target(name: "WebAPI", dependencies: ["Common", "Models", "Services"]), 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chameleon 2 | 3 | A Slack bot built with Swift. 4 | -------------------------------------------------------------------------------- /Sources/App/main.swift: -------------------------------------------------------------------------------- 1 | /* 2 | import Chameleon 3 | 4 | let storage = PListStorage() 5 | let store = MemoryKeyValueStore() 6 | 7 | store.set(value: <#VALUE#>, forKey: "CLIENT_ID") 8 | store.set(value: <#VALUE#>, forKey: "CLIENT_SECRET") 9 | store.set(value: <#VALUE#>, forKey: "REDIRECT_URI") 10 | 11 | let authenticator = OAuthAuthenticator( 12 | network: NetworkProvider(), 13 | storage: storage, 14 | clientId: try store.get(forKey: "CLIENT_ID"), 15 | clientSecret: try store.get(forKey: "CLIENT_SECRET"), 16 | scopes: [.channels_write, .chat_write_bot, .users_read], 17 | redirectUri: try? store.get(forKey: "REDIRECT_URI") 18 | ) 19 | 20 | let bot = SlackBot( 21 | authenticator: authenticator, 22 | services: [] 23 | ) 24 | 25 | bot.on(message.self) { bot, data in 26 | let msg = data.message.makeDecorator() 27 | 28 | guard msg.text.patternMatches(against: ["hello"]) else { return } 29 | 30 | try bot.send(["hey!"], to: msg.target()) 31 | } 32 | 33 | bot.start() 34 | */ 35 | -------------------------------------------------------------------------------- /Sources/Chameleon/Bot/Authenticator/SlackAuthenticator.swift: -------------------------------------------------------------------------------- 1 | 2 | /// Provides the authentication data for a Slack session 3 | public struct SlackAuthentication { 4 | /// The token used to connect to Slack 5 | public let token: String 6 | 7 | /// The `WebAPIAuthenticator` that the `WebAPI` instance will use to authentication web api requests 8 | public let webApiAuthenticator: WebAPIAuthenticator 9 | } 10 | 11 | /// Represents an object that handles authenticating with Slack 12 | public protocol SlackAuthenticator { 13 | /// Configure the `SlackAuthenticator` 14 | /// 15 | /// Called once when the bot is created 16 | func configure(slackBot: SlackBot) 17 | 18 | /// Obtain a `SlackAuthentication` that can be used to connect 19 | func authenticate() throws -> SlackAuthentication 20 | 21 | /// Resets any internal state related to this `SlackAuthenticator` 22 | func reset() 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Chameleon/Bot/Authenticator/TokenAuthenticator.swift: -------------------------------------------------------------------------------- 1 | 2 | /// Provides simple token based authentication 3 | public struct TokenAuthenticator: SlackAuthenticator { 4 | // MARK: - Public Properties 5 | public let token: String 6 | 7 | // MARK: - Lifecycle 8 | /// Create a new ininstance 9 | /// 10 | /// - Parameter token: The `token` to use for authentication and web api requests 11 | public init(token: String) { 12 | self.token = token 13 | } 14 | 15 | // MARK: - Public Functions 16 | public func configure(slackBot: SlackBot) { 17 | // 18 | } 19 | public func authenticate() throws -> SlackAuthentication { 20 | return SlackAuthentication( 21 | token: token, 22 | webApiAuthenticator: self 23 | ) 24 | } 25 | public func reset() { 26 | // 27 | } 28 | } 29 | 30 | extension TokenAuthenticator: WebAPIAuthenticator { 31 | public func token(for method: T) throws -> String { 32 | //token based authentication automatically unlocks all WebAPI methods 33 | return token 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Chameleon/Bot/ModelVendor/BotFromUserRequest.swift: -------------------------------------------------------------------------------- 1 | 2 | struct BotFromUserRequest: WebAPIRequest { 3 | private let innerRequest: UsersInfo 4 | 5 | var url: String { return innerRequest.url } 6 | var endpoint: String { return innerRequest.endpoint } 7 | var body: [String: Any?] { return innerRequest.body } 8 | var scopes: [WebAPI.Scope] { return innerRequest.scopes } 9 | var authenticated: Bool { return innerRequest.authenticated } 10 | 11 | init(id: String) { 12 | innerRequest = UsersInfo(id: id) 13 | } 14 | 15 | func handle(response: NetworkResponse) throws -> BotUser { 16 | let user = try innerRequest.handle(response: response) 17 | 18 | guard user.is_bot else { throw Error.notABot } 19 | 20 | return BotUser(id: user.id, name: user.name) 21 | } 22 | } 23 | 24 | extension BotFromUserRequest { 25 | enum Error: Swift.Error { 26 | case notABot 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Chameleon/Bot/ModelVendor/IMFromListRequest.swift: -------------------------------------------------------------------------------- 1 | 2 | struct IMFromListRequest: WebAPIRequest { 3 | private let innerRequest = RawIMList() 4 | private let id: String 5 | 6 | var url: String { return innerRequest.url } 7 | var endpoint: String { return innerRequest.endpoint } 8 | var body: [String: Any?] { return innerRequest.body } 9 | var scopes: [WebAPI.Scope] { return innerRequest.scopes } 10 | var authenticated: Bool { return innerRequest.authenticated } 11 | 12 | init(id: String) { 13 | self.id = id 14 | } 15 | 16 | func handle(response: NetworkResponse) throws -> IM { 17 | let ims = try innerRequest.handle(response: response) 18 | 19 | guard let im = ims.first(where: { ($0["id"] as? String) == id }) 20 | else { throw Error.imNotFound } 21 | 22 | return try IM(decoder: Decoder(data: im)) 23 | } 24 | } 25 | 26 | public struct RawIMList: WebAPIRequest { 27 | public let endpoint = "im.list" 28 | 29 | public func handle(response: NetworkResponse) throws -> [[String: Any]] { 30 | guard 31 | let dictionary = response.jsonDictionary, 32 | let data = dictionary["ims"] as? [[String: Any]] 33 | else { throw NetworkError.invalidResponse(response) } 34 | 35 | return data 36 | } 37 | } 38 | 39 | extension IMFromListRequest { 40 | enum Error: Swift.Error { 41 | case imNotFound 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/Chameleon/Bot/ModelVendor/ModelPointer+Value.swift: -------------------------------------------------------------------------------- 1 | 2 | var sharedModelVendor: SlackModelTypeVendor? 3 | 4 | public extension ModelPointer where Model: ModelWebAPIRequestRepresentable { 5 | /// Attempts to return the complete model object for this pointer 6 | func value() throws -> Model { 7 | guard let shared = sharedModelVendor 8 | else { throw SlackModelTypeVendorError.noStorageConfigured } 9 | 10 | return try shared.model(id: id) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/Chameleon/Bot/ModelVendor/ModelVendor.swift: -------------------------------------------------------------------------------- 1 | 2 | enum SlackModelTypeVendorError: Error { 3 | case noStorageConfigured 4 | } 5 | 6 | /// Represents an object capable of turning object ids into complete object models 7 | public protocol SlackModelTypeVendor { 8 | /// Attempts to convert the provided object id into a complete object model 9 | func model(id: String) throws -> T 10 | } 11 | 12 | /// Provides the default model vending behaviour 13 | /// Models retrieved from the web api are cached so subsequent requests for complete models 14 | /// don't require additional web api requests 15 | public final class DefaultSlackModelTypeVendor: SlackModelTypeVendor { 16 | 17 | // TODO: Add the ability to 'expire' stored objects 18 | 19 | // MARK: - Private Properties 20 | private let webApi: WebAPI 21 | private var stored: [String: Any] = [:] 22 | 23 | // MARK: - Lifecycle 24 | /// Create a new instance 25 | /// 26 | /// - Parameter webApi: The `WebAPI` that will be used to retrieve full models from provided ids 27 | public init(webApi: WebAPI) { 28 | self.webApi = webApi 29 | } 30 | 31 | // MARK: - Public Functions 32 | public func model(id: String) throws -> T { 33 | if let object = stored[id] as? T { 34 | return object 35 | } 36 | 37 | let request = T.request(id: id) 38 | 39 | do { 40 | let result = try webApi.perform(request: request) 41 | stored[id] = result 42 | return result 43 | 44 | } catch let error { 45 | throw error 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/Chameleon/Bot/ModelVendor/ModelWebAPIRequest.swift: -------------------------------------------------------------------------------- 1 | 2 | /// Wraps a `WebAPIRequest` into a generic struct 3 | public struct ModelWebAPIRequest: WebAPIRequest { 4 | public let url: String 5 | public let endpoint: String 6 | public let body: [String: Any?] 7 | public let scopes: [WebAPI.Scope] 8 | public let authenticated: Bool 9 | 10 | let _handle: (NetworkResponse) throws -> Model 11 | 12 | public init(request: T) where T.Result == Model { 13 | self.url = request.url 14 | self.endpoint = request.endpoint 15 | self.body = request.body 16 | self.scopes = request.scopes 17 | self.authenticated = request.authenticated 18 | self._handle = request.handle 19 | } 20 | 21 | public func handle(response: NetworkResponse) throws -> Model { 22 | return try _handle(response) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/Chameleon/Bot/ModelVendor/ModelWebAPIRequestRepresentable.swift: -------------------------------------------------------------------------------- 1 | 2 | /// Represents an type that can produce a `ModelWebAPIRequest` 3 | /// that converts an id into a full instance of this type 4 | public protocol ModelWebAPIRequestRepresentable { 5 | /// Returns a `ModelWebAPIRequest` that attempts to convert the provided id into a complete model of `Self` 6 | static func request(id: String) -> ModelWebAPIRequest 7 | } 8 | 9 | extension Channel: ModelWebAPIRequestRepresentable { 10 | public static func request(id: String) -> ModelWebAPIRequest { 11 | return ModelWebAPIRequest(request: ChannelsInfo(id: id)) 12 | } 13 | } 14 | 15 | extension User: ModelWebAPIRequestRepresentable { 16 | public static func request(id: String) -> ModelWebAPIRequest { 17 | if id[id.startIndex] == "B" { //bot_id 18 | return ModelWebAPIRequest(request: UserFromBotRequest(bot_id: id)) 19 | } else { 20 | return ModelWebAPIRequest(request: UsersInfo(id: id)) 21 | } 22 | } 23 | } 24 | 25 | extension BotUser: ModelWebAPIRequestRepresentable { 26 | public static func request(id: String) -> ModelWebAPIRequest { 27 | if id[id.startIndex] == "B" { //bot_id 28 | return ModelWebAPIRequest(request: BotsInfo(id: id)) 29 | } else { 30 | return ModelWebAPIRequest(request: BotFromUserRequest(id: id)) 31 | } 32 | } 33 | } 34 | 35 | extension IM: ModelWebAPIRequestRepresentable { 36 | public static func request(id: String) -> ModelWebAPIRequest { 37 | return ModelWebAPIRequest(request: IMFromListRequest(id: id)) 38 | } 39 | } 40 | 41 | extension Group: ModelWebAPIRequestRepresentable { 42 | public static func request(id: String) -> ModelWebAPIRequest { 43 | return ModelWebAPIRequest(request: GroupsInfo(id: id)) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/Chameleon/Bot/ModelVendor/UserFromBotRequest.swift: -------------------------------------------------------------------------------- 1 | 2 | struct UserFromBotRequest: WebAPIRequest { 3 | private let innerRequest = RawUsersList() 4 | private let bot_id: String 5 | 6 | var url: String { return innerRequest.url } 7 | var endpoint: String { return innerRequest.endpoint } 8 | var body: [String: Any?] { return innerRequest.body } 9 | var scopes: [WebAPI.Scope] { return innerRequest.scopes } 10 | var authenticated: Bool { return innerRequest.authenticated } 11 | 12 | init(bot_id: String) { 13 | self.bot_id = bot_id 14 | } 15 | 16 | func handle(response: NetworkResponse) throws -> User { 17 | let users = try innerRequest.handle(response: response) 18 | 19 | func isTarget(_ json: [String: Any]) -> Bool { 20 | guard 21 | let profile = json["profile"] as? [String: Any], 22 | let botid = profile["bot_id"] as? String, 23 | botid == bot_id 24 | else { return false } 25 | 26 | return true 27 | } 28 | 29 | guard let user = users.first(where: isTarget) 30 | else { throw Error.botNotFound } 31 | 32 | return try User(decoder: Decoder(data: user)) 33 | } 34 | } 35 | 36 | public struct RawUsersList: WebAPIRequest { 37 | public let endpoint = "users.list" 38 | public let scopes: [WebAPI.Scope] = [.users_read] 39 | 40 | public init() { } 41 | 42 | public func handle(response: NetworkResponse) throws -> [[String: Any]] { 43 | guard 44 | let dictionary = response.jsonDictionary, 45 | let data = dictionary["members"] as? [[String: Any]] 46 | else { throw NetworkError.invalidResponse(response) } 47 | 48 | return data 49 | } 50 | } 51 | 52 | extension UserFromBotRequest { 53 | enum Error: Swift.Error { 54 | case botNotFound 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/Chameleon/Bot/ReconnectionStrategy.swift: -------------------------------------------------------------------------------- 1 | 2 | import Dispatch 3 | import Foundation 4 | 5 | /// Defines behaviour to be applied when the bot is disconnected 6 | public protocol ReconnectionStrategy: class { 7 | /// Returns `true` if the bot should reconnect, otherwise `false` 8 | func reconnect() -> Bool 9 | 10 | /// Resets any internal state related to this `ReconnectionStrategy` 11 | func reset() 12 | } 13 | 14 | /// Provides the default reconnection behaviour 15 | public final class DefaultReconnectionStrategy: ReconnectionStrategy { 16 | public typealias Backoff = (Double) -> TimeInterval 17 | 18 | private let maxRetries: Int 19 | private var retries = 0 20 | private let backoff: Backoff 21 | 22 | private lazy var queue: DispatchQueue = DispatchQueue(label: String(describing: self)) 23 | private let group = DispatchGroup() 24 | 25 | /// Create a new `DefaultReconnectionStrategy` instance 26 | /// 27 | /// - Parameters: 28 | /// - maxRetries: The maximum number of times the bot will attempt to reconnect 29 | /// - delay: A function that provides the retry count 30 | /// and returns the number of seconds to wait before the next connection attempt 31 | public init(maxRetries: Int, delay: @escaping Backoff) { 32 | self.maxRetries = maxRetries 33 | self.backoff = delay 34 | } 35 | 36 | public func reconnect() -> Bool { 37 | retries += 1 38 | 39 | guard retries <= maxRetries else { return false } 40 | 41 | group.enter() 42 | 43 | let delay = backoff(Double(retries)) 44 | print("Waiting \(delay) seconds until reconnection") 45 | queue.asyncAfter(deadline: DispatchTime(secondsFromNow: delay), execute: group.leave) 46 | 47 | group.wait() 48 | 49 | return true 50 | } 51 | public func reset() { 52 | retries = 0 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/Chameleon/Bot/SlackBot+Configuration.swift: -------------------------------------------------------------------------------- 1 | 2 | extension SlackBot { 3 | /// Defines the configuration used by this bot instance 4 | public struct Configuration { 5 | public let authenticator: SlackAuthenticator 6 | public let reconnectionStrategy: ReconnectionStrategy 7 | public let modelVendor: SlackModelTypeVendor 8 | public let verificationToken: String? 9 | 10 | /// Create a new instance 11 | /// 12 | /// - Parameters: 13 | /// - authenticator: The `SlackAuthenticator` that will be used to authenticate the bot 14 | /// - reconnectionStrategy: The `ReconnectionStrategy` that will be used when the bot is disconnected 15 | /// - modelVendor: The `SlackModelTypeVendor` that will be used to convert object ids into complete object models 16 | /// - verificationToken: The verification token used for _slack application_ level slash commands 17 | public init( 18 | authenticator: SlackAuthenticator, 19 | reconnectionStrategy: ReconnectionStrategy, 20 | modelVendor: SlackModelTypeVendor, 21 | verificationToken: String? = nil 22 | ) 23 | { 24 | self.authenticator = authenticator 25 | self.reconnectionStrategy = reconnectionStrategy 26 | self.modelVendor = modelVendor 27 | self.verificationToken = verificationToken 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/Chameleon/Bot/SlackBot+Creation.swift: -------------------------------------------------------------------------------- 1 | 2 | public extension SlackBot { 3 | /// Create a new instance 4 | /// 5 | /// - Parameters: 6 | /// - authenticator: The `SlackAuthenticator` that will be used to authenticate the bot 7 | /// - verificationToken: The verification token used for _slack application_ level slash commands 8 | /// - services: The `SlackBotService`s that provide the functionality for this instance 9 | public convenience init( 10 | authenticator: SlackAuthenticator, 11 | verificationToken: String? = nil, 12 | services: [SlackBotService] 13 | ) 14 | { 15 | let socket = WebSocketProvider() 16 | let network = NetworkProvider() 17 | let httpServer = HTTPServerProvider() 18 | 19 | let webApi = WebAPI(network: network) 20 | let rtmApi = RTMAPI(socket: socket) 21 | 22 | let config = SlackBot.Configuration( 23 | authenticator: authenticator, 24 | reconnectionStrategy: DefaultReconnectionStrategy( 25 | maxRetries: 5, 26 | delay: { $0 * 2 } 27 | ), 28 | modelVendor: DefaultSlackModelTypeVendor(webApi: webApi), 29 | verificationToken: verificationToken 30 | ) 31 | 32 | self.init( 33 | config: config, 34 | webApi: webApi, 35 | rtmApi: rtmApi, 36 | httpServer: httpServer, 37 | services: services 38 | ) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/Chameleon/Bot/SlackBot+HTTPServer.swift: -------------------------------------------------------------------------------- 1 | 2 | import Dispatch 3 | 4 | extension SlackBot { 5 | /// Configure common HTTP routes 6 | func configureHTTPServer() { 7 | httpServer.register(.GET, path: ["hello"]) { (url, headers, body) -> HTTPServerResponse in 8 | return HTTPResponse.ok 9 | } 10 | 11 | //TODO add things like status page here.. maybe an admin console? 12 | } 13 | 14 | func startHTTPServer() { 15 | DispatchQueue.global().async { 16 | do { 17 | try self.httpServer.start() 18 | } catch let error { 19 | self.error(error) 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Chameleon/Bot/SlackBot+RTMAPIEvents.swift: -------------------------------------------------------------------------------- 1 | 2 | extension SlackBot { 3 | /// Subscribes to an `RTMAPIEvent` 4 | /// 5 | /// - Parameters: 6 | /// - event: The `RTMAPIEvent` to subscribe to 7 | /// - handler: The function called when the event occurs. Provides the bot instance and the events data 8 | public func on(_ event: T.Type, handler: @escaping (SlackBot, T.EventData) throws -> Void) { 9 | rtmApi.on(event) { [unowned self] data in 10 | try handler(self, data) 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Chameleon/Bot/SlackBot+SlashCommands.swift: -------------------------------------------------------------------------------- 1 | 2 | enum SlashCommandError: Swift.Error { 3 | case missingVerificationToken 4 | case invalidVerificationToken 5 | } 6 | 7 | private extension String { 8 | var normalizedSlashCommand: String { 9 | return lowercased().remove(prefix: "/") 10 | } 11 | } 12 | 13 | private extension SlashCommandSource { 14 | var normalizedSlashCommand: String { 15 | switch self { 16 | case .app(let command): 17 | return command.normalizedSlashCommand 18 | case .team(let command, _): 19 | return command.normalizedSlashCommand 20 | } 21 | } 22 | } 23 | 24 | extension SlackBot { 25 | /// Configure slash command HTTP route 26 | func configureSlashCommands() { 27 | httpServer.register(.POST, path: ["slashCommand"]) { [weak self] (url, headers, body) -> HTTPServerResponse in 28 | guard let this = self else { return HTTPResponse.ok } 29 | 30 | do { 31 | let command = try SlashCommand(decoder: Decoder(data: body)) 32 | 33 | let services = this.services 34 | .lazy 35 | .flatMap { $0 as? SlackBotSlashCommandService } 36 | .map { ($0, $0.slashCommands) } 37 | 38 | for (service, commandSources) in services { 39 | for source in commandSources { 40 | guard source.normalizedSlashCommand == command.command.normalizedSlashCommand 41 | else { continue } 42 | 43 | switch source { 44 | case .app: 45 | guard let token = this.config.verificationToken 46 | else { throw SlashCommandError.missingVerificationToken } 47 | 48 | guard token == command.token else { continue } 49 | 50 | try service.onSlashCommand(slackBot: this, command: command) 51 | 52 | case .team(_, let token): 53 | guard token == command.token else { continue } 54 | 55 | try service.onSlashCommand(slackBot: this, command: command) 56 | } 57 | } 58 | } 59 | 60 | } catch let error { 61 | this.error(error) 62 | } 63 | 64 | return HTTPResponse.ok 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/Chameleon/Bot/SlackBot.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | import Dispatch 4 | 5 | /// Represents the core bot used for connecting to a Slack team 6 | public class SlackBot { 7 | // MARK: - Private Properties 8 | private var exit = false 9 | 10 | // MARK: - Internal Properties 11 | private(set) var services: [SlackBotService] 12 | let config: Configuration 13 | let webApi: WebAPI 14 | let rtmApi: RTMAPI 15 | let httpServer: HTTPServer 16 | 17 | // MARK: - Public Properties 18 | private(set) public var me: BotUser! 19 | 20 | // MARK: - Lifecycle 21 | /// Create a `SlackBot` instance 22 | /// 23 | /// - Parameters: 24 | /// - config: The `Configuration` to use for this instance 25 | /// - webApi: The `WebAPI` used for sending web api requests 26 | /// - rtmApi: The `RTMAPI` used for receiving rtm api events 27 | /// - httpServer: The `HTTPServer` used for features such as slash command and interactive buttons 28 | /// - services: The `SlackBotService` that provide the functionality for this instance 29 | public init(config: Configuration, webApi: WebAPI, rtmApi: RTMAPI, httpServer: HTTPServer, services: [SlackBotService]) { 30 | self.config = config 31 | self.webApi = webApi 32 | self.rtmApi = rtmApi 33 | self.httpServer = httpServer 34 | self.services = services 35 | 36 | sharedModelVendor = config.modelVendor 37 | rtmApi.onError = { self.error($0) } 38 | 39 | config.authenticator.configure(slackBot: self) 40 | 41 | configureHTTPServer() 42 | configureSlashCommands() 43 | startHTTPServer() 44 | 45 | configureServices() 46 | } 47 | 48 | // MARK: - Public Functions 49 | /// Start the bot 50 | public func start() { 51 | exit = false 52 | 53 | let group = DispatchGroup() 54 | group.enter() 55 | 56 | do { 57 | let authentication = try config.authenticator.authenticate() 58 | 59 | webApi.use(authenticator: authentication.webApiAuthenticator) 60 | 61 | let request = RTMConnect(token: authentication.token) 62 | let connection = try webApi.perform(request: request) 63 | 64 | me = connection.bot 65 | 66 | rtmApi.onConnected = { [unowned self] in 67 | self.connected() 68 | } 69 | rtmApi.onDisconnected = { group.leave() } 70 | 71 | // this call is blocking when successful 72 | try rtmApi.connect(to: connection.url) 73 | 74 | } catch let error { 75 | self.error(error) 76 | group.leave() 77 | } 78 | 79 | group.wait() 80 | 81 | let reconnect = config.reconnectionStrategy.reconnect() && !exit 82 | disconnected(reconnect: reconnect) 83 | 84 | if reconnect { 85 | start() 86 | } else { 87 | config.authenticator.reset() 88 | } 89 | } 90 | 91 | /// Restart the bot 92 | public func restart() { 93 | config.reconnectionStrategy.reset() 94 | rtmApi.disconnect() 95 | } 96 | 97 | /// Stop the bot 98 | public func stop() { 99 | exit = true 100 | rtmApi.disconnect() 101 | } 102 | 103 | // MARK: - Internal Functions 104 | func error(_ error: Swift.Error) { 105 | defaultErrorHandler(error) 106 | services.forEach { $0.error(slackBot: self, error: error) } 107 | } 108 | func add(service: SlackBotService) { 109 | services.append(service) 110 | } 111 | 112 | // MARK: - Private Functions 113 | private func configureServices() { 114 | services.forEach { $0.configure(slackBot: self) } 115 | } 116 | private func connected() { 117 | config.reconnectionStrategy.reset() 118 | services.forEach { $0.connected(slackBot: self) } 119 | } 120 | private func disconnected(reconnect: Bool) { 121 | services.forEach { $0.disconnected(slackBot: self, reconnecting: reconnect) } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Sources/Chameleon/Bot/SlackBotService.swift: -------------------------------------------------------------------------------- 1 | 2 | /// Represents an object that provides functionality for a bot instance 3 | public protocol SlackBotService: class { 4 | /// Provides a chance to configure this `SlackBotService`. 5 | /// 6 | /// This is where you would do things like event subscription 7 | /// 8 | /// Called once when the bot is created. 9 | func configure(slackBot: SlackBot) 10 | 11 | /// Called each time the bot connects 12 | func connected(slackBot: SlackBot) 13 | 14 | /// Called each time the bot disconnects 15 | /// - reconnecting: `true` if the bot will attempt to reconnect, otherwise `false` 16 | func disconnected(slackBot: SlackBot, reconnecting: Bool) 17 | 18 | /// Called each time an error occurs 19 | func error(slackBot: SlackBot, error: Error) 20 | } 21 | 22 | extension SlackBotService { 23 | public func connected(slackBot: SlackBot) { } 24 | public func disconnected(slackBot: SlackBot, reconnecting: Bool) { } 25 | public func error(slackBot: SlackBot, error: Error) { } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Chameleon/Bot/SlackBotSlashCommandService.swift: -------------------------------------------------------------------------------- 1 | 2 | /// Defines the different types of slash commands 3 | public enum SlashCommandSource { 4 | /// Represents an _application_ level slash command 5 | /// This uses the `verificationToken` provided in the `SlackBot.Configuration` 6 | /// to ensure the command comes from a trusted source 7 | case app(command: String) 8 | 9 | /// Represents a _team_ level slash command 10 | /// This uses the provided `token` to ensure the command comes 11 | /// from a trusted source 12 | case team(command: String, token: String) 13 | } 14 | 15 | /// Allows _application_ level slash commands to be represented by string literals 16 | extension SlashCommandSource: ExpressibleByStringLiteral { 17 | public init(stringLiteral value: String) { 18 | self = .app(command: value) 19 | } 20 | public init(unicodeScalarLiteral value: String) { 21 | self.init(stringLiteral: value) 22 | } 23 | public init(extendedGraphemeClusterLiteral value: String) { 24 | self.init(stringLiteral: value) 25 | } 26 | } 27 | 28 | public protocol SlackBotSlashCommandService: SlackBotService { 29 | /// The commands supported by this `SlackBotSlashCommandService` instance 30 | var slashCommands: [SlashCommandSource] { get } 31 | 32 | /// Called when one of the registered slash commands is triggered 33 | /// 34 | /// - Parameters: 35 | /// - slackBot: The `SlackBot` instance 36 | /// - command: The `SlashCommand` with the command details 37 | func onSlashCommand(slackBot: SlackBot, command: SlashCommand) throws 38 | } 39 | 40 | extension SlashCommand { 41 | public func respond() -> ChatMessageDecorator { 42 | return ChatMessageDecorator(response_url: response_url) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/Chameleon/Chameleon.swift: -------------------------------------------------------------------------------- 1 | 2 | @_exported import Common 3 | @_exported import WebAPI 4 | @_exported import RTMAPI 5 | @_exported import Models 6 | @_exported import Services 7 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/ChatMessageDecorator/ChatMessageDecorator+Attachments.swift: -------------------------------------------------------------------------------- 1 | 2 | public extension ChatMessageDecorator { 3 | // 4 | // here; start wotking on this 5 | // 6 | } 7 | 8 | /* 9 | 10 | maybe break this out into the different attachment types.. 11 | foo 12 | bar 13 | buttons 14 | etc.. 15 | 16 | check docs 17 | 18 | */ 19 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/ChatMessageDecorator/ChatMessageDecorator+Extensions.swift: -------------------------------------------------------------------------------- 1 | 2 | public enum ResponseTarget { 3 | case inline 4 | case threaded 5 | } 6 | 7 | public extension MessageDecorator { 8 | func respond(_ targetType: ResponseTarget = .inline) throws -> ChatMessageDecorator { 9 | let responseTarget: TargetRepresentable 10 | 11 | switch targetType { 12 | case .inline: 13 | responseTarget = try target() 14 | case .threaded: 15 | responseTarget = try threadTarget() 16 | } 17 | 18 | return ChatMessageDecorator(target: responseTarget) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/ChatMessageDecorator/ChatMessageDecorator+Style.swift: -------------------------------------------------------------------------------- 1 | 2 | extension ChatMessageSegmentRepresentable { 3 | /// Place the text in a pre block 4 | public var pre: ChatMessageSegmentRepresentable { 5 | return "```\(messageSegment)```" 6 | } 7 | 8 | /// Use inline code formatting 9 | public var code: ChatMessageSegmentRepresentable { 10 | return "`\(messageSegment)`" 11 | } 12 | 13 | /// Use italics 14 | public var italic: ChatMessageSegmentRepresentable { 15 | return "_\(messageSegment)_" 16 | } 17 | 18 | /// Use bold 19 | public var bold: ChatMessageSegmentRepresentable { 20 | return "*\(messageSegment)*" 21 | } 22 | 23 | /// Use strikethrough 24 | public var strike: ChatMessageSegmentRepresentable { 25 | return "~\(messageSegment)~" 26 | } 27 | 28 | /// Use quote 29 | public var quote: ChatMessageSegmentRepresentable { 30 | return ">>>\(messageSegment)" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/ChatMessageDecorator/ChatMessageDecorator+Text.swift: -------------------------------------------------------------------------------- 1 | 2 | public extension ChatMessageDecorator { 3 | @discardableResult 4 | func text(_ values: [ChatMessageSegmentRepresentable]) -> ChatMessageDecorator { 5 | let newText = values 6 | .map { $0.messageSegment } 7 | .joined(separator: " ") 8 | 9 | text = text + newText 10 | 11 | return self 12 | } 13 | 14 | @discardableResult 15 | func newLine() -> ChatMessageDecorator { 16 | text = text + "\n" 17 | return self 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/ChatMessageDecorator/ChatMessageDecorator.swift: -------------------------------------------------------------------------------- 1 | 2 | public final class ChatMessageDecorator { 3 | // MARK: - Private Properties 4 | private let target: TargetRepresentable? 5 | private let response_url: String? 6 | 7 | // MARK: - Internal Properties 8 | var text: String = "" 9 | var as_user = true 10 | var attachments: [Attachment] = [] 11 | 12 | // MARK: - Lifecycle 13 | init(target: TargetRepresentable) { 14 | self.target = target 15 | self.response_url = nil 16 | } 17 | public init(response_url: String) { 18 | self.target = nil 19 | self.response_url = response_url 20 | } 21 | 22 | // MARK: - Public 23 | public func makeChatMessage() throws -> ChatMessage { 24 | if let target = target { 25 | return ChatMessage( 26 | channel: try target.targetId(), 27 | text: text, 28 | as_user: as_user, 29 | thread_ts: target.targetThread_ts, 30 | attachments: attachments 31 | ) 32 | 33 | } else if let response_url = response_url { 34 | return ChatMessage( 35 | response_url: response_url, 36 | text: text, 37 | as_user: as_user, 38 | attachments: attachments 39 | ) 40 | 41 | } else { 42 | fatalError("Invalid state") 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/ChatMessageDecorator/ChatMessageSegmentRepresentable.swift: -------------------------------------------------------------------------------- 1 | 2 | public protocol ChatMessageSegmentRepresentable { 3 | var messageSegment: String { get } 4 | } 5 | extension ChatMessageSegmentRepresentable { 6 | public var messageSegment: String { return "\(self)" } 7 | } 8 | 9 | extension String: ChatMessageSegmentRepresentable { } 10 | extension Int: ChatMessageSegmentRepresentable { } 11 | 12 | extension Emoji: ChatMessageSegmentRepresentable { } 13 | extension CustomEmoji: ChatMessageSegmentRepresentable { } 14 | extension ChatMessageSegmentRepresentable where Self: EmojiRepresentable { 15 | public var messageSegment: String { return self.emoji } 16 | } 17 | 18 | extension Command: ChatMessageSegmentRepresentable { 19 | public var messageSegment: String { return self.rawValue } 20 | } 21 | 22 | extension ChatMessageSegmentRepresentable where Self: TokenRepresentable & IDRepresentable { 23 | public var messageSegment: String { 24 | return "<\(Self.token)\(id)>" 25 | } 26 | } 27 | extension User: ChatMessageSegmentRepresentable { } 28 | extension BotUser: ChatMessageSegmentRepresentable { } 29 | extension Channel: ChatMessageSegmentRepresentable { } 30 | extension Group: ChatMessageSegmentRepresentable { } 31 | 32 | //TODO - with conditional conformance I should be able to rewrite this to be something like: 33 | // `extension ModelPointer: ChatMessageSegmentRepresentable where Model: TokenRepresentable {` 34 | extension ModelPointer: ChatMessageSegmentRepresentable { 35 | public var messageSegment: String { 36 | let token = (Model.self as? TokenRepresentable.Type)?.token ?? "" 37 | return "<\(token)\(id)>" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/Conversation/Conversation+Event.swift: -------------------------------------------------------------------------------- 1 | 2 | extension Conversation { 3 | public enum Event { 4 | case next(Segment) 5 | case retry([ChatMessageSegmentRepresentable]) 6 | case cancel([ChatMessageSegmentRepresentable]) 7 | case complete([ChatMessageSegmentRepresentable]) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/Conversation/Conversation.swift: -------------------------------------------------------------------------------- 1 | 2 | public class Conversation { 3 | public typealias StateFactory = () -> State 4 | public typealias SegmentHandler = (MessageDecorator, PatternMatch, inout State) throws -> Event 5 | public typealias CompletionHandler = (MessageDecorator, State) -> Void 6 | public typealias CancellationHandler = (MessageDecorator) -> Void 7 | 8 | // MARK: - Internal Properties 9 | let stateFactory: StateFactory 10 | private(set) var events: [Segment: SegmentHandler] = [:] 11 | private(set) var completion: CompletionHandler? 12 | private(set) var cancellation: CancellationHandler? 13 | 14 | // MARK: - Lifecycle 15 | public init(initialState: @escaping StateFactory) { 16 | self.stateFactory = initialState 17 | } 18 | 19 | // MARK: - Public Functions 20 | @discardableResult public func on(_ segment: Segment, handler: @escaping SegmentHandler) -> Conversation { 21 | events[segment] = handler 22 | return self 23 | } 24 | @discardableResult public func onComplete(_ handler: @escaping CompletionHandler) -> Conversation { 25 | completion = handler 26 | return self 27 | } 28 | @discardableResult public func onCancel(_ handler: @escaping CancellationHandler) -> Conversation { 29 | cancellation = handler 30 | return self 31 | } 32 | } 33 | 34 | public extension SlackBot { 35 | func start(conversation: Conversation, in message: MessageDecorator, startingWith segment: Segment, whenMatching matchers: [Matcher]) throws { 36 | if !services.contains(where: { $0 is ConversationService }) { 37 | let service = ConversationService() 38 | service.configure(slackBot: self) 39 | add(service: service) 40 | } 41 | 42 | guard 43 | message.text.patternMatches(against: matchers), 44 | let service = services.lazy.flatMap({ $0 as? ConversationService }).first 45 | else { return } 46 | 47 | try service.start(conversation: conversation, on: segment, in: message, using: self) 48 | } 49 | 50 | func start(conversation: Conversation, in message: MessageDecorator, startingWith segment: Segment, whenMatching pattern: PatternRepresentable) throws { 51 | try start(conversation: conversation, in: message, startingWith: segment, whenMatching: pattern.pattern) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/Conversation/ConversationSegment.swift: -------------------------------------------------------------------------------- 1 | 2 | public protocol ConversationSegment: Hashable { 3 | var message: [ChatMessageSegmentRepresentable] { get } 4 | var input: [Matcher] { get } 5 | } 6 | 7 | extension ConversationSegment { 8 | private var identifier: String { 9 | return message.map({ $0.messageSegment }).joined(separator: ":") + input.map({ $0.matcherDescription }).joined(separator: ":") 10 | } 11 | public static func ==(lhs: Self, rhs: Self) -> Bool { 12 | return lhs.identifier == rhs.identifier 13 | } 14 | public var hashValue: Int { 15 | return identifier.hashValue 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/Conversation/ConversationService.swift: -------------------------------------------------------------------------------- 1 | 2 | final class ConversationService: SlackBotMessageService { 3 | // MARK: - Private Properties 4 | private var activeConversations: [ActiveConversation] = [] 5 | 6 | // MARK: - Internal Functions 7 | func start(conversation: Conversation, on segment: Segment, in message: MessageDecorator, using slackBot: SlackBot) throws { 8 | guard !activeConversations.contains(where: { $0.conversing(with: message) }) else { return } 9 | 10 | let activeConversation = ActiveConversation( 11 | with: conversation, 12 | in: message, 13 | completed: { [unowned self] convo, state in 14 | conversation.completion?(message, state) 15 | self.activeConversations = self.activeConversations.filter { $0 !== convo } 16 | }, 17 | cancelled: { [unowned self] convo in 18 | conversation.cancellation?(message) 19 | self.activeConversations = self.activeConversations.filter { $0 !== convo } 20 | } 21 | ) 22 | activeConversations.append(activeConversation) 23 | 24 | try activeConversation.start(with: segment, in: message, using: slackBot) 25 | } 26 | func onMessage(slackBot: SlackBot, message: MessageDecorator, previous: MessageDecorator?) throws { 27 | let conversations = activeConversations.filter { $0.conversing(with: message) } 28 | 29 | for conversation in conversations { 30 | try conversation.execute(with: message, using: slackBot) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/MessageDecorator/MessageDecorator+Mentions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension MessageDecorator { 4 | public struct Link { 5 | public let range: Range 6 | public let value: T 7 | } 8 | } 9 | 10 | public extension MessageDecorator { 11 | var mentionedUsers: [Link>] { 12 | return buildLinks { match in 13 | guard match.string.hasPrefix("@U") else { return nil } 14 | 15 | return ModelPointer(id: 16 | String(match.string[match.string.index(after: match.string.startIndex)...]) 17 | ) 18 | } 19 | } 20 | var mentionedChannels: [Link>] { 21 | return buildLinks { match in 22 | guard match.string.hasPrefix("#C") else { return nil } 23 | 24 | return ModelPointer(id: 25 | String(match.string[match.string.index(after: match.string.startIndex)...]) 26 | ) 27 | } 28 | } 29 | var mentionedGroups: [Link>] { 30 | return buildLinks { match in 31 | guard match.string.hasPrefix("G") else { return nil } 32 | 33 | return ModelPointer(id: match.string) 34 | } 35 | } 36 | var mentionedCommands: [Link] { 37 | return buildLinks { match in 38 | return Command(rawValue: match.string) 39 | } 40 | } 41 | var mentionedLinks: [Link<(title: String, link: String)>] { 42 | let ignoredTokens = ["@", "#", "!", "G"] 43 | 44 | return buildLinks { match in 45 | let token = String(match.string[match.string.startIndex]) 46 | 47 | guard !ignoredTokens.contains(token) else { return nil } 48 | 49 | let components = match.string.components(separatedBy: "|") 50 | 51 | guard 52 | let first = components.first, 53 | let last = components.last 54 | else { return nil } 55 | 56 | return (first, last) 57 | } 58 | } 59 | } 60 | 61 | private extension MessageDecorator { 62 | func buildLinks(factory: (RegexMatch) -> T?) -> [Link] { 63 | let links: [RegexMatch] = text.substrings(matching: "<(.*?)>") 64 | 65 | return links 66 | .map { match -> RegexMatch in 67 | //TODO: needed because capture groups aren't currently supported 68 | // written in a way that if this code is left in by mistake 69 | // nothing will break 70 | var range = match.range 71 | if match.string.hasPrefix("<") { 72 | range = text.index(after: range.lowerBound)..") { 75 | range = range.lowerBound.. ModelPointer { 6 | guard 7 | let value = message.user 8 | ?? message.edited?.user 9 | ?? message.bot_id.map({ ModelPointer(id: $0.id) }) 10 | else { throw Error.unableToFind(value: #function) } 11 | 12 | return value 13 | 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/MessageDecorator/MessageDecorator+Source.swift: -------------------------------------------------------------------------------- 1 | 2 | public extension MessageDecorator { 3 | var isChannel: Bool { 4 | return message.channel != nil 5 | } 6 | var isThread: Bool { 7 | return message.thread != nil 8 | } 9 | var isIM: Bool { 10 | return message.im != nil 11 | } 12 | var isGroup: Bool { 13 | return message.group != nil 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/MessageDecorator/MessageDecorator+Targets.swift: -------------------------------------------------------------------------------- 1 | 2 | public extension MessageDecorator { 3 | func targetId() throws -> String { 4 | let targetId: String? = try 5 | message.thread.flatMap { try $0.targetId() } 6 | ?? message.im?.id 7 | ?? message.channel?.id 8 | ?? message.group?.id 9 | 10 | guard let value = targetId 11 | else { throw Error.unableToFind(value: #function) } 12 | 13 | return value 14 | } 15 | 16 | func target() throws -> TargetRepresentable { 17 | let target: TargetRepresentable? = try 18 | message.thread 19 | ?? message.channel.flatMap { try $0.value() } 20 | ?? message.im.flatMap { try $0.value() } 21 | ?? message.group.flatMap { try $0.value() } 22 | 23 | guard let value = target 24 | else { throw Error.unableToFind(value: #function) } 25 | 26 | return value 27 | } 28 | 29 | func threadTarget() throws -> TargetRepresentable { 30 | if let thread = message.thread { 31 | return thread 32 | } 33 | 34 | return TargetWrapper( 35 | name: "Thread", 36 | targetId: try targetId(), 37 | threadTs: message.ts 38 | ) 39 | } 40 | } 41 | 42 | private struct TargetWrapper: TargetRepresentable { 43 | private let _targetId: String 44 | 45 | let name: String 46 | 47 | func targetId() throws -> String { 48 | return _targetId 49 | } 50 | let targetThread_ts: String? 51 | 52 | init(name: String, targetId: String, threadTs: String) { 53 | self.name = name 54 | self._targetId = targetId 55 | self.targetThread_ts = threadTs 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/MessageDecorator/MessageDecorator.swift: -------------------------------------------------------------------------------- 1 | 2 | public extension MessageDecorator { 3 | public enum Error: Swift.Error { 4 | case unableToFind(value: String) 5 | } 6 | } 7 | 8 | public struct MessageDecorator { 9 | // MARK: - Public Properties 10 | public let message: Message 11 | 12 | // MARK: - Lifecycle 13 | init(message: Message) { 14 | self.message = message 15 | } 16 | } 17 | 18 | public extension Message { 19 | func makeDecorator() -> MessageDecorator { 20 | return MessageDecorator(message: self) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/ModelPointer+Matcher.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct ModelPointerMatcher: Matcher { 4 | let token: String 5 | 6 | func match(against input: String) -> Match? { 7 | let prefix = "<\(self.token)" 8 | let separator: Character = "|" 9 | let suffix: Character = ">" 10 | 11 | guard 12 | let value = input.components(separatedBy: " ").first, 13 | value.hasPrefix(prefix), 14 | let end = value.firstIndex(of: separator) ?? value.firstIndex(of: suffix) 15 | else { return nil } 16 | 17 | return Match( 18 | key: nil, 19 | value: ModelPointer(id: String(value[prefix.endIndex..(token: ModelType.token) 31 | } 32 | } 33 | 34 | extension BotUser: TypeMatcher { 35 | public static var any: Matcher { 36 | return ModelPointerMatcher(token: token) 37 | } 38 | } 39 | 40 | extension User: TypeMatcher { 41 | public static var any: Matcher { 42 | return ModelPointerMatcher(token: token) 43 | } 44 | } 45 | 46 | extension Channel: TypeMatcher { 47 | public static var any: Matcher { 48 | return ModelPointerMatcher(token: token) 49 | } 50 | } 51 | 52 | extension Group: TypeMatcher { 53 | public static var any: Matcher { 54 | return ModelPointerMatcher(token: token) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/Models+Matcher.swift: -------------------------------------------------------------------------------- 1 | 2 | public protocol ModelMatcher: Matcher, TokenRepresentable { 3 | var matcher: Matcher { get } 4 | } 5 | 6 | public extension ModelMatcher where Self: IDRepresentable & Nameable { 7 | var matcher: Matcher { 8 | let pattern = "<\(Self.token)\(id)>" 9 | 10 | return ValueMatcher( 11 | value: self, 12 | matches: [pattern] 13 | ) 14 | } 15 | func match(against input: String) -> Match? { 16 | return matcher.match(against: input) 17 | } 18 | var matcherDescription: String { 19 | return "(\(Self.token)\(name))" 20 | } 21 | } 22 | 23 | extension BotUser: ModelMatcher { } 24 | extension User: ModelMatcher { } 25 | extension Channel: ModelMatcher { } 26 | extension Group: ModelMatcher { } 27 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/PatternMatching/Match.swift: -------------------------------------------------------------------------------- 1 | 2 | /// Represents a matched `String` and its underlying value 3 | public struct Match { 4 | /// The key associated with this `Match` 5 | public let key: String? 6 | 7 | /// The _raw_ data representing the matched `String` 8 | public let value: Any 9 | 10 | /// The `String` that was matched in the pattern 11 | public let matched: String 12 | 13 | public init(key: String?, value: Any, matched: String) { 14 | self.key = key 15 | self.value = value 16 | self.matched = matched 17 | } 18 | 19 | func with(key: String) -> Match { 20 | return Match(key: key, value: value, matched: matched) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/PatternMatching/Matcher.swift: -------------------------------------------------------------------------------- 1 | 2 | /// Represents an object that can extract a single match from the provided input 3 | public protocol Matcher { 4 | /// Attempt to find a match from the _start_ of the input 5 | func match(against input: String) -> Match? 6 | 7 | /// When `true` this matcher will used to match as much of the input as possible 8 | /// otherwise it will complete with the smallest amount of the input 9 | /// 10 | /// - Default: `false` 11 | var greedy: Bool { get } 12 | 13 | /// Returns a `String` that represents the _specification_ of this `Matcher` 14 | var matcherDescription: String { get } 15 | } 16 | 17 | public extension Matcher { 18 | var greedy: Bool { return false } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/PatternMatching/Matchers/DynamicMatcher.swift: -------------------------------------------------------------------------------- 1 | 2 | struct DynamicMatcher: Matcher { 3 | typealias MatchTest = (String) -> Match? 4 | 5 | let greedy: Bool 6 | let match: MatchTest 7 | 8 | init(greedy: Bool, match: @escaping MatchTest) { 9 | self.greedy = greedy 10 | self.match = match 11 | } 12 | 13 | func match(against input: String) -> Match? { 14 | return match(input) 15 | } 16 | var matcherDescription: String { return "" } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/PatternMatching/Matchers/GreedyMatcher.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | /// Matches as much of the input as possible with the first `Matcher` 5 | /// until the second `Matcher` matches. 6 | /// 7 | /// NOTE: If the second `Matcher` does not match but the first can 8 | /// consume the entire input it will and that will be the returned `Match` 9 | struct GreedyMatcher: Matcher { 10 | private let first: Matcher 11 | private let second: Matcher 12 | 13 | init(match first: Matcher, until second: Matcher) { 14 | self.first = first 15 | self.second = second 16 | } 17 | func match(against input: String) -> Match? { 18 | guard first.greedy else { return first.match(against: input) } 19 | 20 | let indices = input.indices + [input.endIndex] 21 | for index in indices { 22 | let pair = input.split(at: index) 23 | 24 | guard second.match(against: pair.after) != nil else { continue } 25 | 26 | return first.match(against: pair.before) 27 | } 28 | 29 | return first.match(against: input) 30 | } 31 | 32 | var greedy: Bool { return first.greedy } 33 | var matcherDescription: String { return first.matcherDescription } 34 | } 35 | 36 | extension Matcher { 37 | func until(other: Matcher?) -> Matcher { 38 | guard let other = other else { return self } 39 | 40 | return GreedyMatcher(match: self, until: other) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/PatternMatching/Matchers/InstanceMatcher.swift: -------------------------------------------------------------------------------- 1 | 2 | /// Defines a specialised `Matcher` that matches `String` representations of the values of various instances 3 | public protocol InstanceMatcher: Matcher { 4 | var matcher: Matcher { get } 5 | } 6 | public extension InstanceMatcher { 7 | var matcher: Matcher { 8 | return ValueMatcher( 9 | value: self, 10 | matches: [String(describing: self)] 11 | ) 12 | } 13 | func match(against input: String) -> Match? { 14 | return matcher.match(against: input) 15 | } 16 | var matcherDescription: String { 17 | return matcher.matcherDescription 18 | } 19 | } 20 | 21 | extension String: InstanceMatcher { } 22 | extension Int: InstanceMatcher { } 23 | extension Bool: InstanceMatcher { 24 | public var matcher: Matcher { 25 | switch self { 26 | case true: return ValueMatcher(value: self, matches: Bool.truthy) 27 | case false: return ValueMatcher(value: self, matches: Bool.falsey) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/PatternMatching/Matchers/KeyedMatcher.swift: -------------------------------------------------------------------------------- 1 | 2 | /// Wraps an existing `Matcher` and adds the provided key to any `Match` found 3 | struct KeyedMatcher: Matcher { 4 | private let matcher: Matcher 5 | private let key: String 6 | 7 | init(matcher: Matcher, key: String) { 8 | self.matcher = matcher 9 | self.key = key 10 | } 11 | 12 | func match(against input: String) -> Match? { 13 | return matcher.match(against: input)?.with(key: key) 14 | } 15 | 16 | var greedy: Bool { return matcher.greedy } 17 | var matcherDescription: String { return "<\(key)>" } 18 | } 19 | 20 | public extension Matcher { 21 | /// Create a new `Matcher` from the reciever which associate a key with any `Match`es it produces 22 | func using(key: String) -> Matcher { 23 | return KeyedMatcher(matcher: self, key: key) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/PatternMatching/Matchers/OptionalMatcher.swift: -------------------------------------------------------------------------------- 1 | 2 | public extension Matcher { 3 | /// Make this `Matcher` optional 4 | var orNone: Matcher { 5 | return OptionalMatcher(matcher: self) 6 | } 7 | } 8 | 9 | struct OptionalMatcher: Matcher { 10 | let matcher: Matcher 11 | 12 | func match(against input: String) -> Match? { 13 | let match = matcher.match(against: input) 14 | 15 | return match ?? Match( 16 | key: nil, 17 | value: "", 18 | matched: "" 19 | ) 20 | } 21 | 22 | var greedy: Bool { return matcher.greedy } 23 | var matcherDescription: String { 24 | let sanitized = matcher 25 | .matcherDescription 26 | .replacingOccurrences(of: "\n", with: "") 27 | 28 | return sanitized.isEmpty 29 | ? "" 30 | : "[\(sanitized.strippingSyntax())]" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/PatternMatching/Matchers/SequenceMatcher.swift: -------------------------------------------------------------------------------- 1 | 2 | extension Sequence where Iterator.Element: Matcher { 3 | /// Match any of the `Matcher`s within this `Sequence` 4 | public var any: Matcher { 5 | return SequenceMatcher(matchers: Array(self)) 6 | } 7 | } 8 | 9 | /// Attempts to match the input against each `Matcher` returning the first `Match` found, if any 10 | struct SequenceMatcher: Matcher { 11 | private let matchers: [Matcher] 12 | 13 | init(matchers: [Matcher]) { 14 | self.matchers = Array(matchers) 15 | } 16 | func match(against input: String) -> Match? { 17 | return matchers 18 | .lazy 19 | .flatMap { $0.match(against: input) } 20 | .first 21 | } 22 | 23 | var matcherDescription: String { 24 | let description = matchers 25 | .map { $0.matcherDescription } 26 | .joined(separator: "|") 27 | 28 | return "(\(description))" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/PatternMatching/Matchers/TypeMatcher.swift: -------------------------------------------------------------------------------- 1 | 2 | /// Defines a specialised `Matcher` that matches wildcard representations of vaious types 3 | public protocol TypeMatcher { 4 | static var any: Matcher { get } 5 | } 6 | 7 | extension String: TypeMatcher { 8 | public static var any: Matcher { 9 | return DynamicMatcher( 10 | greedy: true, 11 | match: { input in 12 | guard !input.isEmpty else { return nil } 13 | 14 | return Match( 15 | key: nil, 16 | value: input, 17 | matched: input 18 | ) 19 | } 20 | ) 21 | } 22 | } 23 | extension Int: TypeMatcher { 24 | public static var any: Matcher { 25 | return DynamicMatcher( 26 | greedy: false, 27 | match: { input in 28 | guard 29 | let first = input.components(separatedBy: " ").first, 30 | let value = Int(first) 31 | else { return nil } 32 | 33 | return Match( 34 | key: nil, 35 | value: value, 36 | matched: String(input[input.startIndex.. Match? { 14 | let sanitizedInput = input.lowercased() 15 | 16 | guard let match = matches.first(where: sanitizedInput.hasPrefix) 17 | else { return nil } 18 | 19 | return Match( 20 | key: nil, 21 | value: value, 22 | matched: String(input[input.startIndex.. String { 6 | guard 7 | let first = self.first, 8 | characters.contains(first) 9 | else { return self } 10 | 11 | return self 12 | .dropFirst() 13 | .map { String($0) } 14 | .joined() 15 | } 16 | func stripEnd(characters: [Character]) -> String { 17 | guard 18 | let last = self.last, 19 | characters.contains(last) 20 | else { return self } 21 | 22 | return self 23 | .dropLast() 24 | .map { String($0) } 25 | .joined() 26 | } 27 | func strippingSyntax() -> String { 28 | let start: [Character] = ["[", "(", "<"] 29 | let end: [Character] = ["]", ")", ">"] 30 | 31 | return self 32 | .stripStart(characters: start) 33 | .stripEnd(characters: end) 34 | } 35 | } 36 | 37 | extension Bool { 38 | static let truthy = ["1", "true", "t", "yes", "yep", "yup", "yeah", "yeh", "y"] 39 | static let falsey = ["0", "false", "f", "nah", "nup", "nope", "no", "n"] 40 | } 41 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/PatternMatching/PatternMatch.swift: -------------------------------------------------------------------------------- 1 | 2 | extension PatternMatch { 3 | enum Error: Swift.Error { 4 | case missingKey(key: String) 5 | case typeMismatch(key: String, expected: Any.Type, found: Any.Type) 6 | } 7 | } 8 | 9 | /// Represents the results of matching a series of `Matcher`s against provided `String` 10 | public struct PatternMatch { 11 | private let matches: [Match] 12 | 13 | init(matches: [Match]) { 14 | self.matches = matches 15 | } 16 | 17 | /// Attempt to extract the underlying matched value for the provided `key` 18 | public func value(key: String) throws -> T { 19 | guard let match = matches.first(where: { $0.key == key }) 20 | else { throw Error.missingKey(key: key) } 21 | 22 | guard let value = match.value as? T 23 | else { throw Error.typeMismatch(key: key, expected: T.self, found: type(of: match.value)) } 24 | 25 | return value 26 | } 27 | } 28 | 29 | /// Provides a `String` that represents the specification for an entire pattern 30 | public func patternDescription(_ pattern: [Matcher]) -> String { 31 | return pattern 32 | .map { $0.matcherDescription } 33 | .filter { !$0.isEmpty } 34 | .joined(separator: " ") 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/PatternMatching/PatternRepresentable.swift: -------------------------------------------------------------------------------- 1 | 2 | /// Describes the different contexts patterns can be used in 3 | public enum PatternContext { 4 | /// Can be used anywhere 5 | case any 6 | 7 | /// Can only be used in channels (including their threads) 8 | case `public` 9 | 10 | /// Can only be used in IMs 11 | case `private` 12 | } 13 | 14 | /// Defines an object that is representable as a pattern 15 | public protocol PatternRepresentable { 16 | /// Sequence of `Matcher`s that make up a complete pattern 17 | var pattern: [Matcher] { get } 18 | 19 | /// The `PatternContext` this pattern will be available in 20 | var context: PatternContext { get } 21 | 22 | /// When `true` the pattern must match exactly, otherwise the pattern will 23 | /// be surrounded by `String.any.orNone` `Matcher`s to allow a more flexible input 24 | /// 25 | /// - Default: `true` 26 | var strict: Bool { get } 27 | 28 | /// When `true` only admins will be able to trigger this pattern 29 | /// 30 | /// - Default: `false` 31 | var requiresAdmin: Bool { get } 32 | } 33 | 34 | extension PatternRepresentable { 35 | public var context: PatternContext { return .any } 36 | public var strict: Bool { return true } 37 | public var requiresAdmin: Bool { return false } 38 | } 39 | 40 | /// Function called when a pattern is matched, provides the bot instance as well as the message and matching pattern data 41 | public typealias PatternHandler = (SlackBot, MessageDecorator, PatternMatch) throws -> Void 42 | 43 | public extension SlackBot { 44 | /// Attempt to match the text from `message` against the provided `pattern`. 45 | /// if successful the supplied function is called with: 46 | /// - The `SlackBot` instance 47 | /// - The matched `MessageDecorator` 48 | /// - The `PatternMatch` data 49 | @discardableResult 50 | func route(_ message: MessageDecorator, matching pattern: PatternRepresentable, to closure: PatternHandler) throws -> Self { 51 | // stop early if this pattern requires admin and the user isn't one 52 | if try pattern.requiresAdmin && !(message.sender().value().is_admin) { return self } 53 | 54 | var pattern = pattern 55 | 56 | switch (pattern.context, message.isIM) { 57 | 58 | // check for invalid context 59 | case (.private, false): 60 | return self 61 | case (.public, true): 62 | return self 63 | 64 | // adjust pattern for target context 65 | case (.any, false), (.public, false): 66 | // don't add bot user to the start if it's already contained in the pattern 67 | // TODO : this predicate is a little ugly not to mention fragile... perhaps require `Matcher`s to be `Equatable` ? 68 | if pattern.pattern.contains(where: { $0 is BotUser && $0.matcherDescription == "(@\(me.name))" }) { 69 | break 70 | } 71 | pattern = pattern.preceed(with: [me, String.any.orNone]) 72 | 73 | default: 74 | break 75 | } 76 | 77 | if let match = message.text.patternMatch(against: pattern.pattern, strict: pattern.strict) { 78 | try closure(self, message, match) 79 | } 80 | 81 | return self 82 | } 83 | } 84 | 85 | private struct WrappedPattern: PatternRepresentable { 86 | let pattern: [Matcher] 87 | let context: PatternContext 88 | let strict: Bool 89 | let requiresAdmin: Bool 90 | } 91 | 92 | public extension PatternRepresentable { 93 | /// Create a new `PatternRepresentable` in which the original pattern is preceeded by the supplied `Matcher`s 94 | func preceed(with matchers: [Matcher]) -> PatternRepresentable { 95 | return WrappedPattern( 96 | pattern: matchers + pattern, 97 | context: context, 98 | strict: strict, 99 | requiresAdmin: requiresAdmin 100 | ) 101 | } 102 | 103 | /// Create a new `PatternRepresentable` in which the original pattern is preceeded by the supplied `Matcher` 104 | func preceed(with matcher: Matcher) -> PatternRepresentable { 105 | return preceed(with: [matcher]) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/PatternMatching/String+PatternMatch.swift: -------------------------------------------------------------------------------- 1 | 2 | extension String { 3 | /// Tests the receiver incrementally against a sequence of `Matcher`s 4 | /// 5 | /// Testing is performed from left to right and each test must succeed in order otherwise the whole test fails. 6 | /// 7 | /// Examples 8 | /// You can perform simple pattern matching: 9 | /// ``` 10 | /// let source = "hello @botname" 11 | /// if let helloCommand = source.patternMatch(against: [["hi", "hello", "hey"].any, slackBot.me]) { 12 | /// // User said hello to the bot 13 | /// } 14 | /// ``` 15 | /// 16 | /// or if you need to extract the values from the pattern: 17 | /// ``` 18 | /// let source = "@botname give me a number between 0 and 42" 19 | /// if let command = source.patternMatch(against: [slackBot.me, "give me a number between", Int.any.using(key: "first"), "and", Int.any.using(key: "second")]) { 20 | /// let first: Int = try command.value(key: "first") 21 | /// let second: Int = command.value(key: "second") 22 | /// 23 | /// //use Ints to get random number 24 | /// } 25 | /// ``` 26 | /// 27 | /// - Parameter matchers: A sequence of `Matcher`s to attempt a match with 28 | /// - Parameter strict: When `false` the pattern is surrounded by `String.any.orNone` `Matcher`s to allow a more flexible input 29 | /// - Returns: A new `PatternMatch` instance if the receiver matches the `Matcher`s, otherwise `nil` 30 | public func patternMatch(against matchers: [Matcher], strict: Bool = true) -> PatternMatch? { 31 | var matchers = matchers 32 | if !strict { 33 | matchers.insert(String.any.orNone, at: 0) 34 | matchers.append(String.any.orNone) 35 | } 36 | 37 | var input = self 38 | var results: [Match] = [] 39 | 40 | for (_, current, next) in matchers.neighbors { 41 | guard 42 | let match = current.until(other: next).match(against: input) 43 | else { return nil } 44 | 45 | results.append(match) 46 | input = input.remove(prefix: match.matched) 47 | } 48 | 49 | guard input.isEmpty else { return nil } 50 | 51 | return PatternMatch(matches: results) 52 | } 53 | 54 | public func patternMatches(against matchers: [Matcher], strict: Bool = true) -> Bool { 55 | return patternMatch(against: matchers, strict: strict) != nil 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/SlackBot+Convenience.swift: -------------------------------------------------------------------------------- 1 | 2 | public extension SlackBot { 3 | func send(_ message: ChatMessage) throws { 4 | let request = ChatPostMessage(message: message) 5 | try webApi.perform(request: request) 6 | } 7 | 8 | func send(_ text: [ChatMessageSegmentRepresentable], to target: TargetRepresentable) throws { 9 | let message = ChatMessageDecorator(target: target).text(text) 10 | try send(message.makeChatMessage()) 11 | } 12 | 13 | func react(to message: MessageDecorator, with emoji: T) throws { 14 | let target = ChannelReaction(id: try message.targetId(), messageTs: message.message.ts) 15 | let request = ReactionsAdd(emoji: emoji, target: target) 16 | try webApi.perform(request: request) 17 | } 18 | 19 | func permalink(_ message: Message) throws -> String { 20 | let request = ChatPermalink(message: message) 21 | return try webApi.perform(request: request) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/SlackBotServices/SlackBotConnectionService.swift: -------------------------------------------------------------------------------- 1 | 2 | private let TargetKey = "SlackBotConnectionServiceTarget" 3 | 4 | /// Service that allows the bot to notify a given `Channel` when it comes online 5 | public final class SlackBotConnectionService: SlackBotMessageService { 6 | // MARK: - Private Properties 7 | private let store: KeyValueStore 8 | private var target: ModelPointer? { 9 | get { 10 | return try? store.get(forKey: TargetKey) 11 | } 12 | set { 13 | if let value = newValue { 14 | store.set(value: value, forKey: TargetKey) 15 | } else { 16 | store.remove(key: TargetKey) 17 | } 18 | } 19 | } 20 | 21 | // MARK: - Lifecycle 22 | public init(store: KeyValueStore, target: ModelPointer? = nil) { 23 | self.store = store 24 | 25 | if let target = target { 26 | store.set(value: target, forKey: TargetKey) 27 | } 28 | } 29 | 30 | // MARK: - Public 31 | public func configure(slackBot: SlackBot) { 32 | configureMessageService(slackBot: slackBot) 33 | 34 | slackBot.registerHelp(item: Patterns.target) 35 | } 36 | public func connected(slackBot: SlackBot) { 37 | guard let me = slackBot.me else { return } 38 | 39 | send(message: [me, "reporting for duty!"], using: slackBot) 40 | } 41 | public func disconnected(slackBot: SlackBot, reconnecting: Bool) { 42 | guard !reconnecting, let me = slackBot.me else { return } 43 | 44 | send(message: [me, "signing off."], using: slackBot) 45 | } 46 | public func onMessage(slackBot: SlackBot, message: MessageDecorator, previous: MessageDecorator?) throws { 47 | try slackBot.route(message, matching: Patterns.target) { bot, msg, match in 48 | let target: ModelPointer = try match.value(key: "channel") 49 | 50 | self.target = target 51 | 52 | try bot 53 | .send(try msg 54 | .respond() 55 | .text(["Sending connection status to", try target.value(), "(\(target.id))"]) 56 | .makeChatMessage() 57 | ) 58 | } 59 | } 60 | 61 | // MARK: - Private 62 | private func send(message: [ChatMessageSegmentRepresentable], using slackBot: SlackBot) { 63 | guard let target = target.flatMap({ try? $0.value() }) else { return } 64 | 65 | do { 66 | let message = try ChatMessageDecorator(target: target) 67 | .text(message) 68 | .makeChatMessage() 69 | 70 | try slackBot.send(message) 71 | 72 | } catch { } 73 | } 74 | } 75 | 76 | private extension SlackBotConnectionService { 77 | enum Patterns: HelpRepresentable { 78 | case target 79 | 80 | var topic: String { return "Connection Status Reporting" } 81 | var description: String { 82 | switch self { 83 | case .target: return "Tell the bot where to send its connection status" 84 | } 85 | } 86 | var pattern: [Matcher] { 87 | switch self { 88 | case .target: 89 | return [ 90 | ["report", "send", "deliver"].any, 91 | "connection status to", 92 | Channel.any.using(key: "channel") 93 | ] 94 | } 95 | } 96 | var requiresAdmin: Bool { 97 | return true 98 | } 99 | } 100 | } 101 | 102 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/SlackBotServices/SlackBotErrorService.swift: -------------------------------------------------------------------------------- 1 | 2 | private let TargetKey = "SlackBotErrorServiceTarget" 3 | 4 | /// Service that allows the bot to notify a given `Channel` when errors occur 5 | public final class SlackBotErrorService: SlackBotMessageService { 6 | // MARK: - Private Properties 7 | private let store: KeyValueStore 8 | private var target: ModelPointer? { 9 | get { 10 | return try? store.get(forKey: TargetKey) 11 | } 12 | set { 13 | if let value = newValue { 14 | store.set(value: value, forKey: TargetKey) 15 | } else { 16 | store.remove(key: TargetKey) 17 | } 18 | } 19 | } 20 | 21 | // MARK: - Lifecycle 22 | public init(store: KeyValueStore, target: ModelPointer? = nil) { 23 | self.store = store 24 | 25 | if let target = target { 26 | store.set(value: target, forKey: TargetKey) 27 | } 28 | } 29 | 30 | // MARK: - Public 31 | public func configure(slackBot: SlackBot) { 32 | configureMessageService(slackBot: slackBot) 33 | 34 | slackBot.registerHelp(item: Patterns.target) 35 | } 36 | public func error(slackBot: SlackBot, error: Error) { 37 | do { 38 | guard let target = try target?.value() else { return } 39 | 40 | let message = try ChatMessageDecorator(target: target) 41 | .text(["Error occurred".bold]) 42 | .newLine() 43 | .text([String(describing: error)]) 44 | .makeChatMessage() 45 | 46 | try slackBot.send(message) 47 | 48 | } catch let error { 49 | defaultErrorHandler(error) 50 | } 51 | } 52 | public func onMessage(slackBot: SlackBot, message: MessageDecorator, previous: MessageDecorator?) throws { 53 | _ = try? slackBot.route(message, matching: Patterns.target) { bot, msg, match in 54 | do { 55 | let target: ModelPointer = try match.value(key: "channel") 56 | 57 | self.target = target 58 | 59 | try bot 60 | .send(try msg 61 | .respond() 62 | .text(["Sending errors to", try target.value(), "(\(target.id))"]) 63 | .makeChatMessage() 64 | ) 65 | 66 | } catch let error { 67 | defaultErrorHandler(error) 68 | } 69 | } 70 | } 71 | } 72 | 73 | private extension SlackBotErrorService { 74 | enum Patterns: HelpRepresentable { 75 | case target 76 | 77 | var topic: String { return "Error Reporting" } 78 | var description: String { 79 | switch self { 80 | case .target: return "Tell the bot where to send error reports" 81 | } 82 | } 83 | var pattern: [Matcher] { 84 | switch self { 85 | case .target: 86 | return [ 87 | ["report", "send", "deliver"].any, 88 | ["errors", "problems", "issues", "trouble"].any, 89 | "to", 90 | Channel.any.using(key: "channel") 91 | ] 92 | } 93 | } 94 | var requiresAdmin: Bool { 95 | return true 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/SlackBotServices/SlackBotHelpService.swift: -------------------------------------------------------------------------------- 1 | 2 | var sharedHelpRegister: SlackBotHelpService? 3 | 4 | /// Represents a `PatternRepresentable` that can be extended to include help data 5 | public protocol HelpRepresentable: PatternRepresentable { 6 | /// The topic this pattern relates to, used to group items together. 7 | /// 8 | /// This will often be the 'topic' of the `SlackBotService` it comes from, i.e. "Karma" 9 | var topic: String { get } 10 | 11 | /// A description of the action this pattern performs 12 | var description: String { get } 13 | } 14 | 15 | private extension PatternContext { 16 | var availability: String { 17 | switch self { 18 | case .any: return "channels and IMs" 19 | case .private: return "IMs" 20 | case .public: return "channels" 21 | } 22 | } 23 | } 24 | 25 | /// Service that allows other services to add help and display those items to users 26 | public final class SlackBotHelpService: SlackBotMessageService { 27 | fileprivate var items: [HelpRepresentable] = [] 28 | private let context: PatternContext 29 | 30 | public init(context: PatternContext = .private) { 31 | self.context = context 32 | sharedHelpRegister = self 33 | } 34 | public func onMessage(slackBot: SlackBot, message: MessageDecorator, previous: MessageDecorator?) throws { 35 | guard !items.isEmpty else { return } 36 | 37 | let isAdmin = try message.sender().value().is_admin 38 | let phrases = ["help"] 39 | 40 | let pattern: [Matcher] 41 | switch (context, message.isIM) { 42 | case (.public, false), (.any, false): 43 | pattern = [slackBot.me, String.any.orNone, phrases.any] 44 | 45 | case (.private, true), (.any, true): 46 | pattern = [phrases.any] 47 | 48 | default: return 49 | } 50 | 51 | guard message.text.patternMatches(against: pattern, strict: false) else { return } 52 | 53 | let data: [String: [HelpRepresentable]] = items 54 | .filter { !$0.requiresAdmin || isAdmin } 55 | .filter { !$0.topic.isEmpty } 56 | .group(by: { $0.topic }) 57 | .filter { !$0.value.isEmpty } 58 | 59 | if data.isEmpty { 60 | try slackBot.send( 61 | message.respond().text(["Help me to help you..., ask my owner to add help items"]).makeChatMessage() 62 | ) 63 | 64 | } else { 65 | for (topic, entries) in data { 66 | let response = try message 67 | .respond() 68 | .text([topic.bold]) 69 | .newLine() 70 | 71 | for entry in entries { 72 | response 73 | .text([entry.description]) 74 | .newLine() 75 | .text(["Available in", entry.context.availability]) 76 | .newLine() 77 | .text([patternDescription(entry.pattern).pre]) 78 | .newLine() 79 | } 80 | 81 | try slackBot.send(response.makeChatMessage()) 82 | } 83 | } 84 | } 85 | } 86 | 87 | public extension SlackBot { 88 | /// Register help items 89 | /// 90 | /// - Parameter item: The `HelpRepresentable` to register 91 | @discardableResult 92 | func registerHelp(item: HelpRepresentable) -> SlackBot { 93 | sharedHelpRegister?.items.append(item) 94 | return self 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/SlackBotServices/SlackBotMessageService.swift: -------------------------------------------------------------------------------- 1 | 2 | /// Service provides an easier way of working with messages 3 | public protocol SlackBotMessageService: SlackBotService { 4 | /// Allows restricting of non-standard messages to only those matching these provided `Message.Subtype`s 5 | /// 6 | /// - Default: `.me_message` and `.message_changed` 7 | var allowedSubTypes: [Message.Subtype] { get } 8 | 9 | /// Called when a message is received 10 | /// 11 | /// - Parameters: 12 | /// - message: A `MessageDecorator` wrapping the received `Message` 13 | /// - previous: If `message` is an edited message this will be the previous/original message, otherwise `nil` 14 | func onMessage(slackBot: SlackBot, message: MessageDecorator, previous: MessageDecorator?) throws 15 | } 16 | 17 | extension SlackBotMessageService { 18 | public var allowedSubTypes: [Message.Subtype] { return [.me_message, .message_changed] } 19 | 20 | public func configure(slackBot: SlackBot) { 21 | configureMessageService(slackBot: slackBot) 22 | } 23 | 24 | public func configureMessageService(slackBot: SlackBot) { 25 | slackBot.on(message.self, service: self) { [unowned self] bot, data in 26 | if let subtype = data.message.subtype, !self.allowedSubTypes.contains(subtype) { 27 | return 28 | } 29 | 30 | let message = MessageDecorator(message: data.message) 31 | 32 | guard (try? message.sender().id) != bot.me.id else { return } 33 | 34 | try self.onMessage( 35 | slackBot: bot, 36 | message: message, 37 | previous: data.previous.map(MessageDecorator.init) 38 | ) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/SlackBotServices/SlackBotService+Errors.swift: -------------------------------------------------------------------------------- 1 | 2 | /// Encapsulates errors received from `SlackBotService`s to provide more detail when they are propagated forward 3 | public enum SlackBotServiceError: Swift.Error, CustomStringConvertible { 4 | case error(type: T.Type, error: Swift.Error) 5 | 6 | public var description: String { 7 | switch self { 8 | case .error(let type, let error): 9 | return "\(String(reflecting: type)) > \(String(describing: error))" 10 | } 11 | } 12 | } 13 | 14 | extension SlackBotService { 15 | func error(wrapping error: Swift.Error) -> SlackBotServiceError { 16 | return .error(type: Self.self, error: error) 17 | } 18 | } 19 | 20 | public extension SlackBot { 21 | /// Subscribes to an `RTMAPIEvent` 22 | /// 23 | /// - note: this provides the same functionality as `slackBot.on(_:handler:)` but any errors 24 | /// received are wrapped in a `SlackBotServiceError` to provide more details 25 | /// 26 | /// - Parameters: 27 | /// - event: The `RTMAPIEvent` to subscribe to 28 | /// - service: The `SlackBotService` subscribing to the event 29 | /// - handler: The function called when the event occurs. Provides the bot instance and the events data 30 | func on(_ event: T.Type, service: U, handler: @escaping (SlackBot, T.EventData) throws -> Void) { 31 | rtmApi.on(event) { [unowned self] data in 32 | do { 33 | try handler(self, data) 34 | } catch let error { 35 | throw service.error(wrapping: error) 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Chameleon/Sugar/SlackBotServices/SlackBotTimedService.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | /// Service that allows actions to be performed at a given interval 5 | /// 6 | /// - note: This relies on Slacks ping/pong to provide a _low resolution_ timer implementation 7 | public class SlackBotTimedService: SlackBotService { 8 | public typealias TimerAction = (SlackBot, Pong) throws -> Void 9 | 10 | private let id: String 11 | private let storage: KeyValueStore 12 | private let interval: TimeInterval 13 | private let action: TimerAction 14 | 15 | private func key(_ string: String = #function) -> String { 16 | return "\(SlackBotTimedService.self)\(id):\(string)" 17 | } 18 | private var lastExecuted: Int { 19 | get { return (try? storage.get(forKey: key())) ?? 0 } 20 | set { storage.set(value: newValue, forKey: key()) } 21 | } 22 | 23 | /// Create a new instance 24 | /// 25 | /// - Parameters: 26 | /// - id: A unique identifier to represent this instance 27 | /// - storage: The `KeyValueStore` used to persist the state for this instance 28 | /// - interval: The interval in seconds at which this instances action should be performed 29 | /// - action: The action to perform at each `interval` 30 | public init(id: String, storage: KeyValueStore, interval: TimeInterval, action: @escaping TimerAction) { 31 | self.id = id 32 | self.storage = storage 33 | self.interval = interval 34 | self.action = action 35 | } 36 | 37 | public func configure(slackBot: SlackBot) { 38 | slackBot.on(pong.self, service: self) { [unowned self] bot, pong in 39 | guard Double(pong.timestamp - self.lastExecuted) >= self.interval else { return } 40 | 41 | self.lastExecuted = pong.timestamp 42 | try self.action(bot, pong) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/Common/Codable/Decodable.swift: -------------------------------------------------------------------------------- 1 | 2 | public protocol Decodable { 3 | init(decoder: Decoder) throws 4 | } 5 | 6 | public enum DecodeError: Error, CustomStringConvertible { 7 | case error(type: T.Type, error: Error) 8 | 9 | public var description: String { 10 | switch self { 11 | case .error(let type, let error): 12 | return "\(String(reflecting: type)) > \(String(describing: error))" 13 | } 14 | } 15 | } 16 | 17 | public func decode(_ closure: () throws -> T) rethrows -> T { 18 | do { return try closure() } 19 | catch let error { throw DecodeError.error(type: T.self, error: error) } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Common/Codable/Decoder+Decodable.swift: -------------------------------------------------------------------------------- 1 | 2 | public extension Decoder { 3 | public func value(of type: T.Type = T.self, at keyPath: [KeyPathComponent]) throws -> T { 4 | let innerData: [String: Any] = try value(at: keyPath) 5 | let decoder = Decoder(data: innerData) 6 | 7 | return try T(decoder: decoder) 8 | } 9 | public func values(of type: T.Type = T.self, at keyPath: [KeyPathComponent]) throws -> [T] { 10 | let innerData: [[String: Any]] = try value(at: keyPath) 11 | 12 | return try innerData.map { try T(decoder: Decoder(data: $0)) } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Common/Codable/Decoder.swift: -------------------------------------------------------------------------------- 1 | 2 | open class Decoder { 3 | // MARK: - Public Properties 4 | public let data: KeyPathAccessible 5 | 6 | // MARK: - Lifecycle 7 | public init(data: KeyPathAccessible) { 8 | self.data = data 9 | } 10 | 11 | // MARK: - Public 12 | public func value(of type: T.Type = T.self, at keyPath: [KeyPathComponent]) throws -> T { 13 | guard let last = keyPath.last else { throw KeyPathError.invalid(key: keyPath) } 14 | 15 | do { 16 | return try keyPath 17 | .dropLast() 18 | .reduce(data) { try $0.path(at: $1) } 19 | .value(at: last) 20 | 21 | } catch let error as KeyPathError { 22 | switch error { 23 | case .invalid: 24 | throw KeyPathError.invalid(key: keyPath) 25 | case .missing: 26 | throw KeyPathError.missing(key: keyPath) 27 | case .mismatch(_, let expected, let got): 28 | throw KeyPathError.mismatch(key: keyPath, expected: expected, found: got) 29 | } 30 | 31 | } catch let error { 32 | throw error 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Common/Codable/Encodable.swift: -------------------------------------------------------------------------------- 1 | 2 | public protocol Encodable { 3 | func encode() -> [String: Any?] 4 | } 5 | 6 | public extension Dictionary where Value: OptionalType { 7 | func strippingNils() -> [Key: Value.WrappedType] { 8 | var result: [Key: Value.WrappedType] = [:] 9 | for (key, value) in self where value.value != nil { 10 | result[key] = value.value! 11 | } 12 | return result 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Common/Collection+Extensions.swift: -------------------------------------------------------------------------------- 1 | 2 | #if os(Linux) 3 | import Glibc 4 | #else 5 | import Darwin.C 6 | #endif 7 | 8 | public extension Collection where Index == Int { 9 | /// Returns a random element from the collection. 10 | var randomElement: Iterator.Element? { 11 | guard !self.isEmpty else { return nil } 12 | 13 | let min = self.startIndex 14 | let max = self.endIndex - self.startIndex 15 | #if os(Linux) 16 | let index = Int(Glibc.random() % max) + min 17 | #else 18 | let index = Int(arc4random_uniform(UInt32(max))) + min 19 | #endif 20 | 21 | return self[index] 22 | } 23 | } 24 | 25 | public extension Collection { 26 | func group(by closure: (Iterator.Element) throws -> T) rethrows -> [T: [Iterator.Element]] { 27 | var result: [T: [Iterator.Element]] = [:] 28 | for item in self { 29 | let key = try closure(item) 30 | var items = result[key] ?? [] 31 | items.append(item) 32 | result[key] = items 33 | } 34 | return result 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/Common/Dictionary+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension Dictionary { 4 | static func +(lhs: [Key: Value], rhs: [Key: Value]) -> [Key: Value] { 5 | return lhs.appending(rhs) 6 | } 7 | 8 | func appending(_ other: [Key: Value]?) -> [Key: Value] { 9 | guard let other = other else { return self } 10 | 11 | var result = self 12 | for (key, value) in other { 13 | result[key] = value 14 | } 15 | return result 16 | } 17 | } 18 | 19 | public extension Dictionary { 20 | func map(_ closure: (Key, Value) throws -> (T, U)) rethrows -> [T: U] { 21 | return try flatMap { key, value in try closure(key, value) } 22 | } 23 | func flatMap(_ closure: (Key, Value) throws -> (T, U)?) rethrows -> [T: U] { 24 | var result: [T: U] = [:] 25 | 26 | for (key, value) in self { 27 | guard let (k, v) = try closure(key, value) else { continue } 28 | result[k] = v 29 | } 30 | 31 | return result 32 | } 33 | } 34 | 35 | public extension Dictionary { 36 | func makeString() -> String? { 37 | guard 38 | let data = try? JSONSerialization.data(withJSONObject: self, options: []) 39 | else { return nil } 40 | 41 | return String(data: data, encoding: .utf8) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/Common/ErrorHandler.swift: -------------------------------------------------------------------------------- 1 | 2 | public typealias ErrorHandler = (Error) -> Void 3 | 4 | public func defaultErrorHandler(_ error: Error) { 5 | print("ERROR: \(error)") 6 | } 7 | -------------------------------------------------------------------------------- /Sources/Common/KeyPathAccessible/KeyPathAccessible+Array.swift: -------------------------------------------------------------------------------- 1 | 2 | extension Array: KeyPathAccessible { 3 | public func value(at key: KeyPathComponent) throws -> T { 4 | guard let itemKey = key as? Index else { throw KeyPathError.invalid(key: [key]) } 5 | 6 | guard itemKey >= startIndex && itemKey < endIndex else { throw KeyPathError.missing(key: [key]) } 7 | 8 | let value = self[itemKey] 9 | guard let typedValue = value as? T 10 | else { throw KeyPathError.mismatch(key: [key], expected: T.self, found: type(of: value)) } 11 | 12 | return typedValue 13 | } 14 | public func path(at key: KeyPathComponent) throws -> KeyPathAccessible { 15 | return try value(at: key) 16 | } 17 | } 18 | 19 | 20 | #if !os(Linux) 21 | import Foundation 22 | 23 | extension NSArray: KeyPathAccessible { 24 | public func value(at key: KeyPathComponent) throws -> T { 25 | return try (self as Array).value(at: key) 26 | } 27 | public func path(at key: KeyPathComponent) throws -> KeyPathAccessible { 28 | return try (self as Array).path(at: key) 29 | } 30 | } 31 | #endif 32 | -------------------------------------------------------------------------------- /Sources/Common/KeyPathAccessible/KeyPathAccessible+Dictionary.swift: -------------------------------------------------------------------------------- 1 | 2 | extension Dictionary: KeyPathAccessible { 3 | public func value(at key: KeyPathComponent) throws -> T { 4 | guard let itemKey = key as? Key else { throw KeyPathError.invalid(key: [key]) } 5 | 6 | guard let value = self[itemKey] else { throw KeyPathError.missing(key: [key]) } 7 | 8 | guard let typedValue = value as? T 9 | else { throw KeyPathError.mismatch(key: [key], expected: T.self, found: type(of: value)) } 10 | 11 | return typedValue 12 | } 13 | 14 | public func path(at key: KeyPathComponent) throws -> KeyPathAccessible { 15 | return try value(at: key) 16 | } 17 | } 18 | 19 | #if !os(Linux) 20 | import Foundation 21 | 22 | extension NSDictionary: KeyPathAccessible { 23 | public func value(at key: KeyPathComponent) throws -> T { 24 | return try (self as Dictionary).value(at: key) 25 | } 26 | public func path(at key: KeyPathComponent) throws -> KeyPathAccessible { 27 | return try (self as Dictionary).path(at: key) 28 | } 29 | } 30 | #endif 31 | -------------------------------------------------------------------------------- /Sources/Common/KeyPathAccessible/KeyPathAccessible.swift: -------------------------------------------------------------------------------- 1 | 2 | public enum KeyPathError: Error, CustomStringConvertible { 3 | case invalid(key: [KeyPathComponent]) 4 | case missing(key: [KeyPathComponent]) 5 | case mismatch(key: [KeyPathComponent], expected: Any.Type, found: Any.Type) 6 | 7 | public var description: String { 8 | switch self { 9 | case .invalid(let keyPath): 10 | return "Invalid keyPath provided: '\(keyPath)'" 11 | case .missing(let keyPath): 12 | return "KeyPath not found at: '\(keyPath)'" 13 | case .mismatch(let keyPath, let expected, let found): 14 | return "Type mismatch at: '\(keyPath)' - Expected: \(expected), found: \(found)" 15 | } 16 | } 17 | } 18 | 19 | public protocol KeyPathAccessible { 20 | func value(at key: KeyPathComponent) throws -> T 21 | func path(at key: KeyPathComponent) throws -> KeyPathAccessible 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Common/KeyPathAccessible/KeyPathComponent.swift: -------------------------------------------------------------------------------- 1 | 2 | public protocol KeyPathComponent { } 3 | 4 | extension String: KeyPathComponent { } 5 | extension Int: KeyPathComponent { } 6 | -------------------------------------------------------------------------------- /Sources/Common/NeighborSequence.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A collection that returns the current element as well as the previous and next elements when available 4 | public struct NeighborSequence: Sequence, IteratorProtocol { 5 | public typealias Element = (previous: Base.Element?, current: Base.Element, next: Base.Element?) 6 | 7 | private var base: Base.Iterator 8 | private var previous, current: Base.Element? 9 | 10 | fileprivate init(_ base: Base) { 11 | self.base = base.makeIterator() 12 | self.current = self.base.next() 13 | } 14 | 15 | public mutating func next() -> Element? { 16 | guard let current = current else { return nil } 17 | defer { previous = current } 18 | 19 | let next = base.next() 20 | self.current = next 21 | 22 | return (previous, current, next) 23 | } 24 | } 25 | 26 | public extension RandomAccessCollection { 27 | /// Return `Self` as a `NeighborSequence` 28 | /// 29 | /// Each element in a `NeighborSequence` returns the element at the specified 30 | /// index as well as the previous and next elements when possible. 31 | /// 32 | /// This allows you to see either side of a given element. 33 | var neighbors: NeighborSequence { 34 | return NeighborSequence(self) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/Common/OptionalType.swift: -------------------------------------------------------------------------------- 1 | 2 | public protocol OptionalType { 3 | associatedtype WrappedType 4 | 5 | var value: WrappedType? { get } 6 | } 7 | 8 | extension Optional: OptionalType { 9 | public typealias WrappedType = Wrapped 10 | 11 | public var value: WrappedType? { return self } 12 | } 13 | 14 | public enum OptionalError: Error, CustomStringConvertible { 15 | case requiredValue 16 | 17 | public var description: String { 18 | switch self { 19 | case .requiredValue: return "Required value not present" 20 | } 21 | } 22 | } 23 | 24 | public extension OptionalType { 25 | func requiredValue() throws -> WrappedType { 26 | guard let value = value else { throw OptionalError.requiredValue } 27 | 28 | return value 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/Common/Result.swift: -------------------------------------------------------------------------------- 1 | 2 | public enum Result { 3 | case success(T) 4 | case failure(Error) 5 | } 6 | 7 | public extension Result { 8 | init(_ value: T) { 9 | self = .success(value) 10 | } 11 | 12 | init(_ error: Error) { 13 | self = .failure(error) 14 | } 15 | 16 | init(_ closure: () throws -> T) { 17 | do { self = .success(try closure()) } 18 | catch let error { self = .failure(error) } 19 | } 20 | } 21 | 22 | public extension Result { 23 | func extract() throws -> T { 24 | switch self { 25 | case .failure(let error): throw error 26 | case .success(let value): return value 27 | } 28 | } 29 | } 30 | 31 | public extension Result { 32 | func map(_ transform: (T) throws -> U) -> Result { 33 | return Result({ try transform(try self.extract()) }) 34 | } 35 | 36 | func flatMap(_ transform: (T) throws -> Result) -> Result { 37 | do { 38 | return try transform(try self.extract()) 39 | } catch let error { 40 | return .failure(error) 41 | } 42 | } 43 | } 44 | 45 | public extension Result { 46 | func then(_ closure: (T) -> Void) -> Result { 47 | do { 48 | closure(try self.extract()) 49 | return self 50 | 51 | } catch let error { 52 | return .failure(error) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/Common/TimeInterval+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension TimeInterval { 4 | var seconds: TimeInterval { return self } 5 | 6 | var minutes: TimeInterval { return self * 60 } 7 | 8 | var hours: TimeInterval { return self.minutes * 60 } 9 | 10 | var days: TimeInterval { return self.hours * 24 } 11 | 12 | var weeks: TimeInterval { return self.days * 7 } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Models/BotUser.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct BotUser { 3 | public let id: String 4 | public let name: String 5 | 6 | public init(id: String, name: String) { 7 | self.id = id 8 | self.name = name 9 | } 10 | } 11 | 12 | extension BotUser: Common.Decodable { 13 | public init(decoder: Common.Decoder) throws { 14 | self = try decode { 15 | return BotUser( 16 | id: try decoder.value(at: ["id"]), 17 | name: try decoder.value(at: ["name"]) 18 | ) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Models/Channel.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct Channel { 3 | public let id: String 4 | public let name: String 5 | 6 | public let created: Int 7 | public let creator: ModelPointer 8 | 9 | public let members: [ModelPointer] 10 | 11 | public let topic: Topic? 12 | public let purpose: Purpose? 13 | } 14 | 15 | extension Channel: Common.Decodable { 16 | public init(decoder: Common.Decoder) throws { 17 | self = try decode { 18 | return Channel( 19 | id: try decoder.value(at: ["id"]), 20 | name: try decoder.value(at: ["name"]), 21 | created: try decoder.value(at: ["created"]), 22 | creator: try decoder.pointer(at: ["creator"]), 23 | members: try decoder.pointers(at: ["members"]), 24 | topic: try? decoder.value(at: ["topic"]), 25 | purpose: try? decoder.value(at: ["purpose"]) 26 | ) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Models/ChatMessage/Attachment.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct Attachment { 3 | public let fallback: String? 4 | public let color: Color? 5 | public let pretext: String? 6 | 7 | public let author: Author? 8 | public let title: Title? 9 | public let text: String? 10 | 11 | public let fields: [Field] 12 | 13 | public let image_url: String? 14 | public let thumb_url: String? 15 | 16 | public let footer: Footer? 17 | 18 | public let ts: Int? 19 | } 20 | 21 | 22 | extension Attachment: Common.Encodable { 23 | public func encode() -> [String: Any?] { 24 | return [ 25 | "fallback": fallback, 26 | "color": color?.rawValue, 27 | "pretext": pretext, 28 | "text": text, 29 | "fields": fields.map { $0.encode() }, 30 | "image_url": image_url, 31 | "ts": ts, 32 | ] 33 | .appending(author?.encode()) 34 | .appending(title?.encode()) 35 | .appending(footer?.encode()) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Models/ChatMessage/Author.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct Author { 3 | public let name: String 4 | public let link: String? 5 | public let icon: String? 6 | } 7 | 8 | extension Author: Common.Encodable { 9 | public func encode() -> [String: Any?] { 10 | return [ 11 | "author_name": name, 12 | "author_link": link, 13 | "author_icon": icon, 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Models/ChatMessage/ChatMessage.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct ChatMessage { 3 | public let response_url: String? 4 | 5 | public let channel: String? 6 | public let text: String 7 | 8 | public let parse: Parse? 9 | public let link_names: Bool? 10 | public let unfurl_links: Bool? 11 | 12 | public let unfurl_media: Bool? 13 | 14 | public let username: String? 15 | public let as_user: Bool? 16 | 17 | public let icon_url: String? 18 | public let icon_emoji: String? 19 | 20 | public let thread_ts: String? 21 | public let reply_broadcast: Bool? 22 | 23 | public let attachments: [Attachment] 24 | 25 | public init( 26 | response_url: String? = nil, 27 | channel: String? = nil, 28 | text: String, 29 | parse: Parse? = nil, 30 | link_names: Bool? = nil, 31 | unfurl_links: Bool? = nil, 32 | unfurl_media: Bool? = nil, 33 | username: String? = nil, 34 | as_user: Bool? = nil, 35 | icon_url: String? = nil, 36 | icon_emoji: String? = nil, 37 | thread_ts: String? = nil, 38 | reply_broadcast: Bool? = nil, 39 | attachments: [Attachment] = [] 40 | ) 41 | { 42 | self.response_url = response_url 43 | self.channel = channel 44 | self.text = text 45 | self.parse = parse 46 | self.link_names = link_names 47 | self.unfurl_links = unfurl_links 48 | self.unfurl_media = unfurl_media 49 | self.username = username 50 | self.as_user = as_user 51 | self.icon_url = icon_url 52 | self.icon_emoji = icon_emoji 53 | self.thread_ts = thread_ts 54 | self.reply_broadcast = reply_broadcast 55 | self.attachments = attachments 56 | } 57 | } 58 | 59 | extension ChatMessage: Common.Encodable { 60 | public func encode() -> [String: Any?] { 61 | return [ 62 | "channel": channel, 63 | "text": text, 64 | "parse": parse?.rawValue, 65 | "link_names": link_names, 66 | "unfurl_links": unfurl_links, 67 | "unfurl_media": unfurl_media, 68 | "username": username, 69 | "as_user": as_user, 70 | "icon_url": icon_url, 71 | "icon_emoji": icon_emoji, 72 | "thread_ts": thread_ts, 73 | "reply_broadcast": reply_broadcast, 74 | "attachments": attachments.map { $0.encode() }, 75 | ] 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/Models/ChatMessage/Field.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct Field { 3 | public let title: String 4 | public let value: String 5 | public let short: Bool? 6 | } 7 | 8 | extension Field: Common.Encodable { 9 | public func encode() -> [String: Any?] { 10 | return [ 11 | "title": title, 12 | "value": value, 13 | "short": short, 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Models/ChatMessage/Footer.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct Footer { 3 | public let footer: String 4 | public let icon: String? 5 | } 6 | 7 | extension Footer: Common.Encodable { 8 | public func encode() -> [String: Any?] { 9 | return [ 10 | "footer": footer, 11 | "footer_icon": icon, 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Models/ChatMessage/Parse.swift: -------------------------------------------------------------------------------- 1 | 2 | public enum Parse: String { 3 | case none, full 4 | } 5 | -------------------------------------------------------------------------------- /Sources/Models/ChatMessage/Title.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct Title { 3 | public let title: String 4 | public let link: String? 5 | } 6 | 7 | extension Title: Common.Encodable { 8 | public func encode() -> [String: Any?] { 9 | return [ 10 | "title": title, 11 | "title_link": link, 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Models/Color.swift: -------------------------------------------------------------------------------- 1 | 2 | public enum Color: RawRepresentable { 3 | case good 4 | case warning 5 | case danger 6 | case hex(value: String) 7 | 8 | public init?(rawValue: String) { 9 | let basicRawValues = [Color.good, Color.warning, Color.danger] 10 | 11 | if let index = basicRawValues.index(where: { $0.rawValue == rawValue }) { 12 | self = basicRawValues[index] 13 | } else { 14 | self = .hex(value: rawValue) 15 | } 16 | } 17 | public var rawValue: String { 18 | switch self { 19 | case .danger: return "danger" 20 | case .good: return "good" 21 | case .warning: return "warning" 22 | case .hex(let value): return value 23 | } 24 | } 25 | } 26 | 27 | extension Color { 28 | public static var black: Color { return .hex(value: "#000000") } 29 | public static var darkGray: Color { return .hex(value: "#555555") } 30 | public static var lightGray: Color { return .hex(value: "#aaaaaa") } 31 | public static var white: Color { return .hex(value: "#ffffff") } 32 | public static var gray: Color { return .hex(value: "#808080") } 33 | public static var red: Color { return .hex(value: "#ff00000") } 34 | public static var blue: Color { return .hex(value: "#0000ff") } 35 | public static var cyan: Color { return .hex(value: "#00ffff") } 36 | public static var yellow: Color { return .hex(value: "#ffff00") } 37 | public static var magenta: Color { return .hex(value: "#ff00ff") } 38 | public static var orange: Color { return .hex(value: "#ff8000") } 39 | public static var purple: Color { return .hex(value: "#800080") } 40 | public static var brown: Color { return .hex(value: "#996633") } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/Models/Command.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Represents the available Slack commands 4 | public enum Command: RawRepresentable { 5 | /// Mention @channel 6 | case channel 7 | 8 | /// Mention @group 9 | case group 10 | 11 | /// Mention @here 12 | case here 13 | 14 | /// Mention @everyone 15 | case everyone 16 | 17 | /// Mention a specific user group 18 | case userGroup(id: String, name: String) 19 | 20 | /// Custom mention 21 | case custom(name: String) 22 | 23 | public init?(rawValue: String) { 24 | let basicRawValues = [Command.channel, Command.group, Command.here, Command.everyone] 25 | let subteamPrefix = "!subteam^" 26 | 27 | if let index = basicRawValues.index(where: { $0.rawValue == rawValue }) { 28 | self = basicRawValues[index] 29 | 30 | } else if rawValue.hasPrefix(subteamPrefix) { 31 | let components = rawValue[subteamPrefix.endIndex..(at keyPath: [KeyPathComponent]) throws -> ModelPointer { 4 | let pointerId: String = try value(at: keyPath) 5 | return ModelPointer(id: pointerId) 6 | } 7 | public func pointers(at keyPath: [KeyPathComponent]) throws -> [ModelPointer] { 8 | let pointerIds: [String] = try value(at: keyPath) 9 | return pointerIds.map(ModelPointer.init) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/Models/Exports.swift: -------------------------------------------------------------------------------- 1 | 2 | @_exported import Common 3 | -------------------------------------------------------------------------------- /Sources/Models/Group.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct Group { 3 | public let id: String 4 | public let name: String 5 | 6 | public let created: Int 7 | public let creator: ModelPointer 8 | 9 | public let members: [ModelPointer] 10 | 11 | public let topic: Topic? 12 | public let purpose: Purpose? 13 | } 14 | 15 | extension Group: Common.Decodable { 16 | public init(decoder: Common.Decoder) throws { 17 | self = try decode { 18 | return Group( 19 | id: try decoder.value(at: ["id"]), 20 | name: try decoder.value(at: ["name"]), 21 | created: try decoder.value(at: ["created"]), 22 | creator: try decoder.pointer(at: ["creator"]), 23 | members: try decoder.pointers(at: ["members"]), 24 | topic: try? decoder.value(at: ["topic"]), 25 | purpose: try? decoder.value(at: ["purpose"]) 26 | ) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Models/IM.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct IM { 3 | public let id: String 4 | public let is_im: Bool 5 | public let is_open: Bool 6 | public let has_pins: Bool 7 | public let user: ModelPointer 8 | public let created: Int 9 | public let is_user_deleted: Bool 10 | public let last_read: String? 11 | public let latest: Message? 12 | } 13 | 14 | extension IM: Common.Decodable { 15 | public init(decoder: Common.Decoder) throws { 16 | self = try decode { 17 | return IM( 18 | id: try decoder.value(at: ["id"]), 19 | is_im: (try? decoder.value(at: ["is_im"])) ?? false, 20 | is_open: (try? decoder.value(at: ["is_open"])) ?? false, 21 | has_pins: (try? decoder.value(at: ["has_pins"])) ?? false, 22 | user: try decoder.pointer(at: ["user"]), 23 | created: try decoder.value(at: ["created"]), 24 | is_user_deleted: (try? decoder.value(at: ["is_user_deleted"])) ?? false, 25 | last_read: try? decoder.value(at: ["last_read"]), 26 | latest: try? decoder.value(at: ["latest"]) 27 | ) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/Models/Message+Subtype.swift: -------------------------------------------------------------------------------- 1 | 2 | extension Message { 3 | public enum Subtype: String { 4 | case bot_message 5 | 6 | case channel_archive 7 | case channel_join 8 | case channel_leave 9 | case channel_name 10 | case channel_purpose 11 | case channel_topic 12 | case channel_unarchive 13 | 14 | case file_comment 15 | case file_mention 16 | case file_share 17 | 18 | case group_archive 19 | case group_join 20 | case group_leave 21 | case group_name 22 | case group_purpose 23 | case group_topic 24 | case group_unarchive 25 | 26 | case me_message 27 | 28 | case message_changed 29 | case message_deleted 30 | case message_replied 31 | 32 | case pinned_item 33 | case unpinned_item 34 | 35 | @available(*, deprecated, message: "Use thread_broadcast instead") 36 | case reply_broadcast 37 | case thread_broadcast 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Models/Message.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct Message { 3 | public let text: String? 4 | public let ts: String 5 | public let subtype: Subtype? 6 | 7 | public let edited: MessageEdit? 8 | 9 | public let team: ModelPointer? 10 | public let user: ModelPointer? 11 | public let channel: ModelPointer? 12 | public let im: ModelPointer? 13 | public let thread: Thread? 14 | public let group: ModelPointer? 15 | public let bot_id: ModelPointer? 16 | public let hidden: Bool 17 | } 18 | 19 | extension Message: Common.Decodable { 20 | public init(decoder: Common.Decoder) throws { 21 | self = try decode { 22 | return Message( 23 | text: try? decoder.value(at: ["text"]), 24 | ts: try decoder.value(at: ["ts"]), 25 | subtype: Message.Subtype(rawValue: (try? decoder.value(at: ["subtype"])) ?? ""), 26 | edited: try? decoder.value(at: ["edited"]), 27 | team: try? decoder.pointer(at: ["team"]), 28 | user: try? decoder.pointer(at: ["user"]), 29 | channel: channelPointer(from: decoder), 30 | im: imPointer(from: decoder), 31 | thread: try messageThread(from: decoder), 32 | group: groupPointer(from: decoder), 33 | bot_id: try? decoder.pointer(at: ["bot_id"]), 34 | hidden: (try? decoder.value(at: ["hidden"])) ?? false 35 | ) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/Models/MessageEdit.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct MessageEdit { 3 | public let user: ModelPointer 4 | public let ts: String 5 | } 6 | 7 | extension MessageEdit: Common.Decodable { 8 | public init(decoder: Common.Decoder) throws { 9 | self = try decode { 10 | return MessageEdit( 11 | user: try decoder.pointer(at: ["user"]), 12 | ts: try decoder.value(at: ["ts"]) 13 | ) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Models/ModelPointer.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct ModelPointer: IDRepresentable { 3 | public let id: String 4 | 5 | public init(id: String) { 6 | self.id = id 7 | } 8 | } 9 | 10 | extension ModelPointer: Common.Decodable { 11 | public init(decoder: Common.Decoder) throws { 12 | self = try decode { 13 | return ModelPointer(id: try decoder.value(at: ["id"])) 14 | } 15 | } 16 | } 17 | 18 | public protocol ModelPointerType { 19 | associatedtype ModelType: IDRepresentable 20 | 21 | var id: String { get } 22 | } 23 | 24 | extension ModelPointer: ModelPointerType { 25 | public typealias ModelType = Model 26 | } 27 | 28 | extension ModelPointer: LosslessStringConvertible { 29 | public init?(_ description: String) { 30 | self.init(id: description) 31 | } 32 | public var description: String { 33 | return id 34 | } 35 | } 36 | 37 | extension ModelPointer: Hashable { 38 | public var hashValue: Int { 39 | return id.hashValue 40 | } 41 | public static func ==(lhs: ModelPointer, rhs: ModelPointer) -> Bool { 42 | return lhs.id == rhs.id 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/Models/Pong.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct Pong { 3 | public let timestamp: Int 4 | public let reply_to: Int 5 | public var raw: KeyPathAccessible 6 | } 7 | 8 | extension Pong: Common.Decodable { 9 | public init(decoder: Common.Decoder) throws { 10 | self = try decode { 11 | return Pong( 12 | timestamp: try decoder.value(at: ["timestamp"]), 13 | reply_to: try decoder.value(at: ["reply_to"]), 14 | raw: decoder.data 15 | ) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Models/Protocols/EmojiRepresentable.swift: -------------------------------------------------------------------------------- 1 | 2 | public protocol EmojiRepresentable { 3 | var emoji: String { get } 4 | } 5 | 6 | extension Emoji: EmojiRepresentable { 7 | public var emoji: String { 8 | return ":\(self.rawValue):" 9 | } 10 | } 11 | 12 | extension CustomEmoji: EmojiRepresentable { 13 | public var emoji: String { 14 | return ":\(self.name):" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Models/Protocols/IDRepresentable.swift: -------------------------------------------------------------------------------- 1 | 2 | public protocol IDRepresentable { 3 | var id: String { get } 4 | } 5 | 6 | extension BotUser: IDRepresentable { } 7 | extension Team: IDRepresentable { } 8 | extension User: IDRepresentable { } 9 | extension Channel: IDRepresentable { } 10 | extension IM: IDRepresentable { } 11 | extension Group: IDRepresentable { } 12 | -------------------------------------------------------------------------------- /Sources/Models/Protocols/Nameable.swift: -------------------------------------------------------------------------------- 1 | 2 | public protocol Nameable { 3 | var name: String { get } 4 | } 5 | 6 | extension BotUser: Nameable { } 7 | extension Team: Nameable { } 8 | extension User: Nameable { 9 | public var name: String { 10 | return display_name 11 | } 12 | } 13 | extension Channel: Nameable { } 14 | extension IM: Nameable { } 15 | extension Group: Nameable { } 16 | -------------------------------------------------------------------------------- /Sources/Models/Protocols/TargetRepresentable.swift: -------------------------------------------------------------------------------- 1 | 2 | public enum TargetRepresentableError: Error { 3 | case unableToFind(value: String) 4 | } 5 | 6 | public protocol TargetRepresentable { 7 | func targetId() throws -> String 8 | var name: String { get } 9 | var targetThread_ts: String? { get } 10 | } 11 | 12 | extension TargetRepresentable { 13 | public var targetThread_ts: String? { return nil } 14 | } 15 | 16 | extension Channel: TargetRepresentable { 17 | public func targetId() throws -> String { return id } 18 | } 19 | extension IM: TargetRepresentable { 20 | public func targetId() throws -> String { return id } 21 | public var name: String { return "IM" } 22 | } 23 | extension Thread: TargetRepresentable { 24 | public func targetId() throws -> String { 25 | guard 26 | let value = channel?.id ?? im?.id 27 | else { throw TargetRepresentableError.unableToFind(value: #function) } 28 | 29 | return value 30 | } 31 | public var name: String { return "Thread" } 32 | public var targetThread_ts: String? { return thread_ts } 33 | } 34 | extension Group: TargetRepresentable { 35 | public func targetId() throws -> String { return id } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/Models/Protocols/TokenRepresentable.swift: -------------------------------------------------------------------------------- 1 | 2 | public protocol TokenRepresentable { 3 | static var token: String { get } 4 | } 5 | 6 | extension User: TokenRepresentable { 7 | public static var token: String { return "@" } 8 | } 9 | 10 | extension BotUser: TokenRepresentable { 11 | public static var token: String { return "@" } 12 | } 13 | 14 | extension Channel: TokenRepresentable { 15 | public static var token: String { return "#" } 16 | } 17 | 18 | extension Group: TokenRepresentable { 19 | public static var token: String { return "" } // groups don't have tokens :\ 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Models/Purpose.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct Purpose { 3 | public let value: String 4 | public let creator: ModelPointer? 5 | public let last_set: Int 6 | } 7 | 8 | extension Purpose: Common.Decodable { 9 | public init(decoder: Common.Decoder) throws { 10 | self = try decode { 11 | return Purpose( 12 | value: try decoder.value(at: ["value"]), 13 | creator: try? decoder.pointer(at: ["creator"]), 14 | last_set: try decoder.value(at: ["last_set"]) 15 | ) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Models/SlashCommand.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct SlashCommand { 3 | public let token: String 4 | public let team_id: ModelPointer 5 | public let team_domain: String 6 | public let channel_id: ModelPointer 7 | public let channel_name: String 8 | public let user_id: ModelPointer 9 | public let user_name: String 10 | public let command: String 11 | public let text: String 12 | public let response_url: String 13 | } 14 | 15 | extension SlashCommand: Common.Decodable { 16 | public init(decoder: Common.Decoder) throws { 17 | self = try decode { 18 | return SlashCommand( 19 | token: try decoder.value(at: ["token"]), 20 | team_id: try decoder.pointer(at: ["team_id"]), 21 | team_domain: try decoder.value(at: ["team_domain"]), 22 | channel_id: try decoder.pointer(at: ["channel_id"]), 23 | channel_name: try decoder.value(at: ["channel_name"]), 24 | user_id: try decoder.pointer(at: ["user_id"]), 25 | user_name: try decoder.value(at: ["user_name"]), 26 | command: try decoder.value(at: ["command"]), 27 | text: (try? decoder.value(at: ["text"])) ?? "", 28 | response_url: try decoder.value(at: ["response_url"]) 29 | ) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/Models/Targets.swift: -------------------------------------------------------------------------------- 1 | 2 | private func isThread(decoder: Common.Decoder) -> Bool { 3 | guard 4 | let thread_ts: String = try? decoder.value(at: ["thread_ts"]), 5 | let ts: String = try? decoder.value(at: ["ts"]), 6 | thread_ts != ts // true here would mean the message is the root 7 | else { return false } 8 | 9 | return true 10 | } 11 | 12 | func channelPointer(from decoder: Common.Decoder) -> ModelPointer? { 13 | guard 14 | let pointerId: String = try? decoder.value(at: ["channel"]), 15 | pointerId.hasPrefix("C") 16 | else { return nil } 17 | 18 | return ModelPointer(id: pointerId) 19 | } 20 | func imPointer(from decoder: Common.Decoder) -> ModelPointer? { 21 | guard 22 | !isThread(decoder: decoder), 23 | let pointerId: String = try? decoder.value(at: ["channel"]), 24 | pointerId.hasPrefix("D") 25 | else { return nil } 26 | 27 | return ModelPointer(id: pointerId) 28 | } 29 | func messageThread(from decoder: Common.Decoder) throws -> Thread? { 30 | guard isThread(decoder: decoder) else { return nil } 31 | 32 | return try Thread(decoder: decoder) 33 | } 34 | func groupPointer(from decoder: Common.Decoder) -> ModelPointer? { 35 | guard 36 | !isThread(decoder: decoder), 37 | let pointerId: String = try? decoder.value(at: ["channel"]), 38 | pointerId.hasPrefix("G") 39 | else { return nil } 40 | 41 | return ModelPointer(id: pointerId) 42 | } 43 | -------------------------------------------------------------------------------- /Sources/Models/Team.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct Team { 3 | public let id: String 4 | public let name: String 5 | public let domain: String 6 | } 7 | 8 | extension Team: Common.Decodable { 9 | public init(decoder: Common.Decoder) throws { 10 | self = try decode { 11 | return Team( 12 | id: try decoder.value(at: ["id"]), 13 | name: try decoder.value(at: ["name"]), 14 | domain: try decoder.value(at: ["domain"]) 15 | ) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Models/Thread.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct Thread { 3 | public let ts: String 4 | public let thread_ts: String 5 | public let replies: [ThreadReply] 6 | public let channel: ModelPointer? 7 | public let im: ModelPointer? 8 | } 9 | 10 | public struct ThreadReply { 11 | public let user: ModelPointer 12 | public let ts: String 13 | } 14 | 15 | extension Thread: Common.Decodable { 16 | public init(decoder: Common.Decoder) throws { 17 | self = try decode { 18 | return Thread( 19 | ts: try decoder.value(at: ["ts"]), 20 | thread_ts: try decoder.value(at: ["thread_ts"]), 21 | replies: (try? decoder.values(at: ["replies"])) ?? [], 22 | channel: channelPointer(from: decoder), 23 | im: imPointer(from: decoder) 24 | ) 25 | } 26 | } 27 | } 28 | 29 | extension ThreadReply: Common.Decodable { 30 | public init(decoder: Common.Decoder) throws { 31 | self = try decode { 32 | return ThreadReply( 33 | user: try decoder.pointer(at: ["user"]), 34 | ts: try decoder.value(at: ["ts"]) 35 | ) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/Models/Topic.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct Topic { 3 | public let value: String 4 | public let creator: ModelPointer? 5 | public let last_set: Int 6 | } 7 | 8 | extension Topic: Common.Decodable { 9 | public init(decoder: Common.Decoder) throws { 10 | self = try decode { 11 | return Topic( 12 | value: try decoder.value(at: ["value"]), 13 | creator: try? decoder.pointer(at: ["creator"]), 14 | last_set: try decoder.value(at: ["last_set"]) 15 | ) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Models/User.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct User { 3 | public let id: String 4 | public let display_name: String 5 | public let color: String 6 | public let status_text: String? 7 | public let first_name: String? 8 | public let last_name: String? 9 | public let real_name: String? 10 | public let email: String? 11 | public let image: String? 12 | public let is_admin: Bool 13 | public let is_owner: Bool 14 | public let is_bot: Bool 15 | public let updated: Int 16 | } 17 | 18 | extension User: Common.Decodable { 19 | public init(decoder: Common.Decoder) throws { 20 | self = try decode { 21 | return User( 22 | id: try decoder.value(at: ["id"]), 23 | display_name: try decoder.value(at: ["profile", "display_name"]), 24 | color: try decoder.value(at: ["color"]), 25 | status_text: try? decoder.value(at: ["profile", "status_text"]), 26 | first_name: try? decoder.value(at: ["profile", "first_name"]), 27 | last_name: try? decoder.value(at: ["profile", "last_name"]), 28 | real_name: try? decoder.value(at: ["profile", "real_name"]), 29 | email: try? decoder.value(at: ["profile", "email"]), 30 | image: try? decoder.value(at: ["profile", "image_512"]), 31 | is_admin: (try? decoder.value(at: ["is_admin"])) ?? false, 32 | is_owner: (try? decoder.value(at: ["is_owner"])) ?? false, 33 | is_bot: (try? decoder.value(at: ["is_bot"])) ?? false, 34 | updated: try decoder.value(at: ["updated"]) 35 | ) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/RTMAPI/Events/Hello.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct hello: RTMAPIEvent { 3 | public static func handle(packet: [String : Any]) throws -> Void { 4 | // 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/RTMAPI/Events/Message.swift: -------------------------------------------------------------------------------- 1 | 2 | // Whats working?: 3 | // - channels 4 | // [x] new messages 5 | // [x] message edits 6 | // [x] new threads 7 | // [x] thread edits 8 | // - IMs 9 | // [x] new messages 10 | // [x] message edits 11 | // [x] new threads 12 | // [x] thread edits 13 | 14 | // What needs doing? 15 | // ... 16 | 17 | public struct message: RTMAPIEvent { 18 | public static func handle(packet: [String : Any]) throws -> (message: Message, previous: Message?) { 19 | let messagePacket = packet + (packet["message"] as? [String: Any] ?? [:]) 20 | var previousPacket = packet["previous_message"] as? [String: Any] 21 | 22 | // fill in holes left by api 23 | if previousPacket?["channel"] == nil { 24 | previousPacket?["channel"] = messagePacket["channel"] 25 | } 26 | 27 | return ( 28 | message: try Message(decoder: Decoder(data: messagePacket)), 29 | previous: try previousPacket.map(Decoder.init).map(Message.init) 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/RTMAPI/Events/Pong.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct pong: RTMAPIEvent { 3 | public static func handle(packet: [String : Any]) throws -> Pong { 4 | return try Pong(decoder: Decoder(data: packet)) 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/RTMAPI/Exports.swift: -------------------------------------------------------------------------------- 1 | 2 | @_exported import Common 3 | @_exported import Models 4 | @_exported import Services 5 | -------------------------------------------------------------------------------- /Sources/RTMAPI/RTMAPI+Errors.swift: -------------------------------------------------------------------------------- 1 | 2 | extension RTMAPI { 3 | public enum EventError: Error, CustomStringConvertible { 4 | case error(type: T.Type, error: Error) 5 | 6 | public var description: String { 7 | switch self { 8 | case .error(let type, let error): 9 | return "\(String(reflecting: type)): \(String(describing: error))" 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/RTMAPI/RTMAPI+PingPong.swift: -------------------------------------------------------------------------------- 1 | 2 | import Dispatch 3 | import Foundation 4 | 5 | extension RTMAPI { 6 | func startPingPong() { 7 | let ping = DispatchWorkItem { 8 | self.sendPing() 9 | self.startPingPong() 10 | } 11 | 12 | pingPong?.cancel() 13 | pingPong = ping 14 | 15 | DispatchQueue.global().asyncAfter(deadline: .now() + 5.0, execute: ping) 16 | } 17 | func stopPingPong() { 18 | pingPong?.cancel() 19 | } 20 | 21 | private func sendPing() { 22 | //`timestamp` will come back in the response 23 | //could potentially be used later for checking 24 | //latency as suggested in the docs 25 | 26 | let ping: [String: Any] = [ 27 | "id": Int.random(min: 1, max: 999999), 28 | "type": "ping", 29 | "timestamp": Int(Date().timeIntervalSince1970) 30 | ] 31 | 32 | try? socket.send(packet: ping) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/RTMAPI/RTMAPI.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | import Dispatch 4 | 5 | public final class RTMAPI { 6 | typealias EventHandler = ([String: Any]) throws -> Void 7 | public typealias ConnectHandler = () -> Void 8 | public typealias DisconnectHandler = () -> Void 9 | 10 | // MARK: - Public Properties 11 | public private(set) var connected: Bool = false 12 | public var onError: ErrorHandler? 13 | public var onConnected: ConnectHandler? 14 | public var onDisconnected: DisconnectHandler? 15 | 16 | // MARK: - Internal Properties 17 | let socket: WebSocket 18 | var pingPong: DispatchWorkItem? 19 | 20 | // MARK: - Private Properties 21 | private var registeredEvents: [EventHandler] = [] 22 | 23 | // MARK: - Lifecycle 24 | public init(socket: WebSocket) { 25 | self.socket = socket 26 | } 27 | 28 | // MARK: - Public 29 | public func connect(to url: String) throws { 30 | try socket.connect( 31 | to: url, 32 | onConnect: { [weak self] in 33 | print("Connected") 34 | self?.connected = true 35 | self?.startPingPong() 36 | self?.onConnected?() 37 | }, 38 | onDisconnect: { [weak self] in 39 | print("Disconnected") 40 | self?.stopPingPong() 41 | self?.onDisconnected?() 42 | }, 43 | onText: { [weak self] text in 44 | self?.received(string: text) 45 | }, 46 | onError: { [weak self] error in 47 | self?.onError?(error) 48 | } 49 | ) 50 | } 51 | public func disconnect() { 52 | socket.disconnect() 53 | connected = false 54 | } 55 | public func on(_: T.Type, handler: @escaping (T.EventData) throws -> Void) { 56 | let eventHandler: EventHandler = { packet in 57 | guard T.canMake(from: packet) else { return } 58 | 59 | do { 60 | let eventData = try T.handle( 61 | packet: packet 62 | ) 63 | 64 | try handler(eventData) 65 | 66 | } catch let error { 67 | throw EventError.error(type: T.self, error: error) 68 | } 69 | } 70 | 71 | registeredEvents.append(eventHandler) 72 | } 73 | 74 | // MARK: - Private 75 | private func received(string: String) { 76 | guard 77 | !registeredEvents.isEmpty, 78 | let packet = string.makeDictionary() 79 | else { return } 80 | 81 | for handler in registeredEvents { 82 | do { 83 | try handler(packet) 84 | } catch let error { 85 | onError?(error) 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Sources/RTMAPI/RTMAPIEvent.swift: -------------------------------------------------------------------------------- 1 | 2 | public protocol RTMAPIEvent { 3 | associatedtype EventData 4 | 5 | static var eventType: String { get } 6 | 7 | static func handle(packet: [String: Any]) throws -> EventData 8 | } 9 | 10 | extension RTMAPIEvent { 11 | public static var eventType: String { return String(describing: self) } 12 | 13 | static func canMake(from packet: [String: Any]) -> Bool { 14 | guard let event = packet["type"] as? String else { return false } 15 | return event == self.eventType 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Services/Exports.swift: -------------------------------------------------------------------------------- 1 | 2 | @_exported import Common 3 | -------------------------------------------------------------------------------- /Sources/Services/HTTPServer/HTTPServer.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | public enum HTTPMethod: String { 5 | case GET, PUT, POST, DELETE 6 | } 7 | 8 | public protocol HTTPServer { 9 | typealias RequestHandler = (_ url: URL, _ headers: [String: String], _ body: [String: Any]) throws -> HTTPServerResponse 10 | 11 | func start() throws 12 | 13 | func register(_ method: HTTPMethod, path: [String], handler: @escaping RequestHandler) 14 | } 15 | 16 | public extension HTTPServer { 17 | func register(_ method: HTTPMethod, path: [String], target: T, _ handler: @escaping (T) -> RequestHandler) { 18 | register(method, path: path) { [weak target] url, headers, body in 19 | guard let target = target else { return HTTPResponse.ok } 20 | 21 | return try handler(target)(url, headers, body) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/Services/HTTPServer/HTTPServerProvider.swift: -------------------------------------------------------------------------------- 1 | 2 | import Vapor 3 | import HTTP 4 | import Foundation 5 | 6 | private extension HTTPMethod { 7 | var method: HTTP.Method { 8 | switch self { 9 | case .GET: return .get 10 | case .PUT: return .put 11 | case .POST: return .post 12 | case .DELETE: return .delete 13 | } 14 | } 15 | } 16 | 17 | private extension HTTPServerResponse { 18 | func makeResponse() throws -> Response { 19 | let headers = self.headers?.map { (HeaderKey($0), $1) } ?? [:] 20 | 21 | let status = Status(statusCode: code) 22 | let bytes = try (self.body ?? [:]).makeNode(in: nil).bytes 23 | 24 | return Response( 25 | status: status, 26 | headers: headers, 27 | body: .data(bytes ?? []) 28 | ) 29 | } 30 | } 31 | 32 | enum HTTPServerError: Error { 33 | case failedToStart 34 | } 35 | 36 | public final class HTTPServerProvider: HTTPServer { 37 | // MARK: - Private Properties 38 | private lazy var server: Droplet? = try? Droplet() 39 | 40 | // MARK: - Lifecycle 41 | public init() { } 42 | 43 | // MARK: - Public Functions 44 | public func start() throws { 45 | guard server != nil else { throw HTTPServerError.failedToStart } 46 | 47 | try server?.run() 48 | } 49 | public func register(_ method: HTTPMethod, path: [String], handler: @escaping RequestHandler) { 50 | server?.register(host: nil, method: method.method, path: path) { request -> ResponseRepresentable in 51 | let url = try request.uri.makeFoundationURL() 52 | let headers = request.headers.map { ($0.key, $1) } 53 | let body = request.responseJson.jsonDictionary ?? [:] 54 | 55 | return try handler(url, headers, body).makeResponse() 56 | } 57 | } 58 | } 59 | 60 | private extension Request { 61 | var responseJson: StructuredData { 62 | if let json = json { 63 | return json.wrapped 64 | 65 | } else if let data = formURLEncoded { 66 | return JSON(data).wrapped 67 | } 68 | 69 | return JSON(.null).wrapped 70 | } 71 | } 72 | 73 | //https://github.com/vapor/json/blob/master/Sources/JSON/JSON%2BParse.swift#L101 74 | private extension StructuredData { 75 | var foundationJSON: Any { 76 | switch self { 77 | case .array(let values): 78 | return values.map { $0.foundationJSON } 79 | case .bool(let value): 80 | return value 81 | case .bytes(let bytes): 82 | return bytes.base64Encoded.makeString() 83 | case .null: 84 | return NSNull() 85 | case .number(let number): 86 | switch number { 87 | case .double(let value): 88 | return value 89 | case .int(let value): 90 | return value 91 | case .uint(let value): 92 | return value 93 | } 94 | case .object(let values): 95 | var dictionary: [String: Any] = [:] 96 | for (key, value) in values { 97 | dictionary[key] = value.foundationJSON 98 | } 99 | return dictionary 100 | case .string(let value): 101 | return value 102 | case .date(let date): 103 | let string = Date.outgoingDateFormatter.string(from: date) 104 | return string 105 | } 106 | } 107 | } 108 | 109 | private extension StructuredData { 110 | var jsonDictionary: [String: Any]? { 111 | return foundationJSON as? [String: Any] 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Sources/Services/HTTPServer/HTTPServerResponse.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | public protocol HTTPServerResponse { 5 | var code: Int { get } 6 | var headers: [String: String]? { get } 7 | var body: [String: Any]? { get } 8 | } 9 | 10 | extension HTTPServerResponse { 11 | public var headers: [String : String]? { return nil } 12 | public var body: [String : Any]? { return nil } 13 | } 14 | 15 | extension URL: HTTPServerResponse { 16 | public var code: Int { return 307 } 17 | public var headers: [String : String]? { 18 | let url: String? = self.absoluteString 19 | guard let urlString = url else { fatalError("Invalid URL: \(self)") } 20 | 21 | return ["Location": urlString] 22 | } 23 | } 24 | 25 | public enum HTTPResponse: HTTPServerResponse { 26 | case ok, fail(Error) 27 | 28 | public var code: Int { 29 | switch self { 30 | case .ok: return 200 31 | case .fail: return 500 32 | } 33 | } 34 | public var body: [String : Any]? { 35 | switch self { 36 | case .ok: 37 | return [:] 38 | case .fail(let error): 39 | return ["error": "\(error)"] 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/Services/KeyValueStore/KeyValueStore+Environment.swift: -------------------------------------------------------------------------------- 1 | #if os(Linux) 2 | import Glibc 3 | #else 4 | import Darwin.C 5 | #endif 6 | 7 | public final class Environment: KeyValueStore { 8 | public init() { } 9 | 10 | public func get(forKey key: String) throws -> T { 11 | guard 12 | let rawValue = getenv(key), 13 | let value = String(utf8String: rawValue) 14 | else { throw KeyValueStoreError.missing(key: key) } 15 | 16 | guard let result = T(value) else { throw KeyValueStoreError.invalid(key: key, expected: T.self, found: value) } 17 | 18 | return result 19 | } 20 | public func set(value: T, forKey key: String) { 21 | setenv(key, value.description, 1) 22 | } 23 | public func remove(key: String) { 24 | unsetenv(key) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Services/KeyValueStore/KeyValueStore+Memory.swift: -------------------------------------------------------------------------------- 1 | 2 | public final class MemoryKeyValueStore: KeyValueStore { 3 | private(set) var storage: [String: String] = [:] 4 | 5 | public init() { } 6 | 7 | public func get(forKey key: String) throws -> T { 8 | guard let value = storage[key] else { throw KeyValueStoreError.missing(key: key) } 9 | 10 | guard 11 | let result = T(value) 12 | else { throw KeyValueStoreError.invalid(key: key, expected: T.self, found: value) } 13 | 14 | return result 15 | } 16 | public func set(value: T, forKey key: String) { 17 | storage[key] = value.description 18 | } 19 | public func remove(key: String) { 20 | storage.removeValue(forKey: key) 21 | } 22 | 23 | public func removeAll() { 24 | storage.removeAll() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Services/KeyValueStore/KeyValueStore+Redis.swift: -------------------------------------------------------------------------------- 1 | import Redis 2 | import Foundation 3 | 4 | extension NSLock { 5 | func synchronized(_ closure: () throws -> T) rethrows -> T { 6 | self.lock() 7 | defer { self.unlock() } 8 | return try closure() 9 | } 10 | } 11 | 12 | public final class RedisKeyValueStore: KeyValueStore { 13 | typealias ClientFactory = () throws -> Redis.TCPClient 14 | let factory: ClientFactory 15 | private let lock = NSLock() 16 | 17 | public init(hostname: String, port: UInt16, password: String? = nil, database: String? = nil) { 18 | self.factory = { 19 | let client = try TCPClient( 20 | hostname: hostname, 21 | port: port, 22 | password: password 23 | ) 24 | 25 | if let database = database { 26 | try client.command(try Command("select"), [database.description]) 27 | } 28 | 29 | return client 30 | } 31 | } 32 | public convenience init(url: String) { 33 | guard 34 | let components = URLComponents(string: url), 35 | let host = components.host, 36 | let port = components.port 37 | else { fatalError("Invalid URL supplied") } 38 | 39 | let database = (components.path.isEmpty ? nil : components.path) 40 | 41 | self.init(hostname: host, port: UInt16(port), password: components.password, database: database) 42 | } 43 | 44 | public func get(forKey key: String) throws -> T { 45 | return try lock.synchronized { 46 | guard let string = try factory() 47 | .command(.get, [key])? 48 | .string 49 | else { throw KeyValueStoreError.missing(key: key) } 50 | 51 | guard let value = T(string) 52 | else { throw KeyValueStoreError.invalid(key: key, expected: T.self, found: string) } 53 | 54 | return value 55 | } 56 | } 57 | public func set(value: T, forKey key: String) { 58 | lock.synchronized { 59 | _ = try? factory().command(.set, [key, value.description]) 60 | } 61 | } 62 | public func remove(key: String) { 63 | lock.synchronized { 64 | _ = try? factory().command(.delete, [key]) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/Services/KeyValueStore/KeyValueStore.swift: -------------------------------------------------------------------------------- 1 | 2 | public enum KeyValueStoreError: Error, CustomStringConvertible { 3 | case missing(key: String) 4 | case invalid(key: String, expected: Any.Type, found: Any) 5 | 6 | public var description: String { 7 | switch self { 8 | case .missing(let key): 9 | return "Key not found: '\(key)'" 10 | case .invalid(let key, let expected, let found): 11 | return "Unable to convert value '\(found)' at key '\(key)' to: \(expected)" 12 | } 13 | } 14 | } 15 | 16 | public protocol KeyValueStore: class { 17 | 18 | // TODO - replace with generic subscript in Swift4 ? 19 | 20 | func get(forKey key: String) throws -> T 21 | func set(value: T, forKey key: String) 22 | func remove(key: String) 23 | } 24 | 25 | public extension KeyValueStore { 26 | func get(forKey key: String, or `default`: T) throws -> T { 27 | do { 28 | return try get(forKey: key) 29 | } catch KeyValueStoreError.missing { 30 | return `default` 31 | } catch let error { 32 | throw error 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Services/Network/DataRepresentable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol DataRepresentable { 4 | func makeData() -> Data? 5 | } 6 | 7 | public extension DataRepresentable { 8 | var string: String { 9 | guard let data = self.makeData() else { return "" } 10 | return String(data: data, encoding: .utf8) ?? "" 11 | } 12 | } 13 | 14 | extension Data: DataRepresentable { 15 | public func makeData() -> Data? { 16 | return self 17 | } 18 | } 19 | 20 | extension Dictionary: DataRepresentable { 21 | public func makeData() -> Data? { 22 | return try? JSONSerialization.data(withJSONObject: self, options: []) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/Services/Network/Middleware/HTTPStatusCodeMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension HTTPStatusCodeMiddleware { 4 | enum Error: Swift.Error { 5 | case clientError(request: URLRequest, code: Int, data: String?) 6 | case serverError(request: URLRequest, code: Int, data: String?) 7 | } 8 | } 9 | 10 | /// NetworkMiddleware to detect client and server error codes 11 | public struct HTTPStatusCodeMiddleware: NetworkMiddleware { 12 | public init() { } 13 | 14 | public func process(current: NetworkMiddlewareResult, request: URLRequest, response: NetworkResponse, result: @escaping (NetworkMiddlewareResult) -> Void) { 15 | 16 | guard !current.isFailure else { return result(current) } 17 | 18 | let code = response.response.statusCode 19 | var next = current 20 | 21 | if code >= 400 && code <= 499 { 22 | next = .fail(error: Error.clientError(request: request, code: code, data: response.string)) 23 | 24 | } else if code >= 500 && code <= 499 { 25 | next = .fail(error: Error.serverError(request: request, code: code, data: response.string)) 26 | } 27 | 28 | result(next) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/Services/Network/Network.swift: -------------------------------------------------------------------------------- 1 | import Common 2 | 3 | public enum NetworkError: Error { 4 | case invalidResponse(Any?) 5 | case timeout 6 | } 7 | 8 | public enum NetworkRequestError: Error, CustomStringConvertible { 9 | case error(request: NetworkRequest, error: Error) 10 | 11 | public var description: String { 12 | switch self { 13 | case .error(let request, let error): 14 | return "\(String(describing: request)): \(String(describing: error))" 15 | } 16 | } 17 | } 18 | 19 | public protocol Network: class { 20 | func register(middleware: [NetworkMiddleware]) 21 | 22 | func perform(request: NetworkRequest, middleware: [NetworkMiddleware]) throws -> NetworkResponse 23 | 24 | func perform(request: NetworkRequest) throws -> NetworkResponse 25 | } 26 | 27 | public extension Network { 28 | func perform(request: NetworkRequest) throws -> NetworkResponse { 29 | return try perform(request: request, middleware: []) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Services/Network/NetworkMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// The result of the an active middleware process 4 | /// 5 | /// - next: The middleware was successful, proceed to the next 6 | /// - retry: The middleware requires that the network request be retried 7 | /// - fail: The middleware failed with the specified error 8 | public enum NetworkMiddlewareResult { 9 | case next(data: Data?) 10 | case retry 11 | case fail(error: Error) 12 | } 13 | 14 | public extension NetworkMiddlewareResult { 15 | var isFailure: Bool { 16 | switch self { 17 | case .fail: return true 18 | default: return false 19 | } 20 | } 21 | } 22 | 23 | /// Represents a single middleware 24 | public protocol NetworkMiddleware { 25 | /// Process a NetworkResponse 26 | /// 27 | /// - Parameters: 28 | /// - current: The current result of the network requests and any previous middleware 29 | /// - request: The original `URLRequest` 30 | /// - response: The received `NetworkResponse` 31 | /// - result: A closure that must be called with the result of this middleware 32 | /// 33 | /// - Note: not calling `result` will cause the processing to stall 34 | func process( 35 | current: NetworkMiddlewareResult, 36 | request: URLRequest, 37 | response: NetworkResponse, 38 | result: @escaping (NetworkMiddlewareResult) -> Void 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /Sources/Services/Network/NetworkProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Common 3 | import Dispatch 4 | 5 | public class NetworkProvider: Network { 6 | // MARK: - Private Properties 7 | fileprivate let session: URLSession 8 | fileprivate var middleware: [NetworkMiddleware] = [] 9 | 10 | // MARK: - Lifecycle 11 | public init(session: URLSession = URLSession(configuration: .default)) { //TODO at the time of writing `URLSession.shared` is `NSUnimplemented()` 12 | self.session = session 13 | } 14 | 15 | // MARK: - Public 16 | public func register(middleware: [NetworkMiddleware]) { 17 | self.middleware.append(contentsOf: middleware) 18 | } 19 | public func perform(request: NetworkRequest, middleware: [NetworkMiddleware]) throws -> NetworkResponse { 20 | switch execute(request: request, middleware: middleware) { 21 | case .success(let value): 22 | return value 23 | case .failure(let error): 24 | throw NetworkRequestError.error(request: request, error: error) 25 | } 26 | } 27 | } 28 | 29 | // MARK: - Execution 30 | private extension NetworkProvider { 31 | func execute(request: NetworkRequest, middleware: [NetworkMiddleware]) -> Result { 32 | do { 33 | let urlRequest = try request.buildURLRequest() 34 | 35 | var finalResult: Result = .failure(NetworkError.invalidResponse(nil)) 36 | 37 | let group = DispatchGroup() 38 | 39 | let task = session.dataTask( 40 | with: urlRequest, 41 | completionHandler: { data, response, error in 42 | let initialState: NetworkMiddlewareResult 43 | 44 | if let error = error { initialState = .fail(error: error) } 45 | else { initialState = .next(data: data) } 46 | 47 | //make sure this was a HTTP request 48 | guard let urlResponse = response as? HTTPURLResponse else { 49 | finalResult = .failure(NetworkError.invalidResponse(response)) 50 | return group.leave() 51 | } 52 | 53 | let networkResponse = NetworkResponse(response: urlResponse, data: data) 54 | 55 | let combinedMiddleware = middleware + self.middleware 56 | 57 | combinedMiddleware.handle( 58 | startingWith: initialState, 59 | request: urlRequest, 60 | response: networkResponse, 61 | complete: { result in 62 | switch result { 63 | case .next(let data): 64 | finalResult = .success(NetworkResponse(response: urlResponse, data: data)) 65 | 66 | case .fail(let error): 67 | finalResult = .failure(error) 68 | 69 | case .retry: 70 | finalResult = self.execute( 71 | request: request, 72 | middleware: middleware 73 | ) 74 | } 75 | 76 | group.leave() 77 | } 78 | ) 79 | } 80 | ) 81 | 82 | group.enter() 83 | 84 | task.resume() 85 | 86 | switch group.wait(wallTimeout: .now() + 30) { 87 | case .success: 88 | return finalResult 89 | case .timedOut: 90 | return .failure(NetworkError.timeout) 91 | } 92 | 93 | } catch let error { 94 | return .failure(error) 95 | } 96 | } 97 | } 98 | 99 | // MARK: - Middleware 100 | public extension Array where Element == NetworkMiddleware { 101 | func handle( 102 | startingWith result: NetworkMiddlewareResult, 103 | request: URLRequest, 104 | response: NetworkResponse, 105 | complete: @escaping (NetworkMiddlewareResult) -> Void 106 | ) 107 | { 108 | guard !isEmpty else { return complete(result) } 109 | 110 | let active = self[0] 111 | let remaining = Array(dropFirst()) 112 | 113 | func next(newResponse: NetworkResponse) { 114 | active.process(current: result, request: request, response: newResponse) { result in 115 | remaining.handle( 116 | startingWith: result, 117 | request: request, 118 | response: response, 119 | complete: complete 120 | ) 121 | } 122 | } 123 | 124 | switch result { 125 | case .retry: 126 | complete(result) 127 | 128 | case .next(let data): 129 | let updatedResponse = NetworkResponse(response: response.response, data: data) 130 | next(newResponse: updatedResponse) 131 | 132 | case .fail: 133 | next(newResponse: response) 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Sources/Services/Network/NetworkRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - Errors 4 | public extension NetworkRequest { 5 | enum Error: Swift.Error { 6 | case invalidURL(String) 7 | } 8 | } 9 | 10 | // MARK: - HTTP Methods 11 | public extension NetworkRequest { 12 | enum Method: String { 13 | case GET, PUT, PATCH, POST, DELETE 14 | } 15 | } 16 | 17 | // MARK: - NetworkRequest 18 | public struct NetworkRequest { 19 | // MARK: - Properties 20 | public var method: Method 21 | public var url: String 22 | public var headers: [String: LosslessStringConvertible] 23 | public var body: DataRepresentable? 24 | 25 | // MARK: - Lifecycle 26 | public init(method: Method, url: String, headers: [String: LosslessStringConvertible] = [:], body: DataRepresentable? = nil) { 27 | self.method = method 28 | self.url = url 29 | self.headers = headers 30 | self.body = body 31 | } 32 | 33 | // MARK: - Public 34 | public func buildURLRequest() throws -> URLRequest { 35 | guard let url = URL(string: self.url) else { throw Error.invalidURL(self.url) } 36 | 37 | var request = URLRequest(url: url) 38 | request.httpMethod = self.method.rawValue 39 | 40 | for (key, value) in self.headers { 41 | request.addValue(value.description, forHTTPHeaderField: key) 42 | } 43 | 44 | request.httpBody = self.body?.makeData() 45 | 46 | return request 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/Services/Network/NetworkResponse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - NetworkResponse 4 | public struct NetworkResponse { 5 | public let response: HTTPURLResponse 6 | public let data: Data? 7 | 8 | public init(response: HTTPURLResponse, data: Data?) { 9 | self.response = response 10 | self.data = data 11 | } 12 | } 13 | 14 | // MARK: - Conversions 15 | public extension NetworkResponse { 16 | var jsonDictionary: [String: Any]? { 17 | guard let data = self.data else { return nil } 18 | 19 | do { return try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] } 20 | catch { return nil } 21 | } 22 | var jsonArray: [Any]? { 23 | guard let data = self.data else { return nil } 24 | 25 | do { return try JSONSerialization.jsonObject(with: data, options: []) as? [Any] } 26 | catch { return nil } 27 | } 28 | var string: String? { 29 | guard 30 | let data = self.data, 31 | let value = String(data: data, encoding: .utf8), 32 | !value.isEmpty 33 | else { return nil } 34 | 35 | return value 36 | } 37 | } 38 | 39 | // MARK: - CustomStringConvertible 40 | extension NetworkResponse: CustomStringConvertible { 41 | public var description: String { 42 | return "\(self.response)\nDATA:\n\(self.string ?? "")" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/Services/Storage/Storage+Memory.swift: -------------------------------------------------------------------------------- 1 | 2 | public final class MemoryStorage: Storage { 3 | // MARK: - Private 4 | private let store = MemoryKeyValueStore() 5 | 6 | // MARK: - Lifecycle 7 | public init() { } 8 | 9 | // MARK: - Public 10 | public func get(key: String, from namespace: String) throws -> T { 11 | return try execute { try store.get(forKey: namespaced(namespace, key)) } 12 | } 13 | public func set(value: T, forKey key: String, in namespace: String) { 14 | execute { store.set(value: value, forKey: namespaced(namespace, key)) } 15 | } 16 | public func remove(key: String, from namespace: String) { 17 | execute { store.remove(key: namespaced(namespace, key)) } 18 | } 19 | public func keys(in namespace: String) throws -> [String] { 20 | return Array(store.storage.keys).map { $0.remove(prefix: namespaced(namespace, "")) } 21 | } 22 | 23 | public func removeAll() { 24 | store.removeAll() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Services/Storage/Storage+Plist.swift: -------------------------------------------------------------------------------- 1 | #if !os(Linux) 2 | import Foundation 3 | 4 | public final class PListStorage: Storage { 5 | // MARK: - Lifecycle 6 | public init() { } 7 | 8 | // MARK: - Public Functions 9 | public func get(key: String, from namespace: String) throws -> T { 10 | guard let value = dataset()[namespace]?[key] else { throw StorageError.missing(key: key) } 11 | 12 | guard 13 | let result = T(value) 14 | else { throw StorageError.invalid(key: key, expected: T.self, found: value) } 15 | 16 | return result 17 | } 18 | public func set(value: T, forKey key: String, in namespace: String) { 19 | var data = dataset() 20 | var items = data[namespace] ?? [:] 21 | items[key] = value.description 22 | data[namespace] = items 23 | saveDataset(data) 24 | } 25 | public func remove(key: String, from namespace: String) { 26 | var data = dataset() 27 | data[namespace]?.removeValue(forKey: key) 28 | saveDataset(data) 29 | } 30 | public func keys(in namespace: String) throws -> [String] { 31 | return dataset()[namespace]?.keys.values ?? [] 32 | } 33 | 34 | //MARK: - Private 35 | private var fileName: String { 36 | return "\(NSHomeDirectory())/storage.plist" 37 | } 38 | private func dataset() -> [String: [String: String]] { 39 | guard let dict = NSDictionary(contentsOfFile: self.fileName) as? [String: [String: String]] else { 40 | return self.defaultDataset() 41 | } 42 | return dict 43 | } 44 | private func defaultDataset() -> [String: [String: String]] { return [:] } 45 | private func saveDataset(_ dataset: [String: [String: String]]) { 46 | (dataset as NSDictionary).write(toFile: self.fileName, atomically: true) 47 | } 48 | } 49 | #endif 50 | -------------------------------------------------------------------------------- /Sources/Services/Storage/Storage+Private.swift: -------------------------------------------------------------------------------- 1 | 2 | func namespaced(_ namespace: String, _ key: String) -> String { 3 | return "\(namespace):\(key)" 4 | } 5 | func execute(_ closure: () throws -> T) rethrows -> T { 6 | do { 7 | return try closure() 8 | } catch let error { 9 | throw StorageError(error) ?? error 10 | } 11 | } 12 | 13 | extension StorageError { 14 | init?(_ error: Error) { 15 | switch error { 16 | case KeyValueStoreError.missing(let key): 17 | self = .missing(key: key) 18 | case KeyValueStoreError.invalid(let key, let expected, let found): 19 | self = .invalid(key: key, expected: expected, found: found) 20 | default: 21 | return nil 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/Services/Storage/Storage+Redis.swift: -------------------------------------------------------------------------------- 1 | import Redis 2 | import Foundation 3 | 4 | public final class RedisStorage: Storage { 5 | // MARK: - Private Properties 6 | private let keyValueStore: RedisKeyValueStore 7 | 8 | // MARK: - Lifecycle 9 | public init(hostname: String, port: UInt16, password: String? = nil, database: String? = nil) { 10 | self.keyValueStore = RedisKeyValueStore(hostname: hostname, port: port, password: password, database: database) 11 | } 12 | public init(url: String) { 13 | self.keyValueStore = RedisKeyValueStore(url: url) 14 | } 15 | 16 | // MARK: - Public Functions 17 | public func get(key: String, from namespace: String) throws -> T { 18 | return try execute { try keyValueStore.get(forKey: namespaced(namespace, key)) } 19 | } 20 | public func set(value: T, forKey key: String, in namespace: String) { 21 | execute { keyValueStore.set(value: value, forKey: namespaced(namespace, key)) } 22 | } 23 | public func remove(key: String, from namespace: String) { 24 | execute { keyValueStore.remove(key: namespaced(namespace, key)) } 25 | } 26 | public func keys(in namespace: String) throws -> [String] { 27 | let instance = try keyValueStore.factory() 28 | 29 | guard let dataArray = try instance.command(.keys, [namespaced(namespace, "*")])?.array else { return [] } 30 | 31 | return dataArray.compactMap { $0?.string?.remove(prefix: namespaced(namespace, "")) } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/Services/Storage/Storage.swift: -------------------------------------------------------------------------------- 1 | 2 | public enum StorageError: Error, CustomStringConvertible { 3 | case missing(key: String) 4 | case invalid(key: String, expected: Any.Type, found: Any) 5 | 6 | public var description: String { 7 | switch self { 8 | case .missing(let key): 9 | return "Key not found: '\(key)'" 10 | case .invalid(let key, let expected, let found): 11 | return "Unable to convert value '\(found)' at key '\(key)' to: \(expected)" 12 | } 13 | } 14 | } 15 | 16 | //TODO : I expect the api for this to change into something nicer which accepts a full 'model' that can be serialized rather than a key/value based system 17 | // This is just this way for now so the transition from legacy Chameleon is easier 18 | 19 | public protocol Storage: class { 20 | 21 | // TODO - replace with generic subscript in Swift4 ? 22 | 23 | func get(key: String, from namespace: String) throws -> T 24 | func set(value: T, forKey key: String, in namespace: String) 25 | func remove(key: String, from namespace: String) 26 | func keys(in namespace: String) throws -> [String] 27 | 28 | } 29 | 30 | public extension Storage { 31 | func get(key: String, from namespace: String, or `default`: T) throws -> T { 32 | do { 33 | return try get(key: key, from: namespace) 34 | } catch StorageError.missing { 35 | return `default` 36 | } catch let error { 37 | throw error 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/Services/WebSocket/WebSocket.swift: -------------------------------------------------------------------------------- 1 | 2 | public enum WebSocketError: Error { 3 | case invalidPacket 4 | } 5 | 6 | public protocol WebSocket { 7 | typealias ConnectHandler = () -> Void 8 | typealias DisconnectHandler = () -> Void 9 | typealias TextHandler = (String) -> Void 10 | 11 | func connect( 12 | to url: String, 13 | onConnect: @escaping ConnectHandler, 14 | onDisconnect: @escaping DisconnectHandler, 15 | onText: @escaping TextHandler, 16 | onError: @escaping ErrorHandler 17 | ) throws 18 | 19 | func send(packet: [String: Any]) throws 20 | 21 | func disconnect() 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Services/WebSocket/WebSocketProvider.swift: -------------------------------------------------------------------------------- 1 | 2 | import Vapor 3 | 4 | public final class WebSocketProvider: WebSocket { 5 | // MARK: - Private 6 | private let factory = WebSocketFactory() 7 | private var socket: Vapor.WebSocket? 8 | private var onError: ErrorHandler? 9 | 10 | // MARK: - Lifecycle 11 | public init() { } 12 | 13 | // MARK: - Public Functions 14 | public func connect( 15 | to url: String, 16 | onConnect: @escaping ConnectHandler, 17 | onDisconnect: @escaping DisconnectHandler, 18 | onText: @escaping TextHandler, 19 | onError: @escaping ErrorHandler 20 | ) throws 21 | { 22 | self.onError = onError 23 | 24 | try factory.connect(to: url) { [weak self] socket in 25 | 26 | self?.socket = socket 27 | 28 | onConnect() 29 | socket.onClose = { _, _, _, _ in onDisconnect() } 30 | socket.onText = { _, string in onText(string) } 31 | 32 | } 33 | } 34 | public func send(packet: [String: Any]) throws { 35 | guard 36 | let string = packet.makeString() 37 | else { onError?(WebSocketError.invalidPacket); return } 38 | 39 | try socket?.send(string) 40 | } 41 | public func disconnect() { 42 | try? socket?.close() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/WebAPI/Exports.swift: -------------------------------------------------------------------------------- 1 | 2 | @_exported import Services 3 | @_exported import Models 4 | @_exported import Common 5 | -------------------------------------------------------------------------------- /Sources/WebAPI/Methods/BotsInfo.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct BotsInfo: WebAPIRequest { 3 | public let id: String 4 | public let scopes: [WebAPI.Scope] = [.users_read] 5 | public let endpoint = "bots.info" 6 | public var body: [String : Any?] { 7 | return ["bot": id] 8 | } 9 | 10 | public init(id: String) { 11 | self.id = id 12 | } 13 | 14 | public func handle(response: NetworkResponse) throws -> BotUser { 15 | guard 16 | let dictionary = response.jsonDictionary, 17 | let data = dictionary["bot"] as? [String: Any] 18 | else { throw NetworkError.invalidResponse(response) } 19 | 20 | return try BotUser(decoder: Decoder(data: data)) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/WebAPI/Methods/ChannelsInfo.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct ChannelsInfo: WebAPIRequest { 3 | public let id: String 4 | 5 | public let endpoint = "channels.info" 6 | public var body: [String : Any?] { 7 | return ["channel": id] 8 | } 9 | 10 | public init(id: String) { 11 | self.id = id 12 | } 13 | 14 | public func handle(response: NetworkResponse) throws -> Channel { 15 | guard 16 | let dictionary = response.jsonDictionary, 17 | let data = dictionary["channel"] as? [String: Any] 18 | else { throw NetworkError.invalidResponse(response) } 19 | 20 | return try Channel(decoder: Decoder(data: data)) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/WebAPI/Methods/ChatPermalink.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct ChatPermalink: WebAPIRequest { 4 | public let channelID: String? 5 | public let ts: String 6 | 7 | public let endpoint = "chat.getPermalink" 8 | public var body: [String : Any?] { 9 | return ["channel": channelID, "message_ts": ts] 10 | } 11 | 12 | public init(message: Message) { 13 | self.channelID = message.channel?.id 14 | self.ts = message.ts 15 | } 16 | 17 | public func handle(response: NetworkResponse) throws -> String { 18 | guard 19 | let dictionary = response.jsonDictionary, 20 | let data = dictionary["permalink"] as? String 21 | else { throw NetworkError.invalidResponse(response) } 22 | return data 23 | 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/WebAPI/Methods/ChatPostMessage.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct ChatPostMessage: WebAPIRequest { 3 | public var url: String { 4 | return message.response_url ?? WebAPIURL(base: DefaultBaseURL, endpoint) 5 | } 6 | public var encoding: Encoding { 7 | return message.response_url == nil ? .form : .json 8 | } 9 | public let message: ChatMessage 10 | public let endpoint = "chat.postMessage" 11 | public var body: [String: Any?] { 12 | return message.encode() 13 | } 14 | 15 | public init(message: ChatMessage) { 16 | self.message = message 17 | } 18 | 19 | public func handle(response: NetworkResponse) throws -> Void { 20 | // 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/WebAPI/Methods/GroupsInfo.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct GroupsInfo: WebAPIRequest { 3 | public let id: String 4 | 5 | public let endpoint = "groups.info" 6 | public var body: [String : Any?] { 7 | return ["channel": id] 8 | } 9 | 10 | public init(id: String) { 11 | self.id = id 12 | } 13 | 14 | public func handle(response: NetworkResponse) throws -> Group { 15 | guard 16 | let dictionary = response.jsonDictionary, 17 | let data = dictionary["group"] as? [String: Any] 18 | else { throw NetworkError.invalidResponse(response) } 19 | 20 | return try Group(decoder: Decoder(data: data)) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/WebAPI/Methods/IMList.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct IMList: WebAPIRequest { 3 | public let endpoint = "im.list" 4 | 5 | public func handle(response: NetworkResponse) throws -> [IM] { 6 | guard 7 | let dictionary = response.jsonDictionary, 8 | let data = dictionary["ims"] as? [[String: Any]] 9 | else { throw NetworkError.invalidResponse(response) } 10 | 11 | return try data.compactMap { try IM(decoder: Decoder(data: $0)) } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/WebAPI/Methods/IMOpen.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct IMOpen: WebAPIRequest { 3 | public let userId: String 4 | 5 | public let endpoint = "im.open" 6 | public var body: [String : Any?] { 7 | return [ 8 | "user": userId, 9 | "return_im": true, 10 | ] 11 | } 12 | 13 | public init(userId: String) { 14 | self.userId = userId 15 | } 16 | 17 | public func handle(response: NetworkResponse) throws -> IM { 18 | guard 19 | let dictionary = response.jsonDictionary, 20 | let data = dictionary["channel"] as? [String: Any] 21 | else { throw NetworkError.invalidResponse(response) } 22 | 23 | return try IM(decoder: Decoder(data: data)) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/WebAPI/Methods/RTMConnect.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct RTMConnect: WebAPIRequest { 3 | public let token: String 4 | public let endpoint = "rtm.connect" 5 | public var body: [String : Any?] { 6 | return ["token": token] 7 | } 8 | public let authenticated = false 9 | 10 | public init(token: String) { 11 | self.token = token 12 | } 13 | 14 | public func handle(response: NetworkResponse) throws -> SlackConnection { 15 | guard let data = response.jsonDictionary 16 | else { throw NetworkError.invalidResponse(response) } 17 | 18 | return try SlackConnection(decoder: Decoder(data: data)) 19 | } 20 | } 21 | 22 | public struct SlackConnection { 23 | public let url: String 24 | public let team: Team 25 | public let bot: BotUser 26 | } 27 | 28 | extension SlackConnection: Common.Decodable { 29 | public init(decoder: Common.Decoder) throws { 30 | self = try decode { 31 | return SlackConnection( 32 | url: try decoder.value(at: ["url"]), 33 | team: try decoder.value(at: ["team"]), 34 | bot: try decoder.value(at: ["self"]) 35 | ) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/WebAPI/Methods/ReactionsAdd.swift: -------------------------------------------------------------------------------- 1 | 2 | public protocol ReactionTarget { 3 | func encoded() -> [String: Any?] 4 | } 5 | 6 | public struct ReactionsAdd: WebAPIRequest { 7 | public let scopes: [WebAPI.Scope] = [.reactions_write] 8 | public let endpoint = "reactions.add" 9 | public var body: [String : Any?] { 10 | let emojiValue = emoji.emoji.remove(prefix: ":").substring(until: [":"]) 11 | return target.encoded().appending(["name": emojiValue]) 12 | } 13 | 14 | private let emoji: EmojiRepresentable 15 | private let target: ReactionTarget 16 | 17 | public init(emoji: T, target: U) { 18 | self.emoji = emoji 19 | self.target = target 20 | } 21 | 22 | public func handle(response: NetworkResponse) throws -> Void { 23 | // 24 | } 25 | } 26 | 27 | public struct ChannelReaction: ReactionTarget { 28 | public let id: String 29 | public let messageTs: String 30 | 31 | public init(id: String, messageTs: String) { 32 | self.id = id 33 | self.messageTs = messageTs 34 | } 35 | 36 | public func encoded() -> [String: Any?] { 37 | return ["channel": id, "timestamp": messageTs] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/WebAPI/Methods/UsersInfo.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct UsersInfo: WebAPIRequest { 3 | public let id: String 4 | public let scopes: [WebAPI.Scope] = [.users_read] 5 | public let endpoint = "users.info" 6 | public var body: [String : Any?] { 7 | return ["user": id] 8 | } 9 | 10 | public init(id: String) { 11 | self.id = id 12 | } 13 | 14 | public func handle(response: NetworkResponse) throws -> User { 15 | guard 16 | let dictionary = response.jsonDictionary, 17 | let data = dictionary["user"] as? [String: Any] 18 | else { throw NetworkError.invalidResponse(response) } 19 | 20 | return try User(decoder: Decoder(data: data)) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/WebAPI/Methods/UsersList.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct UsersList: WebAPIRequest { 3 | public let endpoint = "users.list" 4 | public let scopes: [WebAPI.Scope] = [.users_read] 5 | 6 | public init() { } 7 | 8 | public func handle(response: NetworkResponse) throws -> [User] { 9 | guard 10 | let dictionary = response.jsonDictionary, 11 | let data = dictionary["members"] as? [[String: Any]] 12 | else { throw NetworkError.invalidResponse(response) } 13 | 14 | return try data.map { try User(decoder: Decoder(data: $0)) } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/WebAPI/Middleware/RetryMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public final class RetryMiddleware: NetworkMiddleware { 4 | private var attempts = 0 5 | private let maxRetries: Int 6 | 7 | public init(maxRetries: Int) { 8 | self.maxRetries = maxRetries 9 | } 10 | 11 | public func process(current: NetworkMiddlewareResult, request: URLRequest, response: NetworkResponse, result: @escaping (NetworkMiddlewareResult) -> Void) { 12 | switch current { 13 | case .fail(let error) where attempts < maxRetries: 14 | switch error { 15 | case NetworkError.timeout: 16 | attempts += 1 17 | return result(.retry) 18 | 19 | default: 20 | break 21 | } 22 | 23 | default: 24 | break 25 | } 26 | 27 | result(current) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/WebAPI/Middleware/WebAPIMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct WebAPIMiddleware: NetworkMiddleware { 4 | public func process(current: NetworkMiddlewareResult, request: URLRequest, response: NetworkResponse, result: @escaping (NetworkMiddlewareResult) -> Void) { 5 | 6 | guard !current.isFailure else { return result(current) } 7 | 8 | if let json = response.jsonDictionary, let error = json["error"] as? String { 9 | result(.fail(error: HTTPStatusCodeMiddleware.Error.clientError( 10 | request: request, 11 | code: response.response.statusCode, 12 | data: error 13 | ))) 14 | 15 | } else { 16 | result(current) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/WebAPI/WebAPI+Errors.swift: -------------------------------------------------------------------------------- 1 | 2 | extension WebAPI { 3 | public enum Error: Swift.Error { 4 | case authenticationRequired 5 | } 6 | 7 | public enum RequestError: Swift.Error, CustomStringConvertible { 8 | case error(type: T.Type, error: Swift.Error) 9 | 10 | public var description: String { 11 | switch self { 12 | case .error(let type, let error): 13 | return "\(String(reflecting: type)): \(String(describing: error))" 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/WebAPI/WebAPI+Scope.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension WebAPI { 4 | public enum Scope: String { 5 | case identify 6 | case client 7 | case admin 8 | case bot 9 | case channels_history = "channels:history" 10 | case channels_read = "channels:read" 11 | case channels_write = "channels:write" 12 | case chat_write_bot = "chat:write:bot" 13 | case chat_write_user = "chat:write:user" 14 | case dnd_read = "dnd:read" 15 | case dnd_write = "dnd:write" 16 | case emoji_read = "emoji:read" 17 | case files_read = "files:read" 18 | case files_write_user = "files:write:user" 19 | case groups_history = "groups:history" 20 | case groups_read = "groups:read" 21 | case groups_write = "groups:write" 22 | case identity_basic = "identity.basic" 23 | case im_history = "im:history" 24 | case im_read = "im:read" 25 | case im_write = "im:write" 26 | case mpim_history = "mpim:history" 27 | case mpim_read = "mpim:read" 28 | case mpim_write = "mpim:write" 29 | case pins_read = "pins:read" 30 | case pins_write = "pins:write" 31 | case reactions_read = "reactions:read" 32 | case reactions_write = "reactions:write" 33 | case reminders_read = "reminders:read" 34 | case reminders_write = "reminders:write" 35 | case search_read = "search:read" 36 | case stars_read = "stars:read" 37 | case stars_write = "stars:write" 38 | case team_read = "team:read" 39 | case usergroups_read = "usergroups:read" 40 | case usergroups_write = "usergroups:write" 41 | case users_profile_read = "users.profile:read" 42 | case users_profile_write = "users.profile:write" 43 | case users_read = "users:read" 44 | case users_write = "users:write" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/WebAPI/WebAPI.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public final class WebAPI { 4 | // MARK: - Private Properties 5 | private let network: Network 6 | private var authenticator: WebAPIAuthenticator? 7 | 8 | // MARK: - Lifecycle 9 | public init(network: Network) { 10 | self.network = network 11 | 12 | network.register( 13 | middleware: [ 14 | HTTPStatusCodeMiddleware(), 15 | WebAPIMiddleware(), 16 | ] 17 | ) 18 | } 19 | 20 | // MARK: - Public 21 | public func use(authenticator: WebAPIAuthenticator?) { 22 | self.authenticator = authenticator 23 | } 24 | public func perform(request: T) throws -> T.Result { 25 | do { 26 | let networkRequest = try request 27 | .signed(with: self.authenticator) 28 | .addCommonHeaders() 29 | 30 | let response = try network.perform( 31 | request: networkRequest, 32 | middleware: [RetryMiddleware(maxRetries: 3)] 33 | ) 34 | 35 | return try request.handle(response: response) 36 | 37 | } catch NetworkRequestError.error(_, let error) { 38 | let unsigned = request.unsigned().addCommonHeaders() 39 | 40 | throw RequestError.error( 41 | type: T.self, 42 | error: NetworkRequestError.error(request: unsigned, error: error) 43 | ) 44 | 45 | } catch let error { 46 | throw RequestError.error(type: T.self, error: error) 47 | } 48 | } 49 | } 50 | 51 | private extension Dictionary where Value: OptionalType { 52 | func encoded(with encoding: Encoding) -> Data? { 53 | switch encoding { 54 | case .form: return strippingNils().urlEncoded() 55 | case .json: return strippingNils().jsonEncoded() 56 | } 57 | } 58 | } 59 | private extension Dictionary { 60 | func urlEncoded() -> Data? { 61 | // borrowed from Moya 62 | let generalDelimitersToEncode = ":#[]@" 63 | let subDelimitersToEncode = "!$&'()*+,;=" 64 | 65 | var allowedCharacterSet = CharacterSet.urlQueryAllowed 66 | allowedCharacterSet.remove(charactersIn: "\(generalDelimitersToEncode)\(subDelimitersToEncode)") 67 | 68 | return self 69 | .map { ($0.key, "\($0.value)".addingPercentEncoding(withAllowedCharacters: allowedCharacterSet)) } 70 | .filter { $0.1 != nil } 71 | .map { "\($0)=\($1!)" } 72 | .joined(separator: "&") 73 | .data(using: .utf8, allowLossyConversion: false) 74 | } 75 | func jsonEncoded() -> Data? { 76 | return try? JSONSerialization.data(withJSONObject: self, options: []) 77 | } 78 | } 79 | 80 | private extension WebAPIRequest { 81 | func unsigned() -> NetworkRequest { 82 | return NetworkRequest( 83 | method: .POST, 84 | url: url, 85 | headers: [:], 86 | body: body.encoded(with: encoding) 87 | ) 88 | } 89 | func signed(with authenticator: WebAPIAuthenticator?) throws -> NetworkRequest { 90 | var request = unsigned() 91 | 92 | guard authenticated else { return request } 93 | 94 | guard let authenticator = authenticator else { throw WebAPI.Error.authenticationRequired } 95 | 96 | let signedBody = body + ["token": try authenticator.token(for: self)] 97 | request.body = signedBody.encoded(with: encoding) 98 | return request 99 | } 100 | } 101 | 102 | private extension NetworkRequest { 103 | func addCommonHeaders() -> NetworkRequest { 104 | let common: [String: LosslessStringConvertible] = [ 105 | "Content-Type": "application/x-www-form-urlencoded", 106 | "Accept" : "application/json", 107 | ] 108 | 109 | return NetworkRequest( 110 | method: method, 111 | url: url, 112 | headers: common + headers, 113 | body: body 114 | ) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Sources/WebAPI/WebAPIAuthenticator.swift: -------------------------------------------------------------------------------- 1 | 2 | public protocol WebAPIAuthenticator { 3 | func token(for method: T) throws -> String 4 | } 5 | -------------------------------------------------------------------------------- /Sources/WebAPI/WebAPIRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public let DefaultBaseURL = "https://slack.com/api/" 4 | 5 | public func WebAPIURL(base: String, _ pathSegments: String...) -> String { 6 | return base + pathSegments.joined(separator: "/") 7 | } 8 | 9 | public enum Encoding { 10 | case form, json 11 | } 12 | 13 | public protocol WebAPIRequest { 14 | associatedtype Result 15 | 16 | var url: String { get } 17 | var encoding: Encoding { get } 18 | var endpoint: String { get } 19 | var body: [String: Any?] { get } 20 | 21 | var scopes: [WebAPI.Scope] { get } 22 | var authenticated: Bool { get } 23 | 24 | func handle(response: NetworkResponse) throws -> Result 25 | } 26 | 27 | public extension WebAPIRequest { 28 | var url: String { return WebAPIURL(base: DefaultBaseURL, endpoint) } 29 | var encoding: Encoding { return .form } 30 | var body: [String: Any?] { return [:] } 31 | var scopes: [WebAPI.Scope] { return [] } 32 | var authenticated: Bool { return true } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/CommonTests/CollectionTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Common 3 | 4 | class CollectionTests: XCTestCase { 5 | func testRandom() { 6 | let source = Array(0..<10) 7 | 8 | let result = Array(0..<10_000) 9 | .flatMap { _ in source.randomElement } 10 | .map(Set.init) 11 | 12 | XCTAssertTrue(result.count > 1) 13 | } 14 | 15 | func testGroup() throws { 16 | let source = ["Aaron", "Bob", "Brian", "Carl"] 17 | 18 | let result = try source.group(by: { String(try $0.first.requiredValue()) }) 19 | 20 | let expected = [ 21 | "A" : ["Aaron"], 22 | "B" : ["Bob", "Brian"], 23 | "C" : ["Carl"], 24 | ] 25 | 26 | for (key, value) in result { 27 | XCTAssertEqual(value, try expected[key].requiredValue()) 28 | } 29 | } 30 | 31 | static var allTests = [ 32 | ("testRandom", testRandom), 33 | ("testGroup", testGroup), 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /Tests/CommonTests/Common.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Common 3 | 4 | enum TestError: Error, Equatable { 5 | case error 6 | 7 | static func ==(lhs: TestError, rhs: TestError) -> Bool { 8 | return true 9 | } 10 | } 11 | 12 | extension Result { 13 | func assertFailure(_ error: T) where T: Equatable { 14 | do { 15 | _ = try extract() 16 | } catch let e as T { 17 | XCTAssertEqual(error, e) 18 | } catch { 19 | XCTFail() 20 | } 21 | } 22 | } 23 | 24 | enum CastError: Error { 25 | case failed(from: T.Type, to: U.Type) 26 | } 27 | 28 | func cast(_ value: T) throws -> U { 29 | guard let castedValue = value as? U else { throw CastError.failed(from: T.self, to: U.self) } 30 | return castedValue 31 | } 32 | 33 | func XCTAssertThrows(error: Error, from expression: @autoclosure () throws -> Any, file: StaticString = #file, line: UInt = #line) { 34 | do { 35 | _ = try expression() 36 | XCTFail("Expression was successful, Expected the failure: \(error)", file: file, line: line) 37 | } catch let e { 38 | XCTAssertEqual(String(describing: e), String(describing: error), file: file, line: line) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Tests/CommonTests/DictionaryTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Common 3 | 4 | class DictionaryTests: XCTestCase { 5 | func testAppending() { 6 | let left = [ 7 | "A": [1,2,3], 8 | "B": [3,4,5], 9 | ] 10 | let right = [ 11 | "B": [6], 12 | "C": [7,8,9], 13 | ] 14 | 15 | let result = left.appending(right) 16 | 17 | let expected = [ 18 | "A" : [1,2,3], 19 | "B" : [6], 20 | "C" : [7,8,9], 21 | ] 22 | 23 | for (key, value) in result { 24 | XCTAssertEqual(value, try expected[key].requiredValue()) 25 | } 26 | } 27 | } 28 | 29 | extension DictionaryTests { 30 | func testMap() { 31 | let source = [ 32 | "one": 1, 33 | "two": 2, 34 | "three": 3, 35 | ] 36 | 37 | let result = source.map { ($0.uppercased(), String($1)) } 38 | 39 | let expected = [ 40 | "ONE": "1", 41 | "TWO": "2", 42 | "THREE": "3", 43 | ] 44 | 45 | for (key, value) in result { 46 | XCTAssertEqual(value, try expected[key].requiredValue()) 47 | } 48 | } 49 | func testMapValues() { 50 | let source = [ 51 | "one": 1, 52 | "two": 2, 53 | "three": 3, 54 | ] 55 | 56 | let result = source.mapValues { $0 + 1 } 57 | 58 | let expected = [ 59 | "one": 2, 60 | "two": 3, 61 | "three": 4, 62 | ] 63 | 64 | for (key, value) in result { 65 | XCTAssertEqual(value, try expected[key].requiredValue()) 66 | } 67 | } 68 | func testFlatMap() { 69 | let source = [ 70 | "one": 1, 71 | "two": 2, 72 | "three": 3, 73 | ] 74 | 75 | let result: [String: String] = source.flatMap { key, value in 76 | guard value != 2 else { return nil } 77 | 78 | return (key.uppercased(), String(value)) 79 | } 80 | 81 | let expected = [ 82 | "ONE": "1", 83 | "THREE": "3", 84 | ] 85 | 86 | for (key, value) in result { 87 | XCTAssertEqual(value, try expected[key].requiredValue()) 88 | } 89 | } 90 | } 91 | 92 | extension DictionaryTests { 93 | func testFilter() { 94 | let source = [ 95 | "one": 1, 96 | "two": 2, 97 | "three": 3, 98 | ] 99 | 100 | let result = source.filter { $0.value != 2 } 101 | 102 | let expected = [ 103 | "one": 1, 104 | "three": 3, 105 | ] 106 | 107 | for (key, value) in result { 108 | XCTAssertEqual(value, try expected[key].requiredValue()) 109 | } 110 | } 111 | } 112 | 113 | extension DictionaryTests { 114 | func testMakeString() { 115 | let source: [String: Any] = [ 116 | "A": 1, 117 | "B": [1,2], 118 | "C": ["D": 3], 119 | ] 120 | 121 | guard let result = source.makeString() else { return XCTFail() } 122 | 123 | // the order can't be determined so we cater for each.. 124 | // curious if there is a better way? 125 | let expected = [ 126 | "{\"C\":{\"D\":3},\"B\":[1,2],\"A\":1}", // cd, b, a 127 | "{\"C\":{\"D\":3},\"A\":1,\"B\":[1,2]}", // cd, a, b 128 | "{\"B\":[1,2],\"A\":1,\"C\":{\"D\":3}}", // b, a, cd 129 | "{\"A\":1,\"B\":[1,2],\"C\":{\"D\":3}}", // a, b, cd 130 | "{\"B\":[1,2],\"C\":{\"D\":3},\"A\":1}", // b, cd, a 131 | "{\"A\":1,\"C\":{\"D\":3},\"B\":[1,2]}", // a, cd, b 132 | ] 133 | 134 | XCTAssertNotNil(expected.first(where: { $0 == result }), "None of the expected values matched: \(result)") 135 | } 136 | } 137 | 138 | extension DictionaryTests { 139 | static var allTests = [ 140 | ("testAppending", testAppending), 141 | 142 | ("testMap", testMap), 143 | ("testMapValues", testMapValues), 144 | ("testFlatMap", testFlatMap), 145 | 146 | ("testFilter", testFilter), 147 | 148 | ("testMakeString", testMakeString), 149 | ] 150 | } 151 | -------------------------------------------------------------------------------- /Tests/CommonTests/KeyPathAccessibleTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Common 3 | 4 | class KeyPathAccessibleTests: XCTestCase { 5 | func testArray_Success() throws { 6 | try XCTAssertEqual([1,2,3].value(at: 1), 2) 7 | try XCTAssertEqual([[1,2], [3, 4]].path(at: 1).value(at: 1), 4) 8 | } 9 | func testDictionary_Success() throws { 10 | let source: [String: Any] = [ 11 | "A": 1, 12 | "B": [2,3], 13 | "C": ["A": [4, 5]], 14 | ] 15 | 16 | try XCTAssertEqual(source.value(at: "A"), 1) 17 | try XCTAssertEqual(source.path(at: "B").value(at: 0), 2) 18 | try XCTAssertEqual(source.path(at: "C").path(at: "A").value(at: 0), 4) 19 | } 20 | } 21 | 22 | extension KeyPathAccessibleTests { 23 | func testArray_Failure() { 24 | let source = [1,2,3,4] 25 | 26 | XCTAssertThrows( 27 | error: KeyPathError.invalid(key: ["foo" as KeyPathComponent]), 28 | from: try source.value(at: "foo") 29 | ) 30 | XCTAssertThrows( 31 | error: KeyPathError.missing(key: [5 as KeyPathComponent]), 32 | from: try source.value(at: 5) 33 | ) 34 | XCTAssertThrows( 35 | error: KeyPathError.mismatch(key: [0], expected: String.self, found: Int.self), 36 | from: try source.value(at: 0) as String 37 | ) 38 | } 39 | func testDictionary_Failure() { 40 | let source: [String: Any] = ["A": 1] 41 | 42 | XCTAssertThrows( 43 | error: KeyPathError.invalid(key: [0 as KeyPathComponent]), 44 | from: try source.value(at: 0) 45 | ) 46 | XCTAssertThrows( 47 | error: KeyPathError.missing(key: ["foo" as KeyPathComponent]), 48 | from: try source.value(at: "foo") 49 | ) 50 | XCTAssertThrows( 51 | error: KeyPathError.mismatch(key: ["A"], expected: String.self, found: Any.self), 52 | from: try source.value(at: "A") as String 53 | ) 54 | } 55 | } 56 | 57 | extension KeyPathAccessibleTests { 58 | static var allTests = [ 59 | ("testArray_Success", testArray_Success), 60 | ("testDictionary_Success", testDictionary_Success), 61 | 62 | ("testArray_Failure", testArray_Failure), 63 | ("testDictionary_Failure", testDictionary_Failure), 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /Tests/CommonTests/NeighborSequenceTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Common 3 | 4 | class NeighborSequenceTests: XCTestCase { 5 | func testIteration() { 6 | let source = Array(0..<5) 7 | 8 | let result = source.neighbors 9 | 10 | let expected: [NeighborSequence<[Int]>.Element] = [ 11 | (previous: nil, current: 0, next: 1), 12 | (previous: 0, current: 1, next: 2), 13 | (previous: 1, current: 2, next: 3), 14 | (previous: 2, current: 3, next: 4), 15 | (previous: 3, current: 4, next: nil), 16 | ] 17 | 18 | for (a, b) in zip(result, expected) { 19 | XCTAssertEqual(a.previous, b.previous) 20 | XCTAssertEqual(a.current, b.current) 21 | XCTAssertEqual(a.next, b.next) 22 | } 23 | } 24 | 25 | static var allTests = [ 26 | ("testIteration", testIteration), 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /Tests/CommonTests/ResultTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Common 3 | 4 | class ResultTests: XCTestCase { 5 | func testResult_InitValue() throws { 6 | let result = Result(42) 7 | try XCTAssertEqual(result.extract(), 42) 8 | } 9 | func testResult_InitError() { 10 | let result = Result(TestError.error) 11 | result.assertFailure(TestError.error) 12 | } 13 | func testResult_InitClosure_Value() throws { 14 | let result = Result({ 42 }) 15 | try XCTAssertEqual(result.extract(), 42) 16 | } 17 | func testResult_InitClosure_Error() { 18 | let result = Result({ throw TestError.error }) 19 | result.assertFailure(TestError.error) 20 | } 21 | 22 | func testResult_Map_Value() throws { 23 | let result = Result(42).map { String($0 * 2) } 24 | try XCTAssertEqual(result.extract(), "84") 25 | } 26 | func testResult_Map_Throws() throws { 27 | let result = Result(42) 28 | try XCTAssertEqual(result.extract(), 42) 29 | 30 | let map = result.map { _ in throw TestError.error } 31 | map.assertFailure(TestError.error) 32 | } 33 | func testResult_Map_AlreadyFailed() { 34 | let result = Result({ throw TestError.error }) 35 | result.assertFailure(TestError.error) 36 | 37 | let map = result.map { $0 + 1 } 38 | map.assertFailure(TestError.error) 39 | } 40 | 41 | func testResult_FlatMap_Value() throws { 42 | let result = Result(42) 43 | try XCTAssertEqual(result.extract(), 42) 44 | 45 | let flatMap = result.flatMap { .success(String($0 * 2)) } 46 | try XCTAssertEqual(flatMap.extract(), "84") 47 | } 48 | func testResult_FlatMap_Throws() throws { 49 | let result = Result(42) 50 | try XCTAssertEqual(result.extract(), 42) 51 | 52 | let flatMap = result.flatMap { _ -> Result in throw TestError.error } 53 | flatMap.assertFailure(TestError.error) 54 | } 55 | func testResult_FlatMap_AlreadyFailed() { 56 | let result = Result({ throw TestError.error }) 57 | result.assertFailure(TestError.error) 58 | 59 | let flatMap = result.flatMap { .success(String($0 * 2)) } 60 | flatMap.assertFailure(TestError.error) 61 | } 62 | } 63 | 64 | extension ResultTests { 65 | static let allTests = [ 66 | ("testResult_InitValue", testResult_InitValue), 67 | ("testResult_InitError", testResult_InitError), 68 | ("testResult_InitClosure_Value", testResult_InitClosure_Value), 69 | ("testResult_InitClosure_Error", testResult_InitClosure_Error), 70 | 71 | ("testResult_Map_Value", testResult_Map_Value), 72 | ("testResult_Map_Throws", testResult_Map_Throws), 73 | ("testResult_Map_AlreadyFailed", testResult_Map_AlreadyFailed), 74 | 75 | ("testResult_FlatMap_Value", testResult_FlatMap_Value), 76 | ("testResult_FlatMap_Throws", testResult_FlatMap_Throws), 77 | ("testResult_FlatMap_AlreadyFailed", testResult_FlatMap_AlreadyFailed), 78 | ] 79 | } 80 | -------------------------------------------------------------------------------- /Tests/CommonTests/StringTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Common 3 | 4 | class StringTests: XCTestCase { 5 | func testMakeDictionary() throws { 6 | let source = "{\"C\":{\"D\":3},\"B\":[1,2],\"A\":1}" 7 | 8 | let result = try source.makeDictionary().requiredValue() 9 | 10 | try XCTAssertEqual(cast(result["A"].requiredValue()), 1) 11 | try XCTAssertEqual(cast(result["B"].requiredValue()), [1,2]) 12 | try XCTAssertEqual(cast(result["C"].requiredValue()), ["D": 3]) 13 | } 14 | func testSplit() { 15 | let source = "foobar" 16 | 17 | let (a,b) = source.split(at: source.startIndex) 18 | XCTAssertEqual(a, "") 19 | XCTAssertEqual(b, "foobar") 20 | 21 | let (c,d) = source.split(at: source.endIndex) 22 | XCTAssertEqual(c, "foobar") 23 | XCTAssertEqual(d, "") 24 | 25 | let (e,f) = source.split(at: source.index(source.startIndex, offsetBy: 3)) 26 | XCTAssertEqual(e, "foo") 27 | XCTAssertEqual(f, "bar") 28 | } 29 | func testRemovePrefix() { 30 | let source = "foo bar" 31 | 32 | XCTAssertEqual(source.remove(prefix: "foo", includeWhitespace: true), "bar") 33 | XCTAssertEqual(source.remove(prefix: "foo", includeWhitespace: false), " bar") 34 | 35 | XCTAssertEqual(source.remove(prefix: "bar", includeWhitespace: true), "foo bar") 36 | XCTAssertEqual(source.remove(prefix: "bar", includeWhitespace: false), "foo bar") 37 | } 38 | func testSubstring() { 39 | let source = "foobar" 40 | 41 | XCTAssertEqual(source.substring(to: "b"), "foo") 42 | XCTAssertEqual(source.substring(to: "o"), "f") 43 | XCTAssertEqual(source.substring(to: "z"), nil) 44 | } 45 | func testTrimming() { 46 | let source = "!@#foo" 47 | 48 | XCTAssertEqual(source.trimCharacters(in: ["!", "@", "#"]), "foo") 49 | XCTAssertEqual(source.trimCharacters(in: ["@", "#"]), source) 50 | XCTAssertEqual(source.trimCharacters(in: ["$"]), source) 51 | 52 | XCTAssertEqual("".trimCharacters(in: []), "") 53 | XCTAssertEqual("foo".trimCharacters(in: []), "foo") 54 | XCTAssertEqual("".trimCharacters(in: ["$"]), "") 55 | } 56 | func testSubstrings_Regex() { 57 | let source = "testing 123, testing 123" 58 | 59 | let empty: [RegexMatch] = source.substrings(matching: "") 60 | XCTAssertTrue(empty.isEmpty) 61 | 62 | let numbers: [RegexMatch] = source.substrings(matching: "\\d+") 63 | let expected: [RegexMatch] = [ 64 | RegexMatch( 65 | range: source.index(source.startIndex, offsetBy: 8)..