ElixirでSlack botを作って楽しむ (2021/12/09)

https://qiita.com/advent-calendar/2021/slack

**2021/12/09** の回です。
前日は、@75asaさんによる「[Block Kit でゲシュタルト崩壊しないために JSX でブロックを記述する](https://zenn.dev/75asa/articles/slack-block-kit-with-jsx-slack)」でした。

> ゲシュタルト崩壊（ゲシュタルトほうかい、独: Gestaltzerfall）とは、知覚における現象のひとつ。 全体性を持ったまとまりのある構造（Gestalt, 形態）から全体性が失われてしまい、個々の構成部分にバラバラに切り離して認識し直されてしまう現象をいう。幾何学図形、文字、顔など、視覚的なものがよく知られているが、聴覚や皮膚感覚、味覚、嗅覚においても生じうる。

(https://ja.wikipedia.org/wiki/%E3%82%B2%E3%82%B7%E3%83%A5%E3%82%BF%E3%83%AB%E3%83%88%E5%B4%A9%E5%A3%8A)

**JSON で冗長な Block Kit に辟易している方はぜひお試しを！**とのまとめです:rocket:

---

# はじめに
- [Slack](https://slack.com/intl/ja-jp/)を楽しんでいますか:bangbang::bangbang::bangbang:
- [Elixir](https://elixir-lang.org/)を楽しんでいますか:bangbang::bangbang::bangbang:

# ボット:robot:をつくります
仕様は以下の通りです。

- チャンネルに新しいユーザが入ってきたら、歓迎メッセージを送る
- チャンネルからユーザが退出したら泣く、悲しむ :sob:
- ボット宛にメッセージが飛んできたらオウム返しする
- ボット宛に`"ping"`メッセージが飛んできたら、`"pong"`を返す
- [Elixir](https://elixir-lang.org/)でつくる

![スクリーンショット 2021-11-11 8.31.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131808/27efa02b-e685-6801-a5fd-24ffb801fc00.png)

![スクリーンショット 2021-11-11 8.31.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131808/376f2a9b-11c5-f5ac-ebea-4081aa5f85e3.png)





# [Bolt for Python](https://github.com/SlackAPI/bolt-python)
[Bolt for Python](https://github.com/SlackAPI/bolt-python)で作るとこんな感じですぐにできあがります。

```python:app.py
import os
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler

# ボットトークンとソケットモードハンドラーを使ってアプリを初期化します
app = App(token=os.environ.get("SLACK_BOT_TOKEN"))

# 'hello' を含むメッセージをリッスンします
# 指定可能なリスナーのメソッド引数の一覧は以下のモジュールドキュメントを参考にしてください：
# https://slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html
@app.message("hello")
def message_hello(message, say):
    # イベントがトリガーされたチャンネルへ say() でメッセージを送信します
    say(f"Hey there <@{message['user']}>!")

@app.event("app_mention")
def handle_app_mention_events(body, logger, say):
    logger.info(body)
    bot_user_id = body['authorizations'][0]['user_id']
    text = body['event']['text']
    user = body['event']['user']

    text = text.replace(f"<@{bot_user_id}>", "").strip()
    if text == "ping":
        say(f"<@{user}> pong :robot_face:")
    else:
        say(f"<@{user}> {text} (to parrot :parrot:)")

@app.event("member_joined_channel")
def handle_member_joined_channel_events(body, logger, say):
    logger.info(body)
    user = body['event']['user']

    msg = f"<@{user}> Welcome! We are the alchemists, my friends!\n(https://github.com/TORIFUKUKaiou/slack_doorman)"
    say(msg)

@app.event("member_left_channel")
def handle_member_left_channel_events(body, logger, say):
    logger.info(body)

    user = body['event']['user']
    say(f"<@{user}> left :sob:")

# アプリを起動します
if __name__ == "__main__":
    SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start()
```

詳しくは、「[Bolt 入門ガイド](https://slack.dev/bolt-python/ja-jp/tutorial/getting-started)」をご参照ください。
まず公式のSDKを使って作ることの自信を得ました。
ガイドの通りにまずはイゴかし[^1]てみることをオススメします。

[^1]: 動かすの意。たぶん西日本の方言、おそらく。[NervesJP](https://nerves-jp.connpass.com/)ではおなじみの表現。少し古いオートレースの映像ですが、実況アナウンサーが「針[^3]イゴきます」とはっきり言っています。https://autorace.jp/netstadium/SearchMovie/Movie/iizuka?date=2017-01-04&p=5&race_number=11&pg=

[^3]: 大時計の針のこと。針がイゴいてある地点まで到達すると選手はスタートを切って良い発走の合図。針がイゴきはじめると(おそらく)選手は緊張するし、スタートはその後のレース展開に大きく影響するので、車券を握りしめている観客たちがもっとも緊張する瞬間であるため、先の尖った鋭いものを連想させる針は緊張の暗喩としても言い得て妙。

## Tips

```python:app.py
import os
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler

# ボットトークンとソケットモードハンドラーを使ってアプリを初期化します
app = App(token=os.environ.get("SLACK_BOT_TOKEN"))

# アプリを起動します
if __name__ == "__main__":
    SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start()
```

この雛形がとても役にたちます。
たとえば、Slackアプリの設定で、**Event Subscriptions**にて、"[app_mention](https://api.slack.com/events/app_mention)"を購読したとします。

そうして

```
$ python3 app.py
⚡️ Bolt app is running!
```

と起動しておくわけですよ。
そこからボットを参加させたチャンネルにて、`@first-bolt-app hello`なんて話かけるわけです。
雛形にはメンション宛に飛んできたメッセージを処理する関数を書いていないからなんにも反応できないのですけれどもログのほうに

```
Unhandled request ({'type': 'event_callback', 'event': {'type': 'app_mention'}})
---
[Suggestion] You can handle this type of event with the following listener function:

@app.event("app_mention")
def handle_app_mention_events(body, logger):
    logger.info(body)

Unhandled request ({'type': 'event_callback', 'event': {'type': 'app_mention'}})
```

と、ばっちり**[Suggestion]**をいただけてこれを組み込めばいいわけですよ。
関数の引数は
https://slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html
に書いてあるものが使えます。
すばらしい :thumbsup::thumbsup_tone1::thumbsup_tone2::thumbsup_tone3::thumbsup_tone4::thumbsup_tone5: 



## Contribution
ちなみにいくつか日本語訳の誤りをみつけたのでプルリクをだしてみたところ、マージしていただけました :tada:
ありがとうございます :pray::pray_tone1::pray_tone2::pray_tone3::pray_tone4::pray_tone5:

- https://github.com/slackapi/bolt-python/pull/507
- https://github.com/slackapi/bolt-python/pull/508
- https://github.com/slackapi/bolt-python/pull/519

何がすごかったかって、マージされたとおもったらもう次の瞬間には、日本語訳のページに反映されていました:fire::rocket::rocket::rocket:
世界を相手にしているサービスのスピード感に驚きました！
<font color="purple">$\huge{Awesome!!!}$</font>

# [Elixir](https://elixir-lang.org/)で書く

ここからは[Elixir](https://elixir-lang.org/)で書きます。
HTTP通信で作ります。
WebSocket(ソケットモード)は、[WebSockex](https://github.com/Azolo/websockex)を使うことでなんとなくは動いたのですが、再接続あたりがうまくいかず一旦置くことにしました。
これはこれでうまくいったところ、うまくいかなかったところをまとめたいとおもってはいます[^2]。

[^2]: おもっています。あくまでもおもっています。

ここではできたことを景気よく書いておきたいとおもいます :tada::tada::tada:

elixir          1.12.3-otp-24
erlang          24.1.4
を使っています。

## 大まかな仕組み(HTTP)
1. Slackアプリの設定で購読したいEventを申し出ておく(設定)
1. Slackで該当の操作(bot宛のメッセージや新しいメンバーの入場)が行われる
1. Slackから`/slack/events`宛にHTTP POSTがボット(アプリ)に飛んでくる(https)
1. 飛んできたEventをボット(アプリ)は本当にSlackが送ってきたものかどうかを確かめる
1. あとは、ボット(アプリ)のほうで煮るなり焼くなり好きにする

### ドキュメント
- https://api.slack.com/apis/connections/events-api
- https://api.slack.com/authentication/verifying-requests-from-slack
- https://api.slack.com/methods/chat.postMessage

公式のSDKなり、非公式のライブラリなりを使わない場合はこのへんのことを理解して実装をする必要があります。
この記事では、Pureな[Phoenix](https://www.phoenixframework.org/)プロジェクトを新規作成して実装を進めてみました。

## 完成品
https://github.com/TORIFUKUKaiou/slack_doorman

## どこでイゴいている[^1]の?

[elixir.jp](https://join.slack.com/t/elixirjp/shared_invite/zt-ae8m5bad-WW69GH1w4iuafm1tKNgd~w) Slack workspaceの`#autoracex`チャンネルに**aweseome-bot**:robot:君がいます。

https://autoracex.connpass.com/

[Elixir](https://elixir-lang.org/)の純粋なもくもく会です。


## [Phoenix](https://www.phoenixframework.org/)インストール

https://hexdocs.pm/phoenix/installation.html#content

上記をご参照ください。
完成品ではPostgreSQLなどのデータベースは使用していません。

## プロジェクトのnew

```bash
$ mix phx.new slack_doorman --no-ecto

$ cd slack_doorman
```
 
### HTTPoisonの追加

```diff:mix.exs
-      {:plug_cowboy, "~> 2.5"}
+      {:plug_cowboy, "~> 2.5"},
+      {:httpoison, "~> 1.8"}
     ]
```

```bash
$ mix setup
```

## ソースコードを書く

### [Verifying requests from Slack](https://api.slack.com/authentication/verifying-requests-from-slack)

リンク先の通りに実装すると、本当にSlackから送られてきたHTTP POSTであることを確かめることができます。
SlackからボットにHTTP Postが送られてくる時に、`"v0:{timestamp}:{body}"`をSlackとボットの作成者しか知り得ない**Signing Secret**で署名して、`X-Slack-Signature`にセットして送ってくれます。
ボット側では、`"v0:{timestamp}:{body}"`を組み立て**Signing Secret**でハッシュを計算してちょっとゴニョって、ヘッダの`X-Slack-Signature`と一致していればSlackから送られてきたものであると判断するわけです。
以下、[Elixir](https://elixir-lang.org/)での実装です。
一点注意点としては、`:crypto.mac/4`関数はOTP-24以上です。OTP-23では`:crypto.hmac/3`関数を使うと同じように実装できるはずです。

```elixir:lib/slack_doorman/slack.ex
defmodule SlackDoorman.Slack do
  require Logger

  def validate_request(conn) do
    # https://api.slack.com/authentication/verifying-requests-from-slack
    Logger.info("validate_request")

    timestamp =
      conn.req_headers
      |> Enum.find(fn {key, _} -> key == "x-slack-request-timestamp" end)
      |> elem(1)
      |> tap(&Logger.info/1)
      |> String.to_integer()

    request_body = conn.assigns.raw_body |> Enum.at(0) |> tap(&Logger.info/1)

    slack_signature =
      conn.req_headers
      |> Enum.find(fn {key, _} -> key == "x-slack-signature" end)
      |> elem(1)
      |> tap(&Logger.info/1)

    validate_request(timestamp, request_body, slack_signature)
  end

  defp validate_request(timestamp, request_body, slack_signature) do
    DateTime.diff(DateTime.now!("Etc/UTC"), DateTime.from_unix!(timestamp))
    |> validate_request(timestamp, request_body, slack_signature)
  end

  defp validate_request(diff, _timestamp, _request_body, _slack_signature)
       when abs(diff) > 5 * 60 do
    false
  end

  defp validate_request(_diff, timestamp, request_body, slack_signature) do
    slack_signing_secret = System.get_env("SLACK_SIGNING_SECRET")

    sig_basestring = "v0:" <> Integer.to_string(timestamp) <> ":" <> request_body

    my_signature =
      :crypto.mac(:hmac, :sha256, slack_signing_secret, sig_basestring)
      |> Base.encode16()
      |> String.downcase()

    my_signature = "v0=" <> my_signature

    my_signature == slack_signature
  end
end
```

自分で書いたかと言われると、ググり力がものを言うわけでして、以下のページをとても参考にしました。

https://stackoverflow.com/a/43810424


## raw bodyの取り出し

```elixir:lib/slack_doorman_web/cache_body_reader.ex
defmodule SlackDoormanWeb.CacheBodyReader do
  def read_body(conn, opts) do
    {:ok, body, conn} = Plug.Conn.read_body(conn, opts)
    conn = update_in(conn.assigns[:raw_body], &[body | &1 || []])
    {:ok, body, conn}
  end
end
```

```diff:lib/slack_doorman_web/endpoint.ex
  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
+   body_reader: {SlackDoormanWeb.CacheBodyReader, :read_body, []},
    json_decoder: Phoenix.json_library()

  plug Plug.MethodOverride
```

https://github.com/phoenixframework/phoenix/issues/459#issuecomment-440820663

https://github.com/elixir-plug/plug/blob/v1.12.1/lib/plug/parsers.ex#L179-L200

Thanks!

## [chat.postMessage](https://api.slack.com/methods/chat.postMessage)

```elixir:lib/slack_doorman/slack/api.ex
defmodule SlackDoorman.Slack.Api do
  def post_message(json) do
    # https://api.slack.com/methods/chat.postMessage
    url = "https://slack.com/api/chat.postMessage"

    headers = [
      "Content-type": "application/json",
      Authorization: "Bearer #{slack_bot_token()}"
    ]

    {:ok, _response} = HTTPoison.post(url, json, headers)
  end

  defp slack_bot_token do
    System.get_env("SLACK_BOT_TOKEN")
  end
end
```

## [Using the Slack Events API](https://api.slack.com/apis/connections/events-api)

SlackからHTTP Postで投げ込まれるEventを処理します。
ボットの振る舞いは、`SlackDoorman.Handler.handle_event/1`を別プロセスで動作させることにしました。


```elixir:lib/slack_doorman_web/controllers/event_controller.ex
defmodule SlackDoormanWeb.EventController do
  use SlackDoormanWeb, :controller
  require Logger

  def create(
        conn,
        %{"challenge" => challenge, "token" => _token, "type" => "url_verification"} = params
      ) do
    Logger.info(conn)
    Logger.info(params)

    if SlackDoorman.Slack.validate_request(conn) do
      conn
      |> put_status(:ok)
      |> render("challenge.json", challenge: challenge)
    end
  end

  def create(conn, params) do
    Logger.info(conn)
    Logger.info(params)

    if SlackDoorman.Slack.validate_request(conn), do: do_something(params)

    ok(conn)
  end

  defp ok(conn) do
    conn
    |> put_status(:ok)
    |> render("ok.json", ok: :ok)
  end

  defp do_something(params) do
    Logger.info("do_something")
    spawn(fn -> SlackDoorman.Handler.handle_event(params) end)
  end
end
```

```elixir:lib/slack_doorman_web/views/event_view.ex
defmodule SlackDoormanWeb.EventView do
  use SlackDoormanWeb, :view

  def render("challenge.json", %{challenge: challenge}) do
    %{challenge: challenge}
  end

  def render("ok.json", %{ok: :ok}) do
    %{}
  end
end
```

```diff:lib/slack_doorman_web/router.ex
  # Other scopes may use custom stacks.
- # scope "/api", SlackDoormanWeb do
- #   pipe_through :api
- # end
+ scope "/slack/events", SlackDoormanWeb do
+   pipe_through :api
+
+   post "/", EventController, :create
+ end
```

## SlackDoorman.Handler

[パターンマッチング](https://elixir-lang.org/getting-started/pattern-matching.html)大活躍です。
ボットの振る舞いを書いています。

```elixir:lib/slack_doorman/handler.ex
defmodule SlackDoorman.Handler do
  require Logger

  # https://api.slack.com/events/member_joined_channel
  def handle_event(
        %{
          "event" => %{"channel" => channel, "type" => "member_joined_channel", "user" => user},
          "type" => "event_callback"
        } = params
      ) do
    Logger.info("member_joined_channel")
    Logger.info(params)

    say(
      channel,
      "<@#{user}> Welcome! We are the alchemists, my friends!\n(https://github.com/TORIFUKUKaiou/slack_doorman)"
    )
  end

  # https://api.slack.com/events/member_left_channel
  def handle_event(
        %{
          "event" => %{"channel" => channel, "type" => "member_left_channel", "user" => user},
          "type" => "event_callback"
        } = params
      ) do
    Logger.info("member_left_channel")
    Logger.info(params)

    say(
      channel,
      "<@#{user}> left :sob:"
    )
  end

  # https://api.slack.com/events/app_mention
  def handle_event(
        %{
          "event" => %{
            "channel" => channel,
            "type" => "app_mention",
            "user" => user,
            "text" => text
          },
          "authorizations" => [%{"user_id" => bot_user_id}],
          "type" => "event_callback"
        } = params
      ) do
    Logger.info("app_mention")
    Logger.info(params)

    text =
      String.replace(text, "<@#{bot_user_id}>", "")
      |> String.trim()
      |> reply()

    say(
      channel,
      "<@#{user}> #{text}"
    )
  end

  def handle_event(params) do
    Logger.info("no handle")
    Logger.info(params)
    IO.inspect(params)
  end

  defp reply("ping"), do: "pong :robot_face:"

  defp reply(text), do: "#{text} (to parrot :parrot:)"

  defp say(channel, text) do
    %{
      channel: channel,
      text: text,
      link_names: true,
      username: "awesome-bot",
      icon_url: "https://ca.slack-edge.com/TL799TXED-UL27SRN3V-ffb245030052-512"
    }
    |> Jason.encode!()
    |> SlackDoorman.Slack.Api.post_message()
  end
end
```

## Slackアプリの設定

### Event Subscriptions

![スクリーンショット 2021-11-10 23.12.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131808/12d4733c-fac0-7527-d7e0-c842755678a2.png)


### OAuth & Permissions

![スクリーンショット 2021-11-10 23.12.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131808/09a62d7a-e591-5199-718d-c7701ddb71bd.png)

## 環境変数
- Basic Information ページの署名シークレットをコピー => **SLACK_SIGNING_SECRET**
- OAuth & Permissions ページのボットトークン (xoxb) をコピー => **SLACK_BOT_TOKEN**

## Run :rocket::rocket::rocket:

```bash
$ mix phx.server
```

## https

httpsじゃないといかんとですよ。
ローカルマシンでイゴかす[^1]場合には、

https://ngrok.com/

を使うと便利です。
[ngrok](https://ngrok.com/)は、[Bolt 入門ガイド（HTTP）](https://slack.dev/bolt-python/ja-jp/tutorial/getting-started-http)で知りました。

![スクリーンショット 2021-11-10 23.24.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131808/4bd6abde-dc87-3a22-101c-4c05bbb8537c.png)


```
$ ./ngrok authtoken <token>

$ ./ngrok http 4000
ngrok by @inconshreveable                                                                                                      (Ctrl+C to quit)
                                                                                                                                               
Session Status                online                                                                                                           
Account                       TORIFUKUKaiou (Plan: Free)                                                                                       
Version                       2.3.40                                                                                                           
Region                        United States (us)                                                                                               
Web Interface                 http://127.0.0.1:4040                                                                                            
Forwarding                    https://95fa-163-49-206-28.ngrok.io -> http://localhost:4000                                                     
Forwarding                    https://95fa-163-49-206-28.ngrok.io -> http://localhost:4000 
```

こげな感じで、たとえば上の実行例ですと`https://95fa-163-49-206-28.ngrok.io`へのアクセスが、ローカルマシンの4000番(Phoenix)とつながります。
これをSlackアプリのほうに設定するとよかです。

![スクリーンショット 2021-11-10 23.20.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131808/eeb8cea9-7d27-169c-c022-300e2a30e42f.png)


# Wrapping up :lgtm::lgtm::lgtm::lgtm::lgtm:
- [Elixir](https://elixir-lang.org/)を使って、[Bolt 入門ガイド（HTTP）](https://slack.dev/bolt-python/ja-jp/tutorial/getting-started-http) 相当のことを楽しめました
    - [elixir.jp](https://join.slack.com/t/elixirjp/shared_invite/zt-ae8m5bad-WW69GH1w4iuafm1tKNgd~w) Slack workspaceの`#autoracex`チャンネルに**aweseome-bot**:robot:君がいます
- ちなみにローカルマシンで動かし続けるというのはいろいろつらいのでそうするとサーバのようなものが必要になります
    - それには、さくらインターネットさんの[Hacobune（はこぶね）](https://www.sakura.ad.jp/information/announcements/2021/08/12/1968207782/)を使っています
    - この話についてはまた記事をわけて別のカレンダーで書いてみたいとおもっています[^2]
- うまく設計すると、**Bolt for Elixir**が誕生するかも :tada::tada::tada: 
- Enjoy [Elixir](https://elixir-lang.org/):bangbang::bangbang::bangbang:

以下、[Elixir](https://elixir-lang.org/)のお役立ち情報です

## オススメの書籍 :books: 
- [プログラミングElixir（第2版）](https://www.ohmsha.co.jp/book/9784274226373/) -- オーム社
- [Elixir実践ガイド](https://book.impress.co.jp/books/1120101021) -- インプレス

## Webアプリケーションを楽しむなら
- [Phoenix](https://www.phoenixframework.org/)

## IoTを楽しむなら
- [Nerves](https://www.nerves-project.org/)

## AIを楽しむなら
- [Nx](https://github.com/elixir-nx/nx) + [Livebook](https://github.com/livebook-dev/livebook)

## コミュニティ
- [elixir.jp](https://join.slack.com/t/elixirjp/shared_invite/zt-ae8m5bad-WW69GH1w4iuafm1tKNgd~w) Slack workspaceに参加してみてください
    - マヂ、やさしい人ばっかりのコミュニティ
    - あなたの**困った**をきっと解決してくれるでしょう
- [NervesJP](https://join.slack.com/t/nerves-jp/shared_invite/zt-9vteokip-iVAqi8TkT0ID_uK9dSqVHA) Slack workspaceでは、[Nerves](https://www.nerves-project.org/)やIoTが好きな愉快なfolksたちがあなたの訪れを歓迎します :tada:
- たくさんのコミュニティがあります
    - @kn339264 さん作の素敵な資料をご紹介します
    - [Elixirコミュニティ の歩き方〜国内オンライン編〜](https://speakerdeck.com/elijo/elixirkomiyunitei-falsebu-kifang-guo-nei-onrainbian) :clap::clap_tone1::clap_tone2::clap_tone3::clap_tone4::clap_tone5:

![FCOvBkAUYAE6mL8.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131808/a277d0ea-2780-d9a3-4062-66d38b175125.jpeg)
@piacerex さん作 :pray::pray_tone1::pray_tone2::pray_tone3::pray_tone4::pray_tone5: