├── .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 | 
5 | 
6 | 
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 |
--------------------------------------------------------------------------------