Wie baut man einen Slackbot mit Kotlin und Kotr

01.09.2021Raffael Schneider
Tech Chat Bot Slack Kotlin Kotr Framework Heroku Gradle How-to Tutorial Hands-on

Wir bei b-nova haben für unsere interne Kommunikation das bekannte Slack im Einsatz. Slack ist nicht nur für den direkten Austausch gut, sondern kann über eine strukturierte Administration mithilfe von Channels und Features die Arbeit koordinieren und über aktuelle Zustände informieren. So werden zum Beispiel automatisch und zentral in einer einzigen App Status-Meldungen einer laufenden CI/CD-Pipeline dargestellt oder spezifische Alerts an die entsprechenden Zielgruppen ausgelöst. Nichtsdestotrotz sind gewisse Inputs von Hand nötig und erfordern dabei nicht selten kurzfristig intensive Copy-/Pasting-Sessions. Dadurch bin ich auf die Idee gekommen diese immer wiederkehrende, repetitive Tasks mit einem Slackbot zu automatisieren. Nach einer kurzen Suche bin ich auf einen Guide gestossen, welcher mithilfe von Kotlin und Kotr einen Slackbot auf Heroku bereitstellt. Kotlin ist dafür bestens geeignet und habe dadurch kurzerhand einen Slackbot damit geschrieben. Hier zeige ich Ihnen wie das geht.

Die Anforderungen an den Bot

Der Bot ist ganz simpel gestrickt. Er stellt einen GET-Endpunkt als Webservice zur Verfügung. Sobald dieser aufgerufen wird, wird eine Nachricht in einem definierten Channel auf Slack ausgelöst. Dieses Szenario kann man dann in einem zweiten Schritt beliebig anpassen und konfigurieren. Hierbei geht es in erster Linie wie man ein solchen Bot in mit einem zeitgenössischen Framework exponiert und wie dieser Bot als App so einfach wie möglich in einem Cloud-Dienst bereitstellt.

In unserem Fall haben wir uns für Ktor, dem von JetBrains entwickelten Kotlin-Framework und Heroku als den App-Provider entschieden. Mit Kotlin kann wie gewohnt mit der JVM-Umgebung arbeiten und doch lesbaren und _maintain_baren Code schreiben. Heroku ist ein beliebter Provider welche eine Vielzahl von Sprachen und Tools unterstützt und womit man mit Minimalaufwand eine Applikation launchen kann.

Der Stack

  • Slack

  • Kotlin

  • Kotr Framework

  • Gradle w/ Kotlin DSL

  • Gradle Shadow für FatJar

  • Heroku

Setup

Für die Entwicklung nutzen wir ausschliesslich JetBrains IntelliJ. Stellen Sie auch sicher, dass Sie die neuste Version der IDE installiert haben. Kotlin und Kotr werden beide Default-mässig, als hausinterne JetBrains-Produkte, mit der IntelliJ-IDE mitausgeliefert und erfordern mit den neueren Versionen der IDE kein zusätzliche Installation mehr.

Erstellen des Kotr-Projekts

Im New Project-Fenster, Ktor als Framework wählen. Hier verwenden wir die Version 1.6.2 des Frameworks.

Im zweiten Fenster der Projekterstellung, sollten Sie noch das GSON-Feature als Plugin dem Projekt hinzufügen. Einfach danach suchen und hinzufügen.

Sobald das Projekt erstellt ist, führen wir ein gradle run aus, um das Vanilla-Projekt versuchsweise zu starten. Kotr setzt Default-mässig einen Server-Endpunkt auf dem Port 8080.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
❯ gradle run

Welcome to Gradle 7.1.1!

Here are the highlights of this release:
 - Faster incremental Java compilation
 - Easier source set configuration in the Kotlin DSL

For more details see https://docs.gradle.org/7.1.1/release-notes.html

Starting a Gradle Daemon (subsequent builds will be faster)

> Task :run
2021-08-02 08:17:38.388 [main] INFO  ktor.application - Autoreload is disabled because the development mode is off.
2021-08-02 08:17:38.510 [main] INFO  ktor.application - Responding at http://0.0.0.0:8080
2021-08-02 08:17:38.510 [main] INFO  ktor.application - Application started in 0.14 seconds.
<==========---> 80% EXECUTING [46s]
> :run

Initial kann das etwas dauern da noch eine DSL downloaded wird und der Gradle-Daemon gestartet werden muss. Beim Aufruf von http://0.0.0.0:8080 sollte ein Hello World! ausgegeben werden.

Perfekt! Ktor läuft und liefert uns ein nettes Hello World! als Standard-Ausgabewert. Soweit so gut. Jetzt können wir uns darum kümmern dass Slack unsere Applikation als App erkennen kann.

Erstellen der Slack-App

Um Slack wissen zu lassen, dass wir eine App mit der Slack-API verbinden möchten, müssen wir zuerst eine App über Slack deklarieren. Dazu müssen Sie sich mit ihrem Slack-Profil in ihrem Workspace der Wahl anmelden. Es sei angemerkt: dies geht ausschliesslich über den Browser und nicht etwa über die Slack-App.

Erstellen Sie die App per Create an app wie folgt:

Wir wählen hier From scratch an und kommen in zweiten Erstellungschritt zur Auswahl für den App-Namen sowie dem Ziel-Workspace. In unserem Fall wählten wir TechUp Slack Bot als Name und unseren hausinternea b-nova-Workspace. Bestätigen Sie mit dem grünen Button Create App.

Jetzt ist die Slack-App erstellt. Wir müssen aber noch ein paar Konfigurationen an der App vornehmen. Zuerst erstellen wir notwendige OAuth-Scopes welche definieren was unsere App darf und nicht darf. In unserem Fall wollen wir sicherstellen, dass die App Nachrichten in Slack schreiben darf. Dazu fügen wir unter dem Tab OAuth & Permissions > Scopes einen Scope per Add an OAuth Scope hinzu.

Der gewünsche Scope ist chat:write. Einfach anwählen und anklicken.

Jetzt müssen wir noch den gewünschten Ziel-Workspace installieren, bzw. den Token für die App generieren lassen. Dazu den grünen Install to Workspace-Button unter OAuth & Permissions > OAuth Tokens for Your Workspace anwählen.

Sie werden im Anschluss aufgefordert die Permissions bezüglich Send messages zu erlauben. Grünen Allow-Button anwählen. Klar.

Sehr gut. Jetzt sollte alles passen. Der Token sollte jetzt auch generiert worden sein. Diesen Token brauchen wir sodass sich die App beim Zugriff auf Slack und dessen API identifizieren kann. Bitte diesen Token irgendwo in ihre Zwischenlage kopieren.

Mit dem OAuth-Token für Slack im Gepäch bauen wir unsere Ktor-App soweit aus, dass wir eine Nachricht in unseren gewünschten Slack-Channel schreiben können.

Slackbot lernt zu schreiben

Wir haben bereits eine main-Funktion in unserer Application.kt-Klasse. Diese erweitern wir jetzt um eine Slack-Schreibfunktionalität.

Die src/-Directory sollte wie folgt aussehen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
|-- src
|   |-- main
|   |   |-- kotlin
|   |   |   `-- com
|   |   |       `-- bnova
|   |   |           |-- Application.kt
|   |   |           |-- HomeRoute.kt
|   |   |           `-- plugins
|   |   |               |-- Routing.kt
|   |   |               `-- Serialization.kt
|   |   `-- resources
|   |       `-- logback.xml

Die Application.kt ist unser Einstiegspunkt in die App. Diese stellt einen Server über 0.0.0.0 bereit. Wichtig hier ist, dass der Port aus den Environment-Variablen herausgelesen wird, da auf Heroku der Port nicht immer zwingend 8080 sein wird. Dies geht ganz einfach wie hier auf Zeile 9 gezeigt ist:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package com.bnova

import com.bnova.plugins.configureSerialization
import com.bnova.plugins.module
import io.ktor.server.engine.*
import io.ktor.server.netty.*

fun main() {
    val port = System.getenv("PORT").toIntOrNull() ?: 8080
    embeddedServer(Netty, port, host = "0.0.0.0") {
        module()
        configureSerialization()
    }.start(wait = true)
}

Die module()-Funktion wird in unserer Routing.kt deklariert. Diese wiederrum ruft für das Routing die homeRoute()-Funktion auf.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package com.bnova.plugins

import com.bnova.homeRoute
import io.ktor.application.*
import io.ktor.routing.*

fun Application.module(testing: Boolean = false) {
    routing {
        homeRoute()
    }
}

Die homeRoute()-Funktion übernimmt die eigentliche Funktionalität des Schreibens der Hello b-nova! 👋-Nachricht. Der gewünschte Ziel-Channel ist hier #sandbox und ist ein privater Channel den ich zu Testzwecken erstellt hatte. Wichtig hier ist auch das Auslesen des Tokens aus den Environment-Variablen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package com.bnova

import com.slack.api.Slack
import io.ktor.application.*
import io.ktor.response.*
import io.ktor.routing.*

fun Routing.homeRoute() {
    get("/") {
        val token = System.getenv("SLACK_TOKEN")
        val slack = Slack.getInstance()
        val response = slack.methods(token).chatPostMessage {
            it.channel("#sandbox")
                .text("Hello b-nova! :wave:")
        }

        call.respondText("Response is: $response")
    }
}

Jetzt müssen wir noch die gewünschten Dependencies in unserer build.gradle.kts definieren, sowie das Shadow-Gradle-Plugin nutzen um ein FatJar beim Build generieren zu lassen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
val ktor_version: String by project
val kotlin_version: String by project
val logback_version: String by project
val slack_version: String by project

plugins {
    application
    kotlin("jvm") version "1.5.21"
    id("com.github.johnrengelman.shadow") version "7.0.0"
}

group = "com.bnova"
version = "1.0.0"
application {
    mainClass.set("com.bnova.ApplicationKt")
}


repositories {
    mavenCentral()
}

dependencies {
    implementation("io.ktor:ktor-server-core:$ktor_version")
    implementation("io.ktor:ktor-gson:$ktor_version")
    implementation("io.ktor:ktor-server-netty:$ktor_version")
    implementation("ch.qos.logback:logback-classic:$logback_version")
    testImplementation("io.ktor:ktor-server-tests:$ktor_version")
    testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version")
    implementation("com.slack.api:slack-api-client:$slack_version")
    implementation("com.slack.api:slack-api-model-kotlin-extension:$slack_version")
    implementation("com.slack.api:slack-api-client-kotlin-extension:$slack_version")
}

tasks.register("stage") {
    dependsOn("shadowJar")
}

tasks {
    shadowJar {
        manifest.attributes["Main-Class"] = "com.bnova.ApplicationKt"
        archiveClassifier.set("")
    }
}

In den gradle.properties legen wir noch die gewisse Properties welche in erster Linie die Versionierung unserer Dependencies vorgibt.

1
2
3
4
5
ktor_version=1.6.2
kotlin_version=1.5.21
logback_version=1.2.3
kotlin.code.style=official
slack_version=1.9.0

Jetzt können wir ein gradle run in IntelliJ als Run-Konfiguration vornehmen. Hierzu einfach beachten, dass eine Environment-Variable für den SLACK_TOKEN gesetzt ist.

Beim Anstossen wird das Ganze auf http://0.0.0.0:8080 deployed.

1
2
3
4
> Task :run
2021-08-02 16:25:37.957 [main] INFO  ktor.application - Autoreload is disabled because the development mode is off.
2021-08-02 16:25:38.097 [main] INFO  ktor.application - Responding at http://0.0.0.0:8080
2021-08-02 16:25:38.099 [main] INFO  ktor.application - Application started in 0.158 seconds.

Beim Aufruf des http://0.0.0.0:8080-Endpunkts, sollte neben einer Response des GET-Aufrufs eine Nachricht im Ziel-Channel ausgelöst werden. Falls nicht, kann es daran liegen, dass der Bot dem Channel noch nicht hinzugefügt worden ist. In diesem Fall wird ein Fehler not_in_channel geworfen. Dies kann man ganz einfach erledigen indem man den Bot per @TechUp Slack Bot in Slack addressiert und im Anschluss durch Slack aufgefordert wird den Bot als User hinzuzufügen.

Yupie! Hat geklappt, oder?

Falls nicht, bitte einfach mit unserer TechUp-Repository auf GitHub abgleichen. Als nächstes möchten wir die Applikation auf Heroku deployen und laufen lassen, sodass sie rund um die Uhr erreichbar ist.

Heroku als App-Provider

Heroku bietet die Möglichkeit Apps schnell und unkompliziert zu deployen. Dazu müssen wir uns zuerst ein Konto erstellen und die App deklarieren.

Zuerst erstellen wir die App auf Heroku. Der Name hier ist techup-slackbot welcher in der Europe-Zone gehostet werden soll.

Danach konfigurieren wir die App wie folgt. Wichtig dabei ist der SLACK_TOCKEN.

Heroku erwartet beim Builden ein Procfile auf Projektebene. Dies soll den Pfad zu unserem FatJar enthalten.

1
web: java -jar ./build/libs/techup-slackbot-1.0.0.jar

Wir werden zuerst Heroku lokal laufen lassen, um zu testen ob Heroku unsere App auch richtig buildet. Das hat den Vorteil dass es viel schneller geht und dass keine Buildtime auf dem Heroku-Server anfällt. Hierzu müssen wir in einer .env-File noch die Environemnt-Variablen definieren.

1
2
SLACK_TOKEN=xoxb-xxxxx-xxxxx-xxxxx
PORT=8080

Mit der Heroku-CLI kann man jetzt bequem einen Login vornehmen und im Anschluss das Ganze lokal bauen und testen lassen.

1
2
❯ heroku login
❯ heroku git:remote -a techup-slackbot

Das Deployment nach Heroku wird ganz einfach mit einem Push der Git-Repository angestossen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
❯ git push heroku main
Enumerating objects: 219, done.
Counting objects: 100% (219/219), done.
Delta compression using up to 12 threads
Compressing objects: 100% (161/161), done.
Writing objects: 100% (219/219), 32.26 MiB | 10.33 MiB/s, done.
Total 219 (delta 49), reused 0 (delta 0)
remote: Compressing source files... done.
remote: Building source:
remote: 
remote: -----> Building on the Heroku-20 stack
remote: -----> Determining which buildpack to use for this app
remote: -----> Gradle app detected
remote: -----> Installing JDK 1.8... done
remote: -----> Building Gradle app...
remote: -----> executing ./gradlew stage
remote:        Downloading https://services.gradle.org/distributions/gradle-7.1-bin.zip
remote:        ..........10%...........20%...........30%..........40%...........50%...........60%..........70%...........80%...........90%...........100%
remote:        To honour the JVM settings for this build a single-use Daemon process will be forked. See https://docs.gradle.org/7.1/userguide/gradle_daemon.html#sec:disabling_the_daemon.
remote:        Daemon will be stopped at the end of the build 
remote:        
remote:        > Task :compileKotlin
remote:        w: /tmp/build_d1e282f8/src/main/kotlin/com/bnova/plugins/Routing.kt: (17, 24): Parameter 'testing' is never used
remote:        
remote:        > Task :compileJava NO-SOURCE
remote:        > Task :processResources
remote:        > Task :classes
remote:        > Task :shadowJar
remote:        > Task :stage
remote:        
remote:        Deprecated Gradle features were used in this build, making it incompatible with Gradle 8.0.
remote:        
remote:        You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.
remote:        
remote:        See https://docs.gradle.org/7.1/userguide/command_line_interface.html#sec:command_line_warnings
remote:        
remote:        BUILD SUCCESSFUL in 2m 2s
remote:        3 actionable tasks: 3 executed
remote: -----> Discovering process types
remote:        Procfile declares types -> web
remote: 
remote: -----> Compressing...
remote:        Done: 67.9M
remote: -----> Launching...
remote:        Released v4
remote:        https://techup-slackbot.herokuapp.com/ deployed to Heroku
remote: 
remote: Verifying deploy... done.
To https://git.heroku.com/techup-slackbot.git
 * [new branch]      main -> main

Sobald dies erfolgt, sollte die App auf Heroku deployed und bereits am laufen sein. Beim Aufruf der App unter https://{app-name}.herokuapp.com/ oder direkt über den Open App-Button

wird die Slack-Nachricht nochmals ausgelöst

Glückwunsch ! Sie haben soeben ihren ersten Slackbot mit Kotlin/Ktor und Heroku gebaut! 🥳

Fazit

Mit dem gewählten Stack kann man relativ schnell und bequem einen Slackbot schreiben. Dabei wirken Kotlin, Kotr und Gradle allesamt sehr modern und bringen die Applikation auf das nächste Level. Wir bei b-nova haben mittlerweile mehrerer solcher Bots im Einsatz und werden diese bei Bedarf weiter ausbauen können. Bauen auch Sie einen Slackbot und lernen Sie die Vorteile des Deployments auf Heroku kennen. Stay tuned! 📻

https://plusmobileapps.com/2020/10/09/ktor-slackbot-heroku.html

https://devcenter.heroku.com/articles/heroku-cli

https://blog.heroku.com/rise-of-kotlin

https://github.com/chatbot-workshop