diff --git a/README.md b/README.md index 9853c06..c7122ff 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # slack4s -![CI](https://github.com/laserdisc-io/slack4s/actions/workflows/ci.yaml/badge.svg) [![codecov](https://codecov.io/gh/laserdisc-io/slack4s/branch/main/graph/badge.svg?token=BEDHQ818EI)](https://codecov.io/gh/laserdisc-io/slack4s) +![CI](https://github.com/laserdisc-io/slack4s/actions/workflows/ci.yaml/badge.svg) +[![codecov](https://codecov.io/gh/laserdisc-io/slack4s/branch/main/graph/badge.svg?token=BEDHQ818EI)](https://codecov.io/gh/laserdisc-io/slack4s) +![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/laserdisc-io/slack4s) A pure functional library for easily building slack bots, built on [cats 3.x](https://typelevel.org/cats/), [http4s](https://http4s.org/) and the [Slack Java SDK](https://github.com/slackapi/java-slack-sdk/). @@ -32,7 +34,7 @@ import io.laserdisc.slack4s.slashcmd._ object MySlackBot extends IOApp.Simple { - val secret: SigningSecret = "your secret that normally should not be hardcoded, right?" + val secret: SigningSecret = "your-signing-secret" // demo purposes - please don't hardcode secrets override def run: IO[Unit] = SlashCommandBotBuilder[IO](secret).serve @@ -40,11 +42,16 @@ object MySlackBot extends IOApp.Simple { ``` -* Set your slash command's "_Request URL_" to `https://{YOUR-HOST}/slack/slashCmd` (copy that path exactly) -* If your hosting (e.g. k8s, AWS ECS) needs a health check endpoint, use `https://{YOUR-HOST}/healthCheck` -* Issue your slash command, e.g. `/mycommand woof` and you should get the default response: +Set your slash command's **Request URL** to -TODO: screenshot of default impl +``` +https://{your-base-url}/slack/slashCmd +``` + + +Issue your slash command, e.g. `/slack4s foo` and you should get the default response: + +![default response screenshot with placeholder message](https://user-images.githubusercontent.com/885049/133712091-82037415-8f72-4fcf-b1ae-4942c4f47f95.png) The builder has some more useful functions if you need to customize your deployment further: @@ -61,6 +68,12 @@ SlashCommandBotBuilder[IO](secret) .serve ``` +If your hosting (e.g. k8s, AWS ECS) needs a health check endpoint, use: + +``` +https://{your-base-url}/healthCheck +``` + ## Implementing Your Mapper Logic Create an implementation of `CommandMapper[F]` and pass it to the builder as follows e.g.: @@ -110,12 +123,8 @@ This is the description of how - _and when_ - to handle the user's request. The * `"NA"` by default, this token used in slack4s's logs when processing this particular command (useful for log filtering) -## An Example! - -Check out [ExampleSlashCommandApp](src/test/scala/examples/ExampleSlashCommandApp.scala) for a working example of a slash command app, that returns an immediate response if no parameter is provided, else queries an external API for some space news for the provided search term, and returns a formatted list of articles. - -Not only does it show how to perform effectful IO in response to a command, it shows the many convenience functions available in `io.laserdisc.slack4s.slack._` that can be used to build a Slack API [ChatPostMessageRequest](https://github.com/slackapi/java-slack-sdk/blob/main/slack-api-client/src/main/java/com/slack/api/methods/request/chat/ChatPostMessageRequest.java) in response. - -[TODO: screenshots] - +## Example +* See [src/](src/test/scala/examples/SpaceNewsExample.scala) for a working example of a slash command handler. +## Tutorial +* See [docs/tutorial.md](docs/tutorial.md) for a walkthrough of configuring and running the example. diff --git a/docs/tutorial.md b/docs/tutorial.md new file mode 100644 index 0000000..c519303 --- /dev/null +++ b/docs/tutorial.md @@ -0,0 +1,265 @@ +# Space News! +## A slack4s tutorial + +In this tutorial, we're going to configure, and run `Space News`, a slack slash command bot (source code @ [SpaceNewsExample.scala](../src/test/scala/examples/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 (see the end of this tutorial for the finished product) + +![a working command](https://user-images.githubusercontent.com/885049/133719900-9336c55a-b3d3-4900-bcf7-0547a77e6159.png) + +For a full guide on configuring slack applications, [see the slack docs](https://api.slack.com/start/distributing#single_workspace_apps). This tutorial shows just enough configuration to get the command working against a locally running service. + +## Steps + +### Setup local forwarding + +We're going to use [ngrok](https://ngrok.com/) 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. + +### Create The Slack App + +* Authenticate to your slack workspace +* Visit [the app admin page](https://api.slack.com/apps), 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` to `https://2846-173-61-91-146.ngrok.io`, with `/slack/slashCmd` suffixed (the URL we copied from earlier). + ```yaml + _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. + +### Install The Slack App + +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. + +![install application](https://user-images.githubusercontent.com/885049/133716089-8f3e8c37-a737-4119-a98c-a6ebf5120084.png) + +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`: + +![failed command](https://user-images.githubusercontent.com/885049/133716307-f0c5eadf-e78d-4e93-848e-2f3e52907a31.png) + +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 +``` + +### Run our service! + +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** + +![signing secret](https://user-images.githubusercontent.com/885049/133716713-a6ef2d27-3d11-4d1d-ad48-0bf8a9c94698.png) + +Following the instructions on the [main README](../README.md), let's create a slack bot with this secret: + +```scala +import cats.effect.{IO, IOApp} +import eu.timepit.refined.auto._ +import io.laserdisc.slack4s.slashcmd._ + +object MySlackBot extends IOApp.Simple { + + // please don't hardcode secrets, this is just a demo + val secret: SigningSecret = "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: + +![Space News with default slack4s response](https://user-images.githubusercontent.com/885049/133717409-3d851e68-57d1-4889-92d1-a0f1e6e972fc.png) + +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). + +### Implement the API call + +We're going to use a simple [http4s](https://http4s.org/) client to make the API call, and [circe](https://circe.github.io/circe/) to decode the result. + +```scala +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](https://app.slack.com/block-kit-builder/) to learn about the various layout blocks available, +as well as an interactive tool for quickly prototyping layouts. + +```scala + +// 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 -> Command[F]` that will map the +payload that slack4s decodes for you into the handler for the that input. See [README.md](../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. :warning: this is just a simple demo - Please ensure you carefully sanitize all input that +the user will pass you. + +```scala + + 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 invoking `querySpaceNews` which returns an `IO` for later evaluation. +* We then format any results we get using our `formatNewsArticle` helper +* We specify that our response is `Delayed`, meaning the `IO` 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](../src/main/scala/io/laserdisc/slack4s/slashcmd/Models.scala) on `Command` and `ResponseType` for more detail. + +### Test your mapper! + +Finally, hook your new mapper up to the builder. + +```scala + SlashCommandBotBuilder[IO](secret) + .withCommandMapper(mapper) + .serve +``` + +We now have a fully functioning slash command handler! See [SpaceNewsExample.scala](../src/test/scala/examples/SpaceNewsExample.scala) for the complete code. + +Restart your service. + +Invoke `/spacenews nasa` and after a second or two, you should see: + +![a working command](https://user-images.githubusercontent.com/885049/133719900-9336c55a-b3d3-4900-bcf7-0547a77e6159.png) + + diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml index ec06cf2..3868d00 100644 --- a/src/test/resources/logback-test.xml +++ b/src/test/resources/logback-test.xml @@ -12,12 +12,12 @@ - + - + diff --git a/src/test/scala/examples/ExampleSlashCommandApp.scala b/src/test/scala/examples/SpaceNewsExample.scala similarity index 88% rename from src/test/scala/examples/ExampleSlashCommandApp.scala rename to src/test/scala/examples/SpaceNewsExample.scala index cb4bcc4..c3e5700 100644 --- a/src/test/scala/examples/ExampleSlashCommandApp.scala +++ b/src/test/scala/examples/SpaceNewsExample.scala @@ -1,6 +1,6 @@ package examples -import cats.effect.{ IO, IOApp } +import cats.effect.{IO, IOApp} import com.slack.api.app_backend.slash_commands.payload.SlashCommandPayload import com.slack.api.model.block.LayoutBlock import eu.timepit.refined.auto._ @@ -16,16 +16,16 @@ import org.http4s.circe.CirceEntityCodec.circeEntityDecoder import java.time.Instant import scala.concurrent.ExecutionContext.global -object ExampleSlashCommandApp extends IOApp.Simple { +object SpaceNewsExample extends IOApp.Simple { - val secret: SigningSecret = "in the real world you wouldn't hardcode secrets...right?" + val secret: SigningSecret = "7e162b0fd1bf1ca4537afa4246368c2c" override def run: IO[Unit] = SlashCommandBotBuilder[IO](secret) - .withCommandMapper(testCommandMapper) + .withCommandMapper(mapper) .serve - def testCommandMapper: CommandMapper[IO] = { (payload: SlashCommandPayload) => + def mapper: CommandMapper[IO] = { (payload: SlashCommandPayload) => payload.getText.trim match { case "" => Command(