├── .gitignore ├── .travis.yml ├── API.md ├── CONTRIBUTING.md ├── INSTALLATION.md ├── Package.swift ├── README.md └── Sources └── Bot ├── Bot+Convenience.swift ├── Bot+DataService.swift ├── Bot+EventDispatch.swift ├── Bot+State.swift ├── Bot.swift ├── RTMStartOption+ConfigValue.swift ├── SlackAuthenticator+OAuth.swift ├── SlackAuthenticator+OAuthAuthenticator.swift ├── SlackAuthenticator+OAuthErrors.swift ├── SlackAuthenticator+Token.swift ├── SlackAuthenticator.swift └── SlackService.swift /.gitignore: -------------------------------------------------------------------------------- 1 | Packages 2 | .build 3 | .DS_Store 4 | *.xcodeproj 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: 2 | - linux 3 | - osx 4 | language: generic 5 | sudo: required 6 | dist: trusty 7 | osx_image: xcode8 8 | script: 9 | - eval "$(curl -sL https://raw.githubusercontent.com/ChameleonBot/Scripts/master/ci)" 10 | -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | # Receiving Slack events 2 | 3 | ## SlackService API 4 | There are a series of `SlackService` protocols you can use that 5 | will give you access to Slacks events. 6 | 7 | ### SlackConnectionService 8 | This service provides you with a function that is called 9 | once the bot is connected to Slack and gives you 10 | access to all of the teams data. 11 | 12 | ``` 13 | func connected(slackBot: SlackBot, botUser: BotUser, team: Team, users: [User], channels: [Channel], groups: [Group], ims: [IM]) throws { 14 | 15 | } 16 | ``` 17 | 18 | ### SlackDisconnectionService 19 | This service provides you with a function that is called 20 | when the bot is disconnected from Slack 21 | 22 | ``` 23 | func disconnected(slackBot: SlackBot, error: Error?) { 24 | 25 | } 26 | ``` 27 | 28 | ### SlackErrorService 29 | This service provides you with a function that is called 30 | when an error occurs, this could be from Slack as well as 31 | any loaded `SlackService`s. 32 | 33 | ``` 34 | func error(slackBot: SlackBot, error: Error) { 35 | 36 | } 37 | ``` 38 | 39 | ### SlackRTMEventService 40 | This service provides you with a function that is called when the bot is created. 41 | It gives you access to a `SlackRTMEventDispatcher` object. This event dispatcher can 42 | be used to listen to any event from Slacks [RTM API](https://api.slack.com/rtm). 43 | 44 | `RTMAPIEvent`s are named for their [RTM API](https://api.slack.com/events) 45 | counterparts and can be used as follows: 46 | 47 | ``` 48 | func configureEvents(slackBot: SlackBot, webApi: WebAPI, dispatcher: SlackRTMEventDispatcher) { 49 | dispatcher.onEvent(message.self) { data in 50 | print("User: \(data.message.user?.name) said, \(data.message.text)") 51 | } 52 | dispatcher.onEvent(error.self) { data in 53 | print("Error: \(data.message) - Code (\(data.code))") 54 | } 55 | } 56 | ``` 57 | 58 | The `data` parameter passed along will contain the data specific to each event. 59 | 60 | The following events are currently supported: 61 | 62 | | Event | 63 | |----------------------| 64 | | error | 65 | | hello | 66 | | pong | 67 | | message | 68 | | presence_change | 69 | | reconnect_url | 70 | | user_change | 71 | | user_typing | 72 | | channel_marked | 73 | | channel_created | 74 | | channel_joined | 75 | | channel_left | 76 | | channel_deleted | 77 | | channel_archive | 78 | | channel_unarchive | 79 | | channel_rename | 80 | | dnd_updated | 81 | | dnd_updated_user | 82 | | file_private | 83 | | file_change | 84 | | file_created | 85 | | file_public | 86 | | file_shared | 87 | | file_unshared | 88 | | file_deleted | 89 | | file_comment_added | 90 | | file_comment_edited | 91 | | file_comment_deleted | 92 | | group_joined | 93 | | group_left | 94 | | group_open | 95 | | group_close | 96 | | group_archive | 97 | | group_unarchive | 98 | | group_marked | 99 | | group_rename | 100 | | im_close | 101 | | im_created | 102 | | im_marked | 103 | | im_open | 104 | | reaction_added | 105 | | reaction_removed | 106 | 107 | ### SlackSlashCommandService 108 | This service allows you to respond to /slash commands. 109 | 110 | ``` 111 | struct MySlashCommandService: SlackSlashCommandService { 112 | let slashCommands = ["/foo", "/bar"] 113 | 114 | func slashCommand(slackBot: SlackBot, command: SlashCommand, webApi: WebAPI) throws { 115 | if (command.command == "/foo") { 116 | //handle foo 117 | 118 | } else if (command.command == "/bar") { 119 | //handle bar 120 | } 121 | } 122 | } 123 | ``` 124 | 125 | `command` is a `SlashCommand` containing all the data associated with the command. 126 | 127 |
128 | 129 | # Posting to Slack 130 | Once you have received an event you will probably want to post message back to Slack. 131 | To do this you can use the `WebAPI` instance that is provided to each service. 132 | 133 | ``` 134 | func configureEvents(slackBot: SlackBot, webApi: WebAPI, dispatcher: SlackRTMEventDispatcher) { 135 | dispatcher.onEvent(message.self) { data in 136 | guard 137 | let target = data.message.channel?.value, 138 | let text = data.message.text, 139 | !text.isEmpty 140 | else { return } 141 | 142 | let request = ChatPostMessage(target: target, text: "ECHO: \(text)) 143 | try webApi.execute(request) 144 | } 145 | } 146 | ``` 147 | 148 | For slash commands you are provided with a response url that you can use to respond. 149 | 150 | ``` 151 | func slashCommand(slackBot: SlackBot, command: SlashCommand, webApi: WebAPI) throws { 152 | if (command.command == "/myCommand") { 153 | guard 154 | let channel = command.channel, 155 | let url = URL(string: command.response_url) 156 | else { return } 157 | 158 | let request = ChatPostMessage( 159 | target: target, 160 | text: "Command parameters: \(command.text)", 161 | customUrl: url 162 | ) 163 | try webApi.execute(request) 164 | } 165 | } 166 | ``` 167 | 168 | Check out [Sugar](https://github.com/ChameleonBot/Sugar) for some syntactic sugar 169 | you can use to make some common tasks a little easier. 170 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | ## Convention 3 | Please make sure you are following the conventions of the existing code. 4 | 5 | ## Branches 6 | Make your branch names short yet descriptive of whats in them. 7 | You should also be keeping them up to date with `master` 8 | 9 | ## Commit messages 10 | Keep them short, under 50 characters is ideal. 11 | Make them [atomic][1], one commit message should pair with one change. 12 | If you have to add an “and” in your commit message, you’ve already committed too much. 13 | 14 | ## Pull requests 15 | Provide a short description of what the code does and why it should be added to the project. 16 | Bullet lists of the contribution are fine and should be easy if your commit messages are also simple. 17 | 18 | ## References: 19 | - [Git Style Guide](https://github.com/agis-/git-style-guide) 20 | - [How to Write a Git Commit Message](http://chris.beams.io/posts/git-commit/) 21 | - [Keep your commits "Atomic"][1] 22 | 23 | [1]: https://www.freshconsulting.com/atomic-commits/ 24 | -------------------------------------------------------------------------------- /INSTALLATION.md: -------------------------------------------------------------------------------- 1 | # Prerequisites 2 | Please work through the following to get your bot up and running. 3 | 4 | ## Xcode 8 5 | Ensure you have Xcode 8 installed, you can download it at [Apple Developer Downloads.](https://developer.apple.com/download/) or via the AppStore. 6 | 7 | ## Slack 8 | There are two ways to authenticate your bot with slack: 9 | 10 | ### OAuth 11 | OAuth access will allow you full access to all of Slacks API capabilities 12 | including `Slash Commands` and `Interactive Buttons` 13 | 14 | You will need to [create a new Slack app](https://api.slack.com/apps/new). 15 | Once created go into the `App Credentials` section and add a redirection url. 16 | It should be the url to your bot with a trailing `/oauth`. 17 | For example `https://yourbot.yourhost.com/oauth`. 18 | Make sure you copy the client id and secret, you will need them later. 19 | 20 | Finally under the `Bot Users` section attach a bot user to the app. 21 | 22 | ### Token 23 | Token based authentication is simpler however you will *not* 24 | be able to use `Slash Commands` or `Interactive Buttons` 25 | 26 | You will need to [configure](https://my.slack.com/services/new/bot) a new bot user for your team. 27 | Make sure you copy the token, you will need it later. 28 | 29 | ## Heroku 30 | If you plan to run a bot on Heroku you will need a [Heroku](https://www.heroku.com/) account (free is fine!) 31 | and also have the [Heroku Toolbelt](https://toolbelt.heroku.com/) installed. 32 | 33 |
34 | 35 | # Deploying 36 | Start by cloning or downloading the [example bot](https://github.com/ChameleonBot/Example). 37 | 38 | ## Running locally on OSX 39 | * Open a terminal window and go to the directory containing `Package.swift` 40 | * Type `swift build` 41 | * For **token** based auth: Type `.build/debug/app --token=""` 42 | * For **oauth** based auth: Type `.build/debug/app --clientId=" --clientSecret=""` 43 | * Go into a default channel in your slack and say `hi @yourBot` - it should respond. 44 | 45 | ## Deploy to Heroku 46 | * Open a terminal window and go to the directory containing `Package.swift` 47 | * Using the [Heroku CLI](https://devcenter.heroku.com/articles/heroku-command) login and create a new app. 48 | * In the Heroku dashboard add config variables for authentication: 49 | * For **token** based auth add a variable named `TOKEN` with your slack token 50 | * For **oauth** based auth add a variables named `CLIENT_ID` and `CLIENT_SECRET` with your slack client id and secret 51 | * Set the buildpack `heroku buildpacks:set https://github.com/IanKeen/heroku-buildpack-swift` 52 | * Create a file called `Procfile` and add the text: `web: App --config:servers.default.port=$PORT` 53 | * Deploy to Heroku by typing: 54 | ``` 55 | git add . 56 | git commit -am 'depoy to heroku' 57 | git push heroku master 58 | ``` 59 | * Once that has completed type: `heroku ps:scale web=1` 60 | * Go into a default channel in your slack and say `hi @yourBot` - it should respond. 61 | 62 |
63 | 64 | # Troubleshooting 65 | ### OpenSSL Errors on OSX 66 | If you are unable to build/run locally due to an `openssl` error, you may need to run the following in terminal: 67 | 68 | ``` 69 | brew install openssl 70 | brew link openssl 71 | ``` 72 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | import PackageDescription 2 | 3 | let package = Package( 4 | name: "Bot", 5 | targets: [ 6 | Target( 7 | name: "Bot", 8 | dependencies: [ 9 | ] 10 | ) 11 | ], 12 | dependencies: [ 13 | .Package(url: "https://github.com/ChameleonBot/Common.git", majorVersion: 0, minor: 2), 14 | .Package(url: "https://github.com/ChameleonBot/Config.git", majorVersion: 0, minor: 2), 15 | .Package(url: "https://github.com/ChameleonBot/Models.git", majorVersion: 0, minor: 2), 16 | .Package(url: "https://github.com/ChameleonBot/Services.git", majorVersion: 0, minor: 2), 17 | .Package(url: "https://github.com/ChameleonBot/WebAPI.git", majorVersion: 0, minor: 2), 18 | .Package(url: "https://github.com/ChameleonBot/RTMAPI.git", majorVersion: 0, minor: 2), 19 | ], 20 | exclude: [ 21 | "XcodeProject" 22 | ] 23 | ) 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chameleon 2 | Chameleon is a Slack bot built with Swift. 3 | 4 | ![Version](https://img.shields.io/badge/Version-0.1.0-brightgreen.svg) 5 | ![Swift](https://camo.githubusercontent.com/0727f3687a1e263cac101c5387df41048641339c/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f53776966742d332e302d6f72616e67652e7376673f7374796c653d666c6174) 6 | ![Platforms](https://img.shields.io/badge/Platforms-osx%20%7C%20linux-lightgrey.svg) 7 | 8 | ## What is Chameleon? 9 | It consists of several frameworks, the core of which are: 10 | 11 | * **Models**: Exposes Slack model data. Slack's APIs only provide object ids in their responses, however the model layer is able to convert those into _full_ model objects. 12 | * **WebAPI**: Allows interaction with Slack's Web API. 13 | * **RTMAPI**: Allows interaction with Slack's Real-time messaging API. 14 | * **Bot**: Utilises each of the frameworks to provide an extensible Slack bot user. 15 | * **Sugar**: Provides a layer over all frameworks adding various helpers, wrappers and syntactic sugar to make developing features more declarative. 16 | 17 | ## Features 18 | * [x] **Extensible**: `SlackService`'s can be added to provide any behaviour you need. 19 | * [x] **Typed**: You always get to work with _full_ typed Slack model data. 20 | 21 | # Installation 22 | Refer to the [Installation Guide](https://github.com/ChameleonBot/Bot/blob/master/INSTALLATION.md). 23 | 24 | # APIs 25 | Refer to the [API Guide](https://github.com/ChameleonBot/Bot/blob/master/API.md). 26 | 27 | # Contributing 28 | Want to contribute, check out the [Contribution Guide](https://github.com/ChameleonBot/Bot/blob/master/CONTRIBUTING.md). 29 | 30 | # ⚠️ Work in Progress 31 | This is a work in progress so expect improvements as well as breaking changes! 32 | 33 | Chameleon is *functional* however there is still a lot to do before it is *complete* 34 | 35 | * The Web and Real time messaging APIs can do _a lot_ - 36 | I have built support for the core/most common features but they are incomplete. 37 | I will add more over time until they are complete. 38 | 39 | # Acknowledgement 40 | This was my first dive into 'Server Side Swift'; 41 | 95% of this code was done over a total of a few days but getting working in the terminal 42 | and then deployed to Heroku took far longer... This project would likely have ended up as 43 | mostly useless OSX app if it hadn't been for the teams from [Vapor](http://qutheory.io/) and [Zewo](http://www.zewo.io/) 44 | pioneering the server side Swift movement. I am especially thankful for the help and patience of 45 | [Logan Wright](https://twitter.com/LogMaestro), [Tanner Nelson](https://twitter.com/tanner0101) and [Dan Appel](https://twitter.com/Dan_Appel) 46 | 47 | # Contact 48 | Feel free to open an issue or PR, 49 | if you wanna chat you can find me on Twitter ([@IanKay](https://twitter.com/IanKay)) 50 | or on [iOS Developers](http://ios-developers.io) 51 | -------------------------------------------------------------------------------- /Sources/Bot/Bot+Convenience.swift: -------------------------------------------------------------------------------- 1 | import class Vapor.Droplet 2 | 3 | /* 4 | 5 | I write messy boilerplate so you don't have to ;) 6 | 7 | */ 8 | 9 | public extension SlackBot { 10 | public convenience init< 11 | Auth: SlackAuthenticator & DependencyBuildable, 12 | Data: Storage & ConfigBuildable 13 | >( 14 | configDataSource: ConfigDataSource, 15 | configItems: [ConfigItem.Type] = [], 16 | authenticator: Auth.Type, 17 | storage: Data.Type, 18 | services: [SlackService]) throws { 19 | 20 | let server = HTTPServerProvider() 21 | let http = HTTPProvider() 22 | let webAPI = WebAPI(http: http) 23 | let rtmAPI = RTMAPI(websocket: WebSocketProvider()) 24 | 25 | let config = try Config( 26 | supportedItems: AllConfigItems(including: configItems + authenticator.configItems), 27 | source: configDataSource 28 | ) 29 | 30 | //I miss splatting :( 31 | let storageInstance = try storage.make( 32 | config: config 33 | ) 34 | 35 | let authenticatorInstance = try authenticator.make( 36 | config: config, 37 | server: server, 38 | http: http, 39 | rtmAPI: rtmAPI, 40 | webAPI: webAPI, 41 | storage: storageInstance 42 | ) 43 | 44 | self.init( 45 | config: config, 46 | authenticator: authenticatorInstance, 47 | storage: storageInstance, 48 | http: http, 49 | webAPI: webAPI, 50 | rtmAPI: rtmAPI, 51 | server: server, 52 | services: services 53 | ) 54 | } 55 | } 56 | 57 | //MARK: - Quick and dirty dependency factory 58 | //This was just a simple way to make building a bot instance and it's dependencies _super_ easy for 99% of use cases 59 | //I don't want to clutter the `App` with everything above 60 | public protocol DependencyBuildable { 61 | static func make(config: Config, server: HTTPServer, http: HTTP, rtmAPI: RTMAPI, webAPI: WebAPI, storage: Storage) throws -> Self 62 | } 63 | 64 | public protocol ConfigBuildable { 65 | static func make(config: Config) throws -> Self 66 | } 67 | 68 | 69 | //MARK: - Authenticators 70 | extension OAuthAuthentication: DependencyBuildable { 71 | public static func make(config: Config, server: HTTPServer, http: HTTP, rtmAPI: RTMAPI, webAPI: WebAPI, storage: Storage) throws -> OAuthAuthentication { 72 | return try OAuthAuthentication( 73 | config: config, 74 | server: server, 75 | http: http, 76 | storage: storage 77 | ) 78 | } 79 | } 80 | 81 | extension TokenAuthentication: DependencyBuildable { 82 | public static func make(config: Config, server: HTTPServer, http: HTTP, rtmAPI: RTMAPI, webAPI: WebAPI, storage: Storage) throws -> TokenAuthentication { 83 | return try TokenAuthentication(config: config) 84 | } 85 | } 86 | 87 | //MARK: - Storage 88 | extension MemoryStorage: ConfigBuildable { 89 | public static func make(config: Config) throws -> MemoryStorage { 90 | return MemoryStorage() 91 | } 92 | } 93 | extension RedisStorage: ConfigBuildable { 94 | public static func make(config: Config) throws -> RedisStorage { 95 | return try RedisStorage(url: try config.value(for: StorageURL.self)) 96 | } 97 | } 98 | 99 | #if !os(Linux) 100 | extension PlistStorage: ConfigBuildable { 101 | public static func make(config: Config) throws -> PlistStorage { 102 | return PlistStorage() 103 | } 104 | } 105 | #endif 106 | -------------------------------------------------------------------------------- /Sources/Bot/Bot+DataService.swift: -------------------------------------------------------------------------------- 1 | 2 | extension SlackBot { 3 | final class DataService: SlackRTMEventService { 4 | func configureEvents(slackBot: SlackBot, webApi: WebAPI, dispatcher: SlackRTMEventDispatcher) { 5 | dispatcher.onEvent(team_join.self) { user in 6 | slackBot.users.append(user) 7 | } 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/Bot/Bot+EventDispatch.swift: -------------------------------------------------------------------------------- 1 | import RTMAPI 2 | 3 | /// An abstraction that represents an object that can route `RTMAPIEvent`s 4 | public protocol SlackRTMEventDispatcher { 5 | func onEvent(_ event: T.Type, handler: @escaping (T.Parameters) throws -> Void) 6 | } 7 | 8 | extension RTMAPI: SlackRTMEventDispatcher { } 9 | -------------------------------------------------------------------------------- /Sources/Bot/Bot+State.swift: -------------------------------------------------------------------------------- 1 | import Common 2 | 3 | //MARK: - Typealiase 4 | typealias BotStateMachine = StateMachine 5 | 6 | //MARK: - States 7 | /// Defines the states the bot will move through during connection 8 | enum BotState { 9 | /** 10 | * The bot is disconnected 11 | * 12 | * @param Error? Exists when the disconnection was the result of an error 13 | */ 14 | case disconnected(error: Error?) 15 | 16 | /** 17 | * The bot is attempting to connect 18 | * 19 | * @param Int The attempt number 20 | * @param Int The maximum number of attempts 21 | */ 22 | case connecting(attempt: Int, maximumAttempts: Int) 23 | 24 | /** 25 | * The bot is connected. 26 | * NOTE: There are two things that need to happen for the bot to be considered 'ready' 27 | * 28 | * 1. We need to receive the `.hello` event from the `RTMAPI` 29 | * 2. The `RTMStart` WebAPI method has to deserialise the Slack teams models 30 | * 31 | * Because `RTMStart` is performed asynchronously the order of 1 & 2 are not guaranteed 32 | * so we use a nested `OptionSet` to keep track of each event. 33 | * 34 | * @param ConnectedState The nested `OptionSet` with the current `ConnectedState` 35 | */ 36 | case connected(state: ConnectedState, maximumReconnectionAttempts: Int) 37 | 38 | /// Sub-states available for the .connected state 39 | struct ConnectedState: OptionSet { 40 | let rawValue: Int 41 | init(rawValue: Int) { self.rawValue = rawValue } 42 | static let Hello = ConnectedState(rawValue: 1) 43 | static let Data = ConnectedState(rawValue: 2) 44 | } 45 | } 46 | 47 | //MARK: - Events 48 | enum BotStateEvent { 49 | /// Disconnect the bot 50 | case disconnect(reconnect: Bool, error: Error?) 51 | 52 | /// Update the connection state 53 | case connect(maximumAttempts: Int) 54 | 55 | /// Update teh connection state 56 | case connectionState(state: BotState.ConnectedState) 57 | } 58 | 59 | //MARK: - Transitions 60 | extension BotState: StateRepresentable { 61 | typealias StateEvent = BotStateEvent 62 | 63 | func transition(withEvent event: BotStateEvent) -> BotState? { 64 | switch (self, event) { 65 | 66 | //Disconnected > 67 | case (.disconnected, .connect(let maximumAttempts)): 68 | return .connecting(attempt: 1, maximumAttempts: maximumAttempts) 69 | 70 | //Connecting > 71 | case (.connecting(_, let maximumAttempts), .connectionState(let state)): 72 | return self.connectedWith(state: state, maximumReconnectionAttempts: maximumAttempts) 73 | 74 | case (.connecting(let attempt, let maximumAttempts), .disconnect(let reconnect, let error)): 75 | if (attempt < maximumAttempts && reconnect) { 76 | return .connecting(attempt: attempt + 1, maximumAttempts: maximumAttempts) 77 | } else { 78 | return .disconnected(error: error) 79 | } 80 | 81 | //Connected > 82 | case (.connected(_, let maximumReconnectionAttempts), .disconnect(let reconnect, let error)): 83 | if (reconnect) { 84 | return .connecting(attempt: 1, maximumAttempts: maximumReconnectionAttempts) 85 | } else { 86 | return .disconnected(error: error) 87 | } 88 | 89 | case (.connected(_, let maximumAttempts), .connectionState(let state)): 90 | return self.connectedWith(state: state, maximumReconnectionAttempts: maximumAttempts) 91 | 92 | //Default 93 | default: return nil 94 | } 95 | } 96 | } 97 | 98 | //MARK: - Derrived State 99 | extension BotState { 100 | /** 101 | Defines whether all requirements for the bot to be considered ready have completed 102 | 103 | - seealso: For more information on the requirements see: `BotState.connected` 104 | */ 105 | var ready: Bool { 106 | switch self { 107 | case .connected(let state, _): 108 | return state.contains(.Hello) && state.contains(.Data) 109 | 110 | default: 111 | return false 112 | } 113 | } 114 | 115 | /** 116 | Updates the nested `ConnectedState` for the `State.Connected` parent state 117 | 118 | - parameter new: The `ConnectedState` that has been completed 119 | - returns: An updated `State` value 120 | */ 121 | func connectedWith(state new: BotState.ConnectedState, maximumReconnectionAttempts: Int) -> BotState { 122 | var current = self 123 | switch self { 124 | case .connected(let state, _): 125 | current = .connected(state: state.union(new), maximumReconnectionAttempts: maximumReconnectionAttempts) 126 | default: 127 | current = .connected(state: new, maximumReconnectionAttempts: maximumReconnectionAttempts) 128 | } 129 | return current 130 | } 131 | } 132 | 133 | //MARK: - Equatable 134 | func ==(lhs: BotState, rhs: BotState) -> Bool { 135 | switch (lhs, rhs) { 136 | case (.disconnected, .disconnected): return true 137 | case (.connecting(let lhs_attempt, _), .connecting(let rhs_attempt, _)): return (lhs_attempt == rhs_attempt) 138 | case (.connected(let lhs_state), .connected(let rhs_state)): return lhs_state == rhs_state 139 | default: return false 140 | } 141 | } 142 | 143 | //MARK: - CustomStringConvertible 144 | extension BotState: CustomStringConvertible { 145 | var description: String { 146 | switch self { 147 | case .disconnected(let error): 148 | let errorString = (error == nil ? "" : ": Error: \(error!)") 149 | return "Disconnected\(errorString)" 150 | case .connecting(let attempt, let maximumAttempts): 151 | return "Connecting: Attempt \(attempt) of \(maximumAttempts)" 152 | case .connected(let state, _): 153 | return "Connected: \(state.description)" 154 | } 155 | } 156 | } 157 | extension BotState.ConnectedState: CustomStringConvertible { 158 | var description: String { 159 | let strings = ["Hello", "Data"] 160 | let values: [BotState.ConnectedState] = [.Hello, .Data] 161 | 162 | return values 163 | .enumerated() 164 | .flatMap { index, value in 165 | guard self.contains(value) else { return nil } 166 | return strings[index] 167 | } 168 | .joined(separator: ",") 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /Sources/Bot/Bot.swift: -------------------------------------------------------------------------------- 1 | @_exported import Config 2 | @_exported import Services 3 | @_exported import WebAPI 4 | @_exported import RTMAPI 5 | @_exported import Models 6 | @_exported import Common 7 | import Foundation 8 | 9 | /// An extensible Slack bot user that can provide custom functionality 10 | public class SlackBot { 11 | //MARK: - Private Properties 12 | fileprivate let config: Config 13 | fileprivate let server: HTTPServer 14 | fileprivate let state: BotStateMachine 15 | fileprivate let authenticator: SlackAuthenticator 16 | fileprivate var services: [SlackService] = [DataService()] 17 | 18 | //MARK: - Private Thread Operations 19 | fileprivate var serverOperation: CancellableDispatchOperation? 20 | fileprivate var rtmApiOperation: CancellableDispatchOperation? 21 | 22 | //MARK: - Internal Dependencies 23 | internal let webAPI: WebAPI 24 | internal let rtmAPI: RTMAPI 25 | 26 | //MARK: - Internal Data 27 | internal var botUser: BotUser? 28 | internal var team: Team? 29 | internal var users: [User] = [] 30 | internal var channels: [Channel] = [] 31 | internal var groups: [Group] = [] 32 | internal var ims: [IM] = [] 33 | //internal fileprivate(set) var mpims: [MPIM] = [] 34 | 35 | //MARK: - Public Properties 36 | public private(set) var storage: Storage 37 | public private(set) var http: HTTP 38 | 39 | //MARK: - Lifecycle 40 | /** 41 | Creates a new `SlackBot` instance 42 | 43 | - parameter config: The `Config` with the configuration for this instance 44 | - parameter authenticator: The `SlackAuthenticator` used to obtain a token for the bot to use 45 | - parameter storage: The `Storage` implementation used for simple key/value storage 46 | - parameter http: The `HTTP` available to `SlackService`s for making http requests 47 | - parameter webAPI: The `WebAPI` used for interaction with the Slack WebAPI 48 | - parameter rtmAPI: The `RTMAPI` used for interaction with the Slack Real-time messaging api 49 | - parameter server: The `HTTPServer` used to handle Web based interactions 50 | - parameter services: A sequence of `SlackService`s that provide this bots functionality 51 | 52 | - returns: A new `SlackBot` instance 53 | */ 54 | public required init( 55 | config: Config, 56 | authenticator: SlackAuthenticator, 57 | storage: Storage, 58 | http: HTTP, 59 | webAPI: WebAPI, 60 | rtmAPI: RTMAPI, 61 | server: HTTPServer, 62 | services: [SlackService]) { 63 | 64 | self.config = config 65 | self.authenticator = authenticator 66 | self.http = http 67 | self.server = server 68 | self.webAPI = webAPI 69 | self.rtmAPI = rtmAPI 70 | self.storage = storage 71 | self.services.append(contentsOf: services) 72 | 73 | self.state = BotStateMachine(startingWith: .disconnected(error: nil)) 74 | self.state.observe(self, transition: SlackBot.botStateTransition) 75 | 76 | self.webAPI.slackModels = self.currentSlackModelData 77 | self.rtmAPI.slackModels = self.currentSlackModelData 78 | 79 | self.bindToRTM() 80 | self.configureServer() 81 | self.configureServices() 82 | self.configureEventServices() 83 | } 84 | 85 | //MARK: - Public Functions 86 | /// Start the bot 87 | public func start() { 88 | self.startServer() 89 | self.startBot() 90 | 91 | keepAlive { 92 | let state = ( 93 | self.state.lastTransition.old, 94 | self.state.lastTransition.new 95 | ) 96 | switch state { 97 | case (.some, .disconnected): return false 98 | default: return true 99 | } 100 | } 101 | 102 | self.authenticator.disconnected() 103 | self.rtmApiOperation?.cancel() 104 | self.serverOperation?.cancel() 105 | } 106 | 107 | private func startBot() { 108 | _ = inBackground( 109 | try: { 110 | let maximumAttempts: Int = try self.config.value(for: ReconnectionAttempts.self) 111 | self.state.transition(withEvent: .connect(maximumAttempts: maximumAttempts)) 112 | }, 113 | catch: { error in 114 | self.state.transition(withEvent: .disconnect(reconnect: true, error: error)) 115 | } 116 | ) 117 | } 118 | } 119 | 120 | //MARK: - State Transitions 121 | fileprivate extension SlackBot { 122 | func botStateTransition(change: StateChange) { 123 | print("STATE: \(change)") 124 | self.rtmAPI.sendEvents = false 125 | 126 | switch change.new { 127 | case .connecting: //(attempt: <#T##Int#>, maximumAttempts: <#T##Int#>): 128 | self.obtainTokenForWebAPI { 129 | self.connectToRTM() 130 | } 131 | 132 | case .connected: //(state: <#T##BotState.ConnectedState#>, maximumReconnectionAttempts: <#T##Int#>): 133 | guard change.new.ready else { return } 134 | print("ME: \(self.botUser)") 135 | self.notifyConnected() 136 | self.rtmAPI.sendEvents = true 137 | 138 | case .disconnected(let error): 139 | self.rtmAPI.disconnect(error: error) 140 | self.notifyDisconnected(error) 141 | } 142 | } 143 | } 144 | 145 | //MARK: - Model Data 146 | extension SlackBot { 147 | public func currentSlackModelData() -> SlackModels { 148 | return ( 149 | users: self.users, 150 | channels: self.channels, 151 | groups: self.groups, 152 | ims: self.ims, 153 | team: self.team 154 | ) 155 | } 156 | public func currentBotUserAndTeam() -> (BotUser, Team) { 157 | guard 158 | let botUser = self.botUser, 159 | let team = self.team 160 | else { fatalError("Something went wrong, we should have botUser and team data at this point!") } 161 | 162 | return (botUser, team) 163 | } 164 | } 165 | 166 | //MARK: - Authentication 167 | fileprivate extension SlackBot { 168 | func obtainTokenForWebAPI(complete: @escaping () -> Void) { 169 | self.authenticator.authenticate( 170 | success: { [weak self] authentication in 171 | self?.webAPI.authentication = authentication 172 | print("AUTHENTICATION: \(authentication)") 173 | complete() 174 | }, 175 | failure: { [weak self] error in 176 | self?.state.transition(withEvent: .disconnect(reconnect: true, error: error)) 177 | } 178 | ) 179 | } 180 | } 181 | 182 | //MARK: - RTMAPI 183 | fileprivate extension SlackBot { 184 | func bindToRTM() { 185 | self.rtmAPI.onDisconnected = { [unowned self] error in 186 | self.state.transition(withEvent: .disconnect(reconnect: true, error: error)) 187 | } 188 | self.rtmAPI.onError = { [unowned self] error in 189 | self.notifyError(error) 190 | } 191 | self.rtmAPI.onEvent(hello.self) { [unowned self] in 192 | self.state.transition(withEvent: .connectionState(state: .Hello)) 193 | } 194 | } 195 | func connectToRTM() { 196 | self.rtmApiOperation?.cancel() 197 | 198 | self.rtmApiOperation = inBackground( 199 | try: { 200 | let options: [RTMStartOption] = try self.config.value(for: RTMStartOptions.self) 201 | let rtmStart = RTMStart(options: options) { [unowned self] serializedData in 202 | do { 203 | let (botUser, team, users, channels, groups, ims) = try serializedData() 204 | 205 | self.botUser = botUser 206 | self.team = team 207 | self.users = users 208 | self.channels = channels 209 | self.groups = groups 210 | self.ims = ims 211 | 212 | self.state.transition(withEvent: .connectionState(state: .Data)) 213 | 214 | } catch let error { 215 | self.state.transition(withEvent: .disconnect(reconnect: true, error: error)) 216 | } 217 | } 218 | let url = try self.webAPI.execute(rtmStart) 219 | let pingPongInterval: Double = try self.config.value(for: PingPongInterval.self) 220 | try self.rtmAPI.connect(to: url, pingPongInterval: pingPongInterval) 221 | }, 222 | catch: { error in 223 | self.state.transition(withEvent: .disconnect(reconnect: true, error: error)) 224 | } 225 | ) 226 | } 227 | } 228 | 229 | //MARK: - HTTPServer 230 | fileprivate extension SlackBot { 231 | enum Endpoint: String { 232 | case status 233 | case slashCommand 234 | case interactiveButton 235 | 236 | static var all: [Endpoint] { return [.status, .slashCommand, .interactiveButton] } 237 | 238 | var method: HTTPRequestMethod { 239 | switch self { 240 | case .status: return .get 241 | case .slashCommand: return .post 242 | case .interactiveButton: return .post 243 | } 244 | } 245 | var handler: (SlackBot) -> RouteHandler { 246 | switch self { 247 | case .status: return SlackBot.statusHandler 248 | case .slashCommand: return SlackBot.slashCommandHandler 249 | case .interactiveButton: return SlackBot.interactiveButtonHandler 250 | } 251 | } 252 | } 253 | 254 | func configureServer() { 255 | self.server.onError = { [unowned self] error in 256 | self.notifyError(error) 257 | } 258 | 259 | for endpoint in Endpoint.all { 260 | self.server.respond( 261 | to: endpoint.method, at: [endpoint.rawValue], 262 | with: self, endpoint.handler 263 | ) 264 | } 265 | } 266 | func startServer() { 267 | self.serverOperation?.cancel() 268 | self.serverOperation = inBackground(function: self.server.start) 269 | } 270 | func statusHandler(url: URL, headers: [String: String], json: [String: Any]?) throws -> HTTPServerResponse? { 271 | return nil //empty 200 272 | } 273 | func slashCommandHandler(url: URL, headers: [String: String], json: [String: Any]?) throws -> HTTPServerResponse? { 274 | guard self.state.state.ready, let json = json else { return nil } 275 | 276 | let builder = SlackModelBuilder.make(models: self.currentSlackModelData()) 277 | let slashCommand = try SlashCommand.makeModel(with: builder(json)) 278 | self.notifySlashCommand(slashCommand) 279 | 280 | return nil 281 | } 282 | func interactiveButtonHandler(url: URL, headers: [String: String], json: [String: Any]?) throws -> HTTPServerResponse? { 283 | guard 284 | self.state.state.ready, 285 | let json = json, 286 | let payload = json["payload"] as? String 287 | else { return nil } 288 | 289 | let builder = SlackModelBuilder.make(models: self.currentSlackModelData()) 290 | let packet = payload.makeDictionary() 291 | let response = try InteractiveButtonResponse.makeModel(with: builder(packet)) 292 | self.notifyInteractiveButton(response) 293 | 294 | return nil 295 | } 296 | } 297 | 298 | //MARK: - Event Propogation 299 | fileprivate extension SlackBot { 300 | func configureServices() { 301 | for service in services { 302 | service.configure(slackBot: self, webApi: self.webAPI) 303 | } 304 | 305 | } 306 | func configureEventServices() { 307 | let services = self.services.flatMap { $0 as? SlackRTMEventService } 308 | 309 | for service in services { 310 | service.configureEvents(slackBot: self, webApi: self.webAPI, dispatcher: self.rtmAPI) 311 | } 312 | } 313 | 314 | func notifyConnected() { 315 | let services = self.services.flatMap { $0 as? SlackConnectionService } 316 | 317 | let (users, channels, groups, ims, _) = self.currentSlackModelData() 318 | let (botUser, team) = self.currentBotUserAndTeam() 319 | 320 | do { 321 | for service in services { 322 | try service.connected( 323 | slackBot: self, 324 | botUser: botUser, 325 | team: team, 326 | users: users, 327 | channels: channels, 328 | groups: groups, 329 | ims: ims 330 | ) 331 | } 332 | 333 | } catch let error { 334 | self.notifyError(error) 335 | } 336 | } 337 | func notifyDisconnected(_ error: Error?) { 338 | let services = self.services.flatMap { $0 as? SlackDisconnectionService } 339 | 340 | for service in services { 341 | service.disconnected(slackBot: self, error: error) 342 | } 343 | } 344 | func notifyError(_ error: Error) { 345 | print("ERROR: \(error)") 346 | guard self.state.state.ready else { return } 347 | 348 | let services = self.services.flatMap { $0 as? SlackErrorService } 349 | 350 | for service in services { 351 | service.error(slackBot: self, error: error) 352 | } 353 | } 354 | func notifySlashCommand(_ command: SlashCommand) { 355 | guard self.state.state.ready else { return } 356 | 357 | do { 358 | let verificationToken: String = try self.config.value(for: VerificationToken.self) 359 | 360 | let services = self.services.flatMap { $0 as? SlackSlashCommandService } 361 | 362 | for service in services { 363 | let noMatch = service 364 | .slashCommands 365 | .filter { $0.with(prefix: "/") == command.command && verificationToken == command.token } 366 | .isEmpty 367 | 368 | if (!noMatch) { 369 | try service.slashCommand( 370 | slackBot: self, 371 | webApi: self.webAPI, 372 | command: command 373 | ) 374 | } 375 | } 376 | 377 | } catch let error { 378 | self.notifyError(error) 379 | } 380 | } 381 | func notifyInteractiveButton(_ response: InteractiveButtonResponse) { 382 | guard self.state.state.ready else { return } 383 | 384 | do { 385 | let verificationToken: String = try self.config.value(for: VerificationToken.self) 386 | guard verificationToken == response.token else { return } 387 | 388 | let services = self.services.flatMap { $0 as? SlackInteractiveButtonService } 389 | 390 | for service in services { 391 | try service.interactiveButton( 392 | slackBot: self, 393 | webApi: self.webAPI, 394 | response: response 395 | ) 396 | } 397 | 398 | } catch let error { 399 | self.notifyError(error) 400 | } 401 | } 402 | } 403 | -------------------------------------------------------------------------------- /Sources/Bot/RTMStartOption+ConfigValue.swift: -------------------------------------------------------------------------------- 1 | import WebAPI 2 | import Config 3 | 4 | extension RTMStartOption: ConfigValue { 5 | public static func makeConfigValue(from string: String) throws -> RTMStartOption { 6 | let pair = string.components(separatedBy: "=") 7 | 8 | guard 9 | pair.count == 2, 10 | let key = pair.first, 11 | let value = pair.last, 12 | let result = RTMStartOption(key: key, value: value) 13 | else { throw ConfigValueError.unableToConvert(value: string, to: RTMStartOption.self) } 14 | 15 | return result 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Bot/SlackAuthenticator+OAuth.swift: -------------------------------------------------------------------------------- 1 | import Config 2 | import Services 3 | import Common 4 | import Foundation 5 | 6 | //MARK: - Endpoints 7 | private enum Endpoint: String { 8 | case login 9 | case oauth 10 | } 11 | 12 | //MARK: - OAuthAuthentication 13 | /// Handles oauth authentication 14 | public final class OAuthAuthentication: SlackAuthenticator { 15 | //MARK: - Private Properties 16 | fileprivate let clientId: String 17 | fileprivate let clientSecret: String 18 | fileprivate let scopes: Set 19 | fileprivate let server: HTTPServer 20 | fileprivate let http: HTTP 21 | fileprivate let storage: Storage 22 | 23 | //MARK: - Private Mutable Properties 24 | fileprivate var state = "" 25 | fileprivate var success: ((SlackAuthentication) -> Void)? 26 | fileprivate var failure: ((Error) -> Void)? 27 | fileprivate var oauthOperation: CancellableDispatchOperation? 28 | 29 | //MARK: - Lifecycle 30 | public init(clientId: String, clientSecret: String, scopes: [String], server: HTTPServer, http: HTTP, storage: Storage) { 31 | self.clientId = clientId 32 | self.clientSecret = clientSecret 33 | self.scopes = Set(["bot"] + scopes) 34 | self.server = server 35 | self.http = http 36 | self.storage = storage 37 | 38 | self.configureServer() 39 | } 40 | public convenience init(config: Config, server: HTTPServer, http: HTTP, storage: Storage) throws { 41 | let clientId: String = try config.value(for: OAuthClientID.self) 42 | let clientSecret: String = try config.value(for: OAuthClientSecret.self) 43 | let scopes: [String] = try config.value(for: Scopes.self) 44 | 45 | self.init( 46 | clientId: clientId, 47 | clientSecret: clientSecret, 48 | scopes: scopes, 49 | server: server, 50 | http: http, 51 | storage: storage 52 | ) 53 | } 54 | 55 | //MARK: - Public 56 | public static var configItems: [ConfigItem.Type] { 57 | return [OAuthClientID.self, OAuthClientSecret.self, Scopes.self] 58 | } 59 | public func authenticate(success: @escaping (SlackAuthentication) -> Void, failure: @escaping (Error) -> Void) { 60 | self.oauthOperation?.cancel() 61 | 62 | if let authentication = self.authentication() { 63 | success(authentication) 64 | return 65 | } 66 | 67 | self.state = "\(Int.random(min: 1, max: 999999))" 68 | self.success = { [weak self] authentication in 69 | self?.reset() 70 | success(authentication) 71 | } 72 | self.failure = { [weak self] error in 73 | self?.reset() 74 | failure(error) 75 | } 76 | 77 | print("Ready to authenticate: Please visit /login") 78 | } 79 | public func disconnected() { 80 | try! self.clearAuthentication() 81 | } 82 | 83 | //MARK: - State 84 | private func reset() { 85 | self.state = "" 86 | self.success = nil 87 | self.failure = nil 88 | } 89 | } 90 | 91 | fileprivate extension OAuthAuthentication { 92 | func authentication() -> OAuthSlackAuthentication? { 93 | guard 94 | let values: [String] = self.storage.get(.in("oauth"), key: "token"), 95 | values.count == 2, 96 | let bot_access_token = values.first, 97 | let access_token = values.last 98 | else { return nil } 99 | 100 | return OAuthSlackAuthentication( 101 | bot_access_token: bot_access_token, 102 | access_token: access_token 103 | ) 104 | } 105 | 106 | func updateAuthentication(json: [String: Any]) throws -> OAuthSlackAuthentication { 107 | let bot_access_token: String = try json.value(at: ["bot", "bot_access_token"]) 108 | let access_token: String = try json.value(at: ["access_token"]) 109 | 110 | try self.storage.set(.in("oauth"), key: "token", value: [bot_access_token, access_token]) 111 | 112 | return OAuthSlackAuthentication( 113 | bot_access_token: bot_access_token, 114 | access_token: access_token 115 | ) 116 | } 117 | 118 | func clearAuthentication() throws { 119 | let value: [String]? = nil 120 | try self.storage.set(.in("oauth"), key: "token", value: value) 121 | } 122 | } 123 | 124 | //MARK: - Server 125 | fileprivate extension OAuthAuthentication { 126 | func configureServer() { 127 | self.server.respond( 128 | to: .get, at: [Endpoint.login.rawValue], 129 | with: self, OAuthAuthentication.handleLogin 130 | ) 131 | self.server.respond( 132 | to: .get, at: [Endpoint.oauth.rawValue], 133 | with: self, OAuthAuthentication.handleOAuth 134 | ) 135 | } 136 | 137 | func handleLogin(url: URL, headers: [String: String], data: [String: Any]?) throws -> HTTPServerResponse? { 138 | guard !self.state.isEmpty else { return nil } 139 | return try self.oAuthAuthorizeURL() 140 | } 141 | func handleOAuth(url: URL, headers: [String: String], data: [String: Any]?) throws -> HTTPServerResponse? { 142 | guard 143 | let data = url.query?.makeQueryParameters(), 144 | let state = data["state"], 145 | let code = data["code"], 146 | !state.isEmpty, state == self.state 147 | else { return nil } 148 | 149 | if let error = data["error"] { throw OAuthAuthenticationError.oauthError(reason: error) } 150 | 151 | self.oauthOperation = inBackground( 152 | try: { 153 | let accessUrl = try self.oAuthAccessURL(code: code) 154 | let request = HTTPRequest(method: .get, url: accessUrl) 155 | let (_, data) = try self.http.perform(with: request) 156 | let json = (data as? [String: Any]) ?? [:] 157 | 158 | let authentication = try self.updateAuthentication(json: json) 159 | 160 | self.success?(authentication) 161 | }, 162 | catch: { error in 163 | self.failure?(error) 164 | } 165 | ) 166 | 167 | return nil 168 | } 169 | } 170 | 171 | //MARK: - URLs 172 | fileprivate extension OAuthAuthentication { 173 | func oAuthAuthorizeURL() throws -> URL { 174 | var components = URLComponents(string: "https://slack.com/oauth/authorize") 175 | 176 | //because of course they are different... 177 | #if os(Linux) 178 | let separator = "%20" 179 | #else 180 | let separator = " " 181 | #endif 182 | 183 | components?.queryItems = [ 184 | URLQueryItem(name: "client_id", value: self.clientId), 185 | URLQueryItem(name: "scope", value: self.scopes.joined(separator: separator)), 186 | URLQueryItem(name: "state", value: self.state), 187 | ] 188 | 189 | guard let url = components?.url else { throw OAuthAuthenticationError.invalidURL } 190 | return url 191 | } 192 | func oAuthAccessURL(code: String) throws -> URL { 193 | var components = URLComponents(string: "https://slack.com/api/oauth.access") 194 | components?.queryItems = [ 195 | URLQueryItem(name: "client_id", value: self.clientId), 196 | URLQueryItem(name: "client_secret", value: self.clientSecret), 197 | URLQueryItem(name: "code", value: code), 198 | ] 199 | 200 | guard let url = components?.url else { throw OAuthAuthenticationError.invalidURL } 201 | return url 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /Sources/Bot/SlackAuthenticator+OAuthAuthenticator.swift: -------------------------------------------------------------------------------- 1 | import WebAPI 2 | import Common 3 | 4 | public struct OAuthSlackAuthentication: SlackAuthentication { 5 | let bot_access_token: String 6 | let access_token: String 7 | 8 | public func token(for method: T) throws -> String { 9 | if (method.requiredScopes.isEmpty) { 10 | return self.bot_access_token 11 | } 12 | return self.access_token 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Bot/SlackAuthenticator+OAuthErrors.swift: -------------------------------------------------------------------------------- 1 | 2 | /// Describes the range of possible errors that can occur when authenticating using OAuth 3 | public enum OAuthAuthenticationError: Error, CustomStringConvertible { 4 | /// A derived url was invalid 5 | case invalidURL 6 | 7 | /// An oAuth error 8 | case oauthError(reason: String) 9 | 10 | public var description: String { 11 | switch self { 12 | case .invalidURL: return "Invalid URL" 13 | case .oauthError(let reason): return "OAuth Failed: \(reason)" 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Bot/SlackAuthenticator+Token.swift: -------------------------------------------------------------------------------- 1 | import Config 2 | import Common 3 | 4 | public struct TokenSlackAuthentication: SlackAuthentication { 5 | fileprivate let token: String 6 | 7 | public func token(for method: T) throws -> String { 8 | //token based authentication automatically unlocks all WebAPI methods 9 | return token 10 | } 11 | } 12 | 13 | /// Handles direct token authentication 14 | public struct TokenAuthentication: SlackAuthenticator { 15 | //MARK: - Private 16 | private let authentication: TokenSlackAuthentication 17 | 18 | //MARK: - Lifecycle 19 | public init(token: String) { 20 | self.authentication = TokenSlackAuthentication(token: token) 21 | } 22 | public init(config: Config) throws { 23 | let token: String = try config.value(for: Token.self) 24 | self.init(token: token) 25 | } 26 | 27 | //MARK: - Public 28 | public static var configItems: [ConfigItem.Type] { 29 | return [Token.self] 30 | } 31 | public func authenticate(success: @escaping (SlackAuthentication) -> Void, failure: @escaping (Error) -> Void) { 32 | success(self.authentication) 33 | } 34 | public func disconnected() { } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Bot/SlackAuthenticator.swift: -------------------------------------------------------------------------------- 1 | import Config 2 | import WebAPI 3 | 4 | /// Abstraction representing a means for the `SlackBot` to authenticate. 5 | public protocol SlackAuthenticator { 6 | /// Config items required by this `SlackAuthenticator` 7 | static var configItems: [ConfigItem.Type] { get } 8 | 9 | /** 10 | Authenticate the `SlackBot` 11 | 12 | - parameter success: This closure fires with the `SlackAuthentication` needed for the `SlackBot` to authenticate 13 | - parameter failure: This closure fires with the reason the authentication attempt failed 14 | */ 15 | func authenticate(success: @escaping (SlackAuthentication) -> Void, failure: @escaping (Error) -> Void) 16 | 17 | /** 18 | The `SlackBot` was disconnected, this allows any cleanup to be performed 19 | */ 20 | func disconnected() 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Bot/SlackService.swift: -------------------------------------------------------------------------------- 1 | import Models 2 | import RTMAPI 3 | import WebAPI 4 | 5 | /// An empty abstraction used to provide a 'base' for each type of supported service 6 | public protocol SlackService { 7 | /** 8 | Called once during bot creation, allows service to configure itself with the provided resources 9 | 10 | - parameter slackBot: The `SlackBot` instance 11 | - parameter webApi: The current `WebAPI` that can be used to interact with Slack 12 | */ 13 | func configure(slackBot: SlackBot, webApi: WebAPI) 14 | } 15 | extension SlackService { 16 | public func configure(slackBot: SlackBot, webApi: WebAPI) { } 17 | } 18 | 19 | /// An abstraction that represents the 'connection' event 20 | public protocol SlackConnectionService: SlackService { 21 | /** 22 | Called when the bot has finished conenction to Slack and all the data is ready 23 | 24 | - parameter slackBot: The `SlackBot` instance 25 | - parameter botUser: The `BotUser` representing the `SlackBot` 26 | - parameter team: The `Team` conencted to 27 | - parameter users: The teams `User`s 28 | - parameter channels: The `Channel`s visible to the bot 29 | - parameter groups: The `Group`s the bot is in 30 | - parameter ims: The `IM`s with the bot 31 | */ 32 | func connected( 33 | slackBot: SlackBot, 34 | botUser: BotUser, 35 | team: Team, 36 | users: [User], 37 | channels: [Channel], 38 | groups: [Group], 39 | ims: [IM] 40 | ) throws 41 | } 42 | 43 | /// An abstraction that represents the 'disconnection' event 44 | public protocol SlackDisconnectionService: SlackService { 45 | /** 46 | Called when the bot disconnects 47 | 48 | - parameter slackBot: The `SlackBot` instance 49 | - parameter error: The `Error` _if_ the disconnection was a result of an error 50 | */ 51 | func disconnected(slackBot: SlackBot, error: Error?) 52 | } 53 | 54 | /// An abstraction that represents the 'error' event 55 | public protocol SlackErrorService: SlackService { 56 | /** 57 | Called when the bot encounters an error 58 | 59 | - parameter slackBot: The `SlackBot` instance 60 | - parameter error: The `Error` describing the details 61 | */ 62 | func error(slackBot: SlackBot, error: Error) 63 | } 64 | 65 | /// An abstraction that represents any `RTMAPI` event 66 | public protocol SlackRTMEventService: SlackService { 67 | /** 68 | Called once during bot creation, allows service to subscribe to the `RTMAPIEvent`s it needs 69 | 70 | - parameter slackBot: The `SlackBot` instance 71 | - parameter webApi: The current `WebAPI` that can be used to interact with Slack 72 | - parameter event: The `SlackRTMEventDispatcher` that can be used to subscribe to `RTMAPIEvent`s 73 | */ 74 | func configureEvents(slackBot: SlackBot, webApi: WebAPI, dispatcher: SlackRTMEventDispatcher) 75 | } 76 | 77 | /// An abstraction that represents a slash command handler 78 | public protocol SlackSlashCommandService: SlackService { 79 | /** 80 | The commands supported by this `SlackSlashCommandService` instance 81 | */ 82 | var slashCommands: [String] { get } 83 | 84 | /** 85 | Called when one of the registered slash commands is triggered 86 | 87 | - parameter slackBot: The `SlackBot` instance 88 | - parameter webApi: The current `WebAPI` that can be used to interact with Slack 89 | - parameter slashCommand: The `SlashCommand` with the command details 90 | */ 91 | func slashCommand(slackBot: SlackBot, webApi: WebAPI, command: SlashCommand) throws 92 | } 93 | 94 | /// An abstraction that represents a interactive button handler 95 | public protocol SlackInteractiveButtonService: SlackService { 96 | /** 97 | Called when an interactive button response is received 98 | 99 | - parameter slackBot: The `SlackBot` instance 100 | - parameter webApi: The current `WebAPI` that can be used to interact with Slack 101 | - parameter response: The `InteractiveButtonResponse` with the response details 102 | */ 103 | func interactiveButton(slackBot: SlackBot, webApi: WebAPI, response: InteractiveButtonResponse) throws 104 | } 105 | 106 | --------------------------------------------------------------------------------