In this tutorial, we're going to configure, and run Space News
, a slack slash command bot (source code @ SpaceNewsExample.scala) against a local development service.
We'll make use of https://www.spaceflightnewsapi.net/ - a simple, open API offering a GET endpoint for querying space news articles.
We will deploy a /spacenews
slash command that:
- takes a search term argument, e.g
/spacenews nasa
- queries
GET https://api.spaceflightnewsapi.net/v3/articles?_limit=3&title_contains=nasa
- formats the results in a pretty list:
For a full guide on configuring slack applications, see the slack docs. This tutorial shows just enough configuration to get the command working against a locally running service.
We're going to use ngrok to quickly create a tunnel to the local app (which we'll start later).
Install ngrok
and run a tunnel to the default port that slack4s uses:
❯ ngrok http 8080
Session Status online
Web Interface http://127.0.0.1:4040
Forwarding http://2846-173-61-91-146.ngrok.io -> http://localhost:8080
Forwarding https://2846-173-61-91-146.ngrok.io -> http://localhost:8080
Make a note of the https Forwarding
address, https://2846-173-61-91-146.ngrok.io
, we'll need this later.
-
Authenticate to your slack workspace
-
Visit the app admin page, and click 'Create New App'
-
Click
From an app manifest
when asked how you wish to create your app- Manifests are a recently added (at the time of writing) beta feature and will save us lots of clicking around.
-
Choose your dev workspace (apps are developed in a workspace, then deployed to that or other workspaces).
-
Paste the following YAML into the editor that appears, after updating the
url
tohttps://2846-173-61-91-146.ngrok.io
, with/slack/slashCmd
suffixed (the URL we copied from earlier)._metadata: major_version: 1 minor_version: 1 display_information: name: Space News! description: Space News App background_color: "#080f06" features: bot_user: display_name: spacenews always_online: false slash_commands: - command: /spacenews url: https://2846-173-61-91-146.ngrok.io/slack/slashCmd description: Query space news by topic keywords usage_hint: nasa should_escape: false oauth_config: scopes: bot: - commands settings: org_deploy_enabled: false socket_mode_enabled: false token_rotation_enabled: false
-
Click Create on the final dialog.
-
Bookmark the page you are on now, so you can easily access this app's configuration later.
The app will exist now, but not be available until you install it to your workspace. On the app config screen, you'll find the option to install the app.
You will be prompted to accept the permission "Add shortcuts and/or slash commands that people can use" for your workspace.
Once you accept, test that the slash command is installed in your workspace by typing /spacenews foo
:
It fails because our service isn't running yet, but it does confirm that the slash command is installed!
If you look at your ngrok
terminal, you'll see the connection attempt, proving that slack is attempting to access the correct URL.
HTTP Requests
-------------
POST /slack/slashCmd 502 Bad Gateway
First, let's grab the signing secret. Visit your application configuration page, and under Basic Information
-> App Credentials
, click Show
and copy the value for Signing Secret
Following the instructions on the main README, let's create a slack bot with this secret:
import cats.effect.{IO, IOApp}
import io.laserdisc.slack4s.slashcmd.*
object MySlackBot extends IOApp.Simple {
// please don't hardcode secrets, this is just a demo
val secret: SigningSecret = SigningSecret.unsfeFrom("7e16-----redacted------68c2c")
override def run: IO[Unit] = SlashCommandBotBuilder[IO](secret).serve
}
Run the App! By default, it will bind to localhost:8080, which is what we set ngrok
up to proxy earlier.
Note: The library uses log4cats-slf4j for logging, so add something like logback to the classpath if you want log output.
2021-09-16 22:56:39,920 INFO o.h.b.c.n.NIO1SocketServerGroup [io-compute-6] Service bound to address /0:0:0:0:0:0:0:0:8080
2021-09-16 22:56:39,926 INFO o.h.b.s.BlazeServerBuilder [io-compute-6]
----------------------------------------------------------
Starting slack4s v0.0.0+21-40742d0b+20210910-2234-SNAPSHOT
----------------------------------------------------------
2021-09-16 22:56:39,945 INFO o.h.b.s.BlazeServerBuilder [io-compute-6] http4s v0.23.3 on blaze v0.15.2 started at http://[::]:8080/
Now when you try and access the bot /spacenews nasa
, you should see the default response:
If you're still getting dispatch_failed
errors:
- Ensure your slack app configuration has the correct URL defined.
- If you don't see entries appearing on your
ngrok
terminal, then requests aren't being sent from slack to the right URL. - Verify that your slack signing secret has been correctly copied (you'll see http 401 on
ngrok
, and also log errors) - Once the URL is correct, the service log output is where you'll get help (you'll need a logging implementation on your classpath).
We're going to use a simple http4s client to make the API call, and circe to decode the result.
import io.circe.generic.auto.*
import org.http4s.Method.GET
import org.http4s.Uri.unsafeFromString
import org.http4s.*
import org.http4s.blaze.client.BlazeClientBuilder
import org.http4s.circe.CirceEntityCodec.circeEntityDecoder
// our simple model for representing the result
case class SpaceNewsArticle(
title: String,
url: String,
imageUrl: String,
newsSite: String,
summary: String,
updatedAt: Instant
)
// perform a GET on the API, deocding the results into a list of our model above
def querySpaceNews(word: String): IO[List[SpaceNewsArticle]] =
BlazeClientBuilder[IO](global).resource.use { // will create a client for each invocation, but this is just a demo
_.fetchAs[List[SpaceNewsArticle]](
Request[IO](
GET,
unsafeFromString(s"https://api.spaceflightnewsapi.net/v3/articles")
.withQueryParam("_limit", "3")
.withQueryParam("title_contains", word)
)
)
}
Next, let's write a helper function for building a slack SDK LayoutBlock
for an individual SpaceNewsArticle
result.
See the official Slack Block Kit Builder to learn about the various layout blocks available, as well as an interactive tool for quickly prototyping layouts.
// helper functions for building the various block types in the slack LayoutBlock SDK
import io.laserdisc.slack4s.slack.*
def formatNewsArticle(article: SpaceNewsArticle): Seq[LayoutBlock] =
Seq(
markdownWithImgSection(
markdown = s"*<${article.url}|${article.title}>*\n${article.summary}",
imageUrl = URL.unsafeFrom(article.imageUrl),
imageAlt = s"Image for ${article.title}"
),
contextSection(
markdownElement(s"*Via ${article.newsSite}* - _last updated: ${article.updatedAt}_")
),
dividerSection
)
Now it is time to implement the CommandMapper[F]
- a type alias for SlashCommandPayload -> F[Command[F]]
. This function
maps the payload (that slack4s decodes and validates for you) into the handler for the that input. This mapping logic itself
is contained in an effect F
(so that it can be composed with other effectful behaviour, e.g. logging as you parse your commands).
See README.md for more detail.
In our case, there's no complex parsing or pattern matching. We'll just ensure that something was entered, and
blindly pass it to the API call.
def mapper: CommandMapper[IO] = { (payload: SlashCommandPayload) =>
payload.getText.trim match {
case "" =>
Command(
handler = IO.pure(slackMessage(headerSection("Please provide a search term!"))),
responseType = Immediate
)
case searchTerm =>
Command(
handler = querySpaceNews(searchTerm).map {
case Seq() =>
slackMessage(
headerSection(s"No results for: $searchTerm")
)
case articles =>
slackMessage(
headerSection(s"Space news results for: $searchTerm")
+: articles.flatMap(formatNewsArticle)
)
},
responseType = Delayed
)
}
}
Notice that:
- In the second
case
, we're invokingquerySpaceNews
which returns anIO
for later evaluation. - We then format any results we get using our
formatNewsArticle
helper - We specify that our response is
Delayed
, meaning theIO
for the result will be evaluated in a background queue, in case the API is slower than slack's limit of 3 seconds for an inline response. - See the scaladoc on
Command
andResponseType
for more detail.
Finally, hook your new mapper up to the builder.
SlashCommandBotBuilder[IO](secret)
.withCommandMapper(mapper)
.serve
We now have a fully functioning slash command handler! See SpaceNewsExample.scala for the complete code.
Restart your service.
Invoke /spacenews nasa
and after a second or two, you should see: