Skip to content

Instantly share code, notes, and snippets.

@4np
Last active April 26, 2018 15:47
Show Gist options
  • Save 4np/02681db80b5d02e0c0129e59b4b37554 to your computer and use it in GitHub Desktop.
Save 4np/02681db80b5d02e0c0129e59b4b37554 to your computer and use it in GitHub Desktop.
Vapor SlackBot POC

Package.swift:

// swift-tools-version:4.0
import PackageDescription

let package = Package(
    name: "SlackBot",
    dependencies: [
        // 💧 A server-side Swift web framework.
        .package(url: "https://github.com/vapor/vapor.git", from: "3.0.0-rc"),
        // 🔵 Swift ORM (queries, models, relations, etc) built on SQLite 3.
        .package(url: "https://github.com/vapor/fluent-sqlite.git", from: "3.0.0-rc"),
        // Websockets
        .package(url: "https://github.com/vapor/websocket.git", from: "1.0.0"),
        // Jobs / Timers
        .package(url: "https://github.com/BrettRToomey/Jobs.git", from: "1.1.1")
    ],
    targets: [
        .target(name: "App", dependencies: ["FluentSQLite", "WebSocket", "Jobs", "Vapor"]),
        .target(name: "Run", dependencies: ["App"]),
        .testTarget(name: "AppTests", dependencies: ["App"])
    ]
)

main.swift:

import App
import Service
import Vapor
import Foundation
import Jobs

// Add your bot token here:
let slackBotToken = "xoxb-....YOUR-TOKEN...."

// see https://api.slack.com/methods/rtm.start
// see https://api.slack.com/methods/rtm.connect
public struct SlackDetails: Codable {
    var ok: Bool
    var url: URL
    
    enum CodingKeys: String, CodingKey {
        case ok
        case url
   }
}

public enum SlackNotificationType: String, Codable {
    case hello = "hello"
    case message = "message"
    case ping = "ping"
    case pong = "pong"
}

public struct SlackNotification: Codable {
    var type: SlackNotificationType
}

public struct SlackMessage: Codable {
    var id: Int64?
    var type: SlackNotificationType
    var channel: String?
    var user: String?
    var sourceTeam: String?
    var team: String?
    var text: String?
    
    enum CodingKeys: String, CodingKey {
        case id
        case type
        case channel
        case user
        case team
        case text
    }
    
    init(id: Int64, type: SlackNotificationType) {
        self.id = id
        self.type = type
    }
    
    mutating func stripDetails() {
        user = nil
        sourceTeam = nil
        team = nil
        text = nil
    }
}

public final class MessageFactory {
    public static let shared = MessageFactory()
    private var identifier: Int64 = -1
    
    private var id: Int64 {
        get {
            self.identifier += 1
            return identifier
        }
    }
    
    private init() {
        print("initialized message factory")
    }
    
    public func response(for message: SlackMessage) -> SlackMessage {
        var response = message
        response.id = id
        response.stripDetails()
        if let user = message.user {
            response.text = "Hello <@\(user)>"
        } else if let text = message.text {
            response.text = String(text.reversed())
        } else {
            response.text = "Hi!"
        }
        return response
    }
    
    public func ping() -> SlackMessage {
        return SlackMessage(id: id, type: .ping)
    }
}

extension WebSocket {
    public func send(message: SlackMessage, promise: Promise<Void>? = nil) {
        do {
            let encoder = JSONEncoder()
            let data = try encoder.encode(message)
            guard let json = String(bytes: data, encoding: .utf8) else { return }
            print("--> \(json) (\(data))")
            //send(text: json, promise: promise)
            send(data, promise: promise)
        } catch {
            print("ERROR: \(error.localizedDescription)")
        }
    }
}

// The contents of main are wrapped in a do/catch block because any errors that get raised to the top level will crash Xcode
do {
    var config = Config.default()
    var env = try Environment.detect()
    var services = Services.default()

    try App.configure(&config, &env, &services)

    let app = try Application(
        config: config,
        environment: env,
        services: services
    )

    try App.boot(app)

    // Create websocket worker
    let worker = MultiThreadedEventLoopGroup(numThreads: 1)
    
    // Whether or not we're connected
    var connected = false

    // Get the websocket url from Slack
    var connectHeaders = HTTPHeaders()
    connectHeaders.add(name: .accept, value: "application/json")
    let connectURL = "https://slack.com/api/rtm.connect?token=\(slackBotToken)"
    let details = try app.make(Client.self).get(connectURL, headers: connectHeaders).map(to: SlackDetails.self) { (response) -> SlackDetails in
        guard let data = response.http.body.data else {
            fatalError("No response")
        }
        let decoder = JSONDecoder()
        return try decoder.decode(SlackDetails.self, from: data)
    }.wait()

    print("ws url: \(details.url)")

    // Setup the RTM Websocket
    let ws = try HTTPClient.webSocket(scheme: .wss, hostname: details.url.host!, port: details.url.port, path: details.url.path, on: worker).wait()
//    let promise = worker.eventLoop.newPromise(Void.self)
    print("Established Real Time Messaging (RTM) websocket connection")
    ws.onError { (ws, error) in
        print("error: \(error.localizedDescription)")
    }
//    ws.onBinary { (ws, data) in
//        print("<-- \(data)")
//    }
    ws.onText { (ws, text) in
        print("<-- \(text)")
//        promise.succeed(result: text)
        let data = text.convertToData()
        let decoder = JSONDecoder()
        do {
            let notification = try decoder.decode(SlackNotification.self, from: data)

            switch notification.type {
            case .hello:
                print("Connected")
                connected = true
                break
//            case .ping:
//                ws.send(message: MessageFactory.shared.pong())
            case .pong:
                print("<-- PONG!")
                break
            case .message:
                let message = try decoder.decode(SlackMessage.self, from: data)
                print("<-- \(text)")
                ws.send(message: MessageFactory.shared.response(for: message))
                //ws.send(message: message, promise: promise)
                break
            default:
                break
            }
        } catch {
            // unsupported notification type
            //print("error: \(error.localizedDescription)")
            //print("json: \(text)")
        }
    }

    Jobs.add(interval: .seconds(10)) {
        //print("ws closed: \(ws.isClosed), connected: \(connected)")
        guard !ws.isClosed, connected else { return }

        // send keepalive ping
        ws.send(message: MessageFactory.shared.ping())
    }

    try ws.onClose.always {
        connected = false
    }.wait()

    print("WebSocket connection closed, is closed: \(ws.isClosed)")

    try app.run()
} catch {
    print(error)
    exit(1)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment