LoginSignup
1

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-12-08

2021/12/09 の回です。
前日は、@75asaさんによる「Block Kit でゲシュタルト崩壊しないために JSX でブロックを記述する」でした。

ゲシュタルト崩壊(ゲシュタルトほうかい、独: 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を楽しんでいますか:bangbang::bangbang::bangbang:
  • Elixirを楽しんでいますか:bangbang::bangbang::bangbang:

ボット:robot:をつくります

仕様は以下の通りです。

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

スクリーンショット 2021-11-11 8.31.48.png

スクリーンショット 2021-11-11 8.31.16.png

Bolt for Python

Bolt for 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 入門ガイド」をご参照ください。
まず公式のSDKを使って作ることの自信を得ました。
ガイドの通りにまずはイゴかし1てみることをオススメします。

Tips

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"を購読したとします。

そうして

$ 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:

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

Elixirで書く

ここからはElixirで書きます。
HTTP通信で作ります。
WebSocket(ソケットモード)は、WebSockexを使うことでなんとなくは動いたのですが、再接続あたりがうまくいかず一旦置くことにしました。
これはこれでうまくいったところ、うまくいかなかったところをまとめたいとおもってはいます2

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

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

大まかな仕組み(HTTP)

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

ドキュメント

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

完成品

どこでイゴいている1の?

elixir.jp Slack workspaceの#autoracexチャンネルにaweseome-bot:robot:君がいます。

Elixirの純粋なもくもく会です。

Phoenixインストール

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

プロジェクトのnew

$ mix phx.new slack_doorman --no-ecto

$ cd slack_doorman

HTTPoisonの追加

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

ソースコードを書く

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での実装です。
一点注意点としては、:crypto.mac/4関数はOTP-24以上です。OTP-23では:crypto.hmac/3関数を使うと同じように実装できるはずです。

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

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

raw bodyの取り出し

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
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

Thanks!

chat.postMessage

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

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

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
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
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

パターンマッチング大活躍です。
ボットの振る舞いを書いています。

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

OAuth & Permissions

スクリーンショット 2021-11-10 23.12.50.png

環境変数

  • Basic Information ページの署名シークレットをコピー => SLACK_SIGNING_SECRET
  • OAuth & Permissions ページのボットトークン (xoxb) をコピー => SLACK_BOT_TOKEN

Run :rocket::rocket::rocket:

$ mix phx.server

https

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

を使うと便利です。
ngrokは、Bolt 入門ガイド(HTTP)で知りました。

スクリーンショット 2021-11-10 23.24.39.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

Wrapping up :lgtm::lgtm::lgtm::lgtm::lgtm:

  • Elixirを使って、Bolt 入門ガイド(HTTP) 相当のことを楽しめました
    • elixir.jp Slack workspaceの#autoracexチャンネルにaweseome-bot:robot:君がいます
  • ちなみにローカルマシンで動かし続けるというのはいろいろつらいのでそうするとサーバのようなものが必要になります
    • それには、さくらインターネットさんのHacobune(はこぶね)を使っています
    • この話についてはまた記事をわけて別のカレンダーで書いてみたいとおもっています2
  • うまく設計すると、Bolt for Elixirが誕生するかも :tada::tada::tada:
  • Enjoy Elixir:bangbang::bangbang::bangbang:

以下、Elixirのお役立ち情報です

オススメの書籍 :books:

Webアプリケーションを楽しむなら

IoTを楽しむなら

AIを楽しむなら

コミュニティ

FCOvBkAUYAE6mL8.jpeg
@piacerex さん作 :pray::pray_tone1::pray_tone2::pray_tone3::pray_tone4::pray_tone5:


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

  2. おもっています。あくまでもおもっています。 

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
1