RubyでSlackのbotをつくる2021：Socket Mode編

この記事は[『ドワンゴ Advent Calendar 2021』](https://qiita.com/advent-calendar/2021/dwango) 10日目の記事です。

## この記事について

Socket Mode に対応した [Slack](https://slack.com) の bot を Ruby で作る話です。
Slack は機能の追加ペースが結構速いため、 **2021年12月時点での情報（＆僕の知っていること）** であることにご注意ください。

記事の内容としては割とライトな想定です。
Slack App は非常に高機能なため、最初の取っ掛かりになれば……！

### 対象読者
- Slack の bot を Ruby で書きたい
- Slack の API の気持ちを知りたい
- Socket Mode が追加されたのは知ってるが、 RTM API に甘えて 1mm もキャッチアップできてない（先月までの僕です！）

### この記事で取り扱う範囲
- （Ruby で）Socket Mode の接続
- （Ruby で）Socket Mode で実際にイベントを受け取る
- （Ruby で）Socket Mode でボタンとモーダルを利用する
- おまけ： Slack bot ライブラリを作った話

※ Ruby 固有の話はあまりありません

## Slack bot をつくる方法

:::note info
前提部分の話のため、実装部分だけを見たい場合はこのセクションは skip して OK です
:::

### 定番は公式の「Bolt」
- [The Bolt family of SDKs \| Slack](https://api.slack.com/tools/bolt)

JavaScript / Python / Java 向けに、Slack公式ライブラリとして **Bolt** が提供されています。
様々な機能が使えるのはもちろんですが、使い方もシンプルにまとまっており、ちょっとした bot を作成するのであれば Bolt を選択すれば間違いナシです。

```typescript:TypeScriptの場合
import { App } from '@slack/bolt';

const app = new App({
  token: 'xxxxxxxxx',
  ...
});

app.message('ping', async ({message, say}) => {
  await say({text: `<@${message.user}> pong!`});
});
```

パット見て何が起きるのかわかりやすくていいですね。

そんなわけで、本来は Bolt を選択すれば何の問題もないのですが、僕自身は Ruby が好きなので可能であれば Ruby で Slack bot を作りたいな……という気持ちがあります。[^ruby_kakitai]

[^ruby_kakitai]: 趣味でブラウザゲームを作っている関係上、僕の書くコードは9割くらいが TypeScript になっており、 bot くらいは Ruby で書きたい！！！１１１

### Rubyの場合は？

Ruby で bot を作るとすると、このあたりとかでしょうか？

- [slack-ruby-bot](https://github.com/slack-ruby/slack-ruby-bot) / [slack-ruby-bot-server](https://github.com/slack-ruby/slack-ruby-bot-server)
- [Ruboty](https://github.com/r7kamura/ruboty)＋[ruboty-slack-rtm](https://github.com/rosylilly/ruboty-slack_rtm)
- [Mobb](https://github.com/kinoppyd/mobb) 

などなど。[公式ページにもいろいろツールやライブラリがまとめられています](https://api.slack.com/community) 。

slack-ruby-bot は名前のとおり Slack 専用のライブラリで、 Ruboty と Mobb はどちらも汎用 bot フレームワークで Slack に対応したアダプターが存在するタイプのものです。

かつての Slack は「**ちょっとすごい IRC っぽいチャットツールやつ**」という感じでしたが、様々なアップデートを経て今では「**プラットフォーム**」と呼ぶほうが適切なのではないか？と本気で思うほどに高機能です。
そのため、汎用 bot フレームワークに無理にこだわらずに、 Slack 専用のライブラリを使用したほうが良い場合も多いと考えています。

### EventAPI と Socket Mode

以前は Slack でリアルタイムにメッセージ応答を行うような bot を作る際には [RTM API](https://api.slack.com/rtm) という WebSocket で接続するタイプのものが利用されていました。
そういったアプリケーションは現在では Classic Slack App という枠組みになっており、今の Slack App では [Event API](https://api.slack.com/apis/connections/events-api) を使うのが推奨されています。

Event API は、PublicのHTTPエンドポイントを用意して Outgoing WebHook のようにHTTP経由でイベントを受け取る方法と、2021年に追加された [Socket Mode](https://api.slack.com/apis/connections/socket) と呼ばれる、 RTP API のように WebSocket で接続してイベントを受け取る方法があります。

HTTPのエンドポイントを用意するタイプのものは、どちらかというと1つのワークスペースで使うようなアプリケーションではなく、様々なスペースで非常にたくさんの人が使うようなアプリケーションに適していそうです。
もしくは Heroku の無料プラン上で bot を動かす場合など、プロセスの起動時間に制限がある場合も良いかもしれません。

一方、社内のみで使いたい bot などは外部からアクセス可能な HTTP のエンドポイントを用意することがネットワーク的またはポリシー的に難しいため、 Socket Mode を利用するほうが良いでしょう。

### Socket Mode を使いたい → つくるぞ！

そんなわけで、僕は用途的に Socket Mode を利用したいのですが、残念ながら2021年12月5日時点で先程挙げた Ruby 用の Slack bot ライブラリの中には Socket Mode に対応したものがありません……(◞‸◟)

せっかくなので、 Socket Mode の勉強がてら Ruby の bot フレームワークもどきを作ってみました。

## Socket Mode について

Socket Mode の概要や利用方法は以下のページにまとまっています。

- [Intro to Socket Mode \| Slack](https://api.slack.com/apis/connections/socket)
- [Socket Mode implementation \| Slack](https://api.slack.com/apis/connections/socket-implement)

また、以下の記事にも情報がまとまっているため、参考になります。

- [Slack ソケットモードの最も簡単な始め方 \- Qiita](https://qiita.com/seratch/items/1a460c08c3e245b56441)
- [Slack の Socket Mode API を使ってみた \| Technology \| KLablog \| KLab株式会社](https://www.klab.com/jp/blog/tech/2021/0201-slack.html)

アプリケーション登録の際に Socket Mode を有効にするのを忘れずに！

### Socket Mode の接続

Socket Mode への接続は RTM API とほぼ同じ流れです。

1. [`apps.connections.open` のAPI](https://api.slack.com/methods/apps.connections.open) を呼び出す
2. WebSocket の接続先 URL が手に入るので接続する

`apps.connections.open` を呼び出す際のトークンは [App-Level tokens](https://api.slack.com/authentication/token-types#app) を利用する必要があります。

また、 Ruby の場合 Slack API の呼び出しに [slack-ruby-client](https://github.com/slack-ruby/slack-ruby-client) を利用することが多いかと思いますが、 2021年12月5日時点ではトークン付与の方法の問題があり slack-ruby-client を使って呼び出すことができません。
[Issue やそれを解消する PullRequest は出ている](https://github.com/slack-ruby/slack-ruby-client/issues/363) ため、遠くない将来解消されると思います。

今回は一旦、 slack-api-client 経由ではなく直接 POST のリクエストをするようにしました。そのうち置き換えよう……！

<details><summary>サンプルコード</summary><div>

```ruby:ベタにPOSTするだけ
require 'net/http'

app_token = ENV['SLACK_APP_TOKEN']

uri = URI.parse('https://slack.com')
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
resp = http.post(
  '/api/apps.connections.open', {}.to_json,
  # 認証情報は Authorization ヘッダーに
  Authorization: "Bearer #{app_token}",
  'Content-Type': 'application/json'
)
JSON.parse(resp.body)
# => { "ok": true, "url": "wss://～" }
```
</div></details>

### イベントの受け取り

- [Events API types \| Slack](https://api.slack.com/events)

Classic Slack App とは異なり、今の Slack App は各種イベントごとに権限設定を行うことができるため、通知が必要なイベントは事前に Slack App の設定で権限を振っておく必要があります。
会話に反応する bot を作るのであれば [`message` イベント](https://api.slack.com/events/message) あたりを設定しておけばよいかと思います。

権限の設定されているイベントが発生すると、以下のような `type` に `events_api` が設定されたイベントが送られてきます。

```javascript
{
  "envelope_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "type": "events_api",
  "payload": { ... }
}
```

`payload` に [Event types](https://api.slack.com/events) に記載の各イベントの中身が入っています。

また、ドキュメントや参考ページにも書かれていますが、受信確認の処理として、送られてきた `envelope_id` を WebSocket で Slack の API に3秒以内に送り返す必要があります。
これを忘れると、同じイベントが再送されてくるため注意です。

Socket Mode 自体の使い方は大体このような感じです。
payload の中身はリッチですが、 WebSocket でのやり取り部分はあまり難しい要素がないため、簡単に触り始められると思います。

## Socket Mode でボタンなどの interactive features を利用する

ここまでの機能だと「別に RTM API でいいじゃん」という内容でした。
せっかく Event API を使うため、ボタンやモーダルなどの機能を利用してみます。

- [An overview of app interactivity \| Slack](https://api.slack.com/interactivity)

メッセージ内へのボタンの設置や、モーダルの中身は Block Kit で作成します。

- [Block Kit \| Slack](https://api.slack.com/block-kit)

スマホアプリでも作るんか？ってくらい機能ありますね……
Block Kit は JSON でレイアウトを作成するのですが、手で書くにはしんどい規模のため、 [公式で提供されている Block Kit Builder](https://app.slack.com/block-kit-builder/) を使用してつくるのが良いです。

### まずボタンを出してみる
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25109/5e2c9d08-ef49-34b9-22eb-1cf73a23f5fc.png)

ボタンを出す操作自体は Socket Mode は特に関係ありません。
メッセージ送信の API である [`chat.postMessage`](https://api.slack.com/methods/chat.postMessage) のパラメータに Block Kit Builder で作成した JSON を付与するだけです。

<details><summary>サンプルコード</summary><div>

```ruby:ボタン付きのメッセージを送る例
require 'slack'

client = Slack::Web::Client.new(token: ENV['SLACK_BOT_TOKEN'])
client.chat_postMessage(
  channel: '適切なチャンネルID',
  text: 'please click button!',
  blocks: [
    {
      type: 'actions',
      elements: [
        {
          type: 'button',
          action_id: 'click_open_button',
          text: {
            type: 'plain_text',
            text: 'Open Modal'
          },
          value: 'OpenModal'
        },
        {
          type: 'button',
          action_id: 'click_close_button',
          text: {
            type: 'plain_text',
            text: 'Close'
          },
          value: 'Close'
        }
      ]
    }
  ]
)
```
</div></detail>

ここで大事なのはボタンに設定している `action_id` の値です。
この ID をもとに、後でどのボタンが押されたのかを判定します。

### ボタンが押されたらモーダルを出す

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25109/a0788f8d-32f6-8d17-4e77-a7eb153550de.png" width="300">

ボタンなどの操作によるイベントは `type` が `interactive` メッセージとして送られてきます。
ちょっと長いですが、以下のようなものです。

```javascript:ボタン押下時に来るイベント
// ※わかりやすさのため、説明に不要な一部要素を省略しています
{
  "envelope_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "type": "interactive",
  "payload": {
    "type": "block_actions",
    "trigger_id": "xxxxxxxxxxxx.xxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxx",
    "user": {
      "id": "Uxxxxxxxx",
      "username": "rutan",
      "name": "rutan",
      "team_id": "Txxxxxxxx"
    },
    "message": {
      ...
    },
    "actions": [
      {
        "type": "button",
        "action_id": "click_open_button",
        "text": {
          "type": "plain_text",
          "text": "Open Modal",
          "emoji": true
        },
        "value": "OpenModal",
        "action_ts": "1638727837.845068"
      }
    ],
    ...
  }
}
```

`actions` には押されたボタンなどの情報が配列で入っています。 `actions` 内の `action_id` に先程設定した `click_open_button` が入っているため、モーダルを開くボタンがクリックされたということがわかります。

そして重要なのは `trigger_id` です。
これは、このイベントの元（ボタン押下）となる操作に紐づくもので、[ モーダルを開く `views.open` API](https://api.slack.com/methods/views.open)  の必須パラメータになっています。
ボタン操作など無しに勝手にモーダル開いたら怖いですしネ……

`trigger_id` の有効期限は 3 秒のため、イベントを受け取ったらすぐに次のアクションを実行するようにしましょう。

```ruby
client.views_open(
  trigger_id: trigger_id,
  view: {
    callback_id: 'my_modal',
    # 以下、Block Kit Builder で作ったモーダルのJSON
    type: 'modal',
    ...
  }
)
```

ここでモーダルに `callback_id` を設定しています。
先ほどの `action_id` 同様に、次のイベントの特定に使用します。

### モーダルの Submit イベントを受け取る

先ほどのボタンとほぼ同様です。
`payload` の `type` が `view_submission` として送られてきます。

こちらも少し長いですが、見たほうがわかりやすいので貼り付けます。

```javascript:モーダル送信時に来るイベント
// ※わかりやすさのため、説明に不要な一部要素を省略しています
{
  "envelope_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "type": "interactive",
  "payload": {
    "type": "view_submission",
    "trigger_id": "xxxxxxxxxxxx.xxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxx",
    "user": {
      "id": "Uxxxxxxxx",
      "username": "rutan",
      "name": "rutan",
      "team_id": "Txxxxxxxx"
    },
    "view": {
      "id": "Vxxxxxxxx",
      "callback_id": "my_modal",
      "type": "modal",
      "title": { ... },
      "blocks": [ ... ],
      "state": {
        "values": {
          "title_section": {
            "title_value": {
              "type": "plain_text_input",
              "value": "Title"
            }
          },
          "body_section": {
            "body_value": {
              "type": "plain_text_input",
              "value": "Body"
            }
          }
        }
      },
      ...
    },
    ...
  }
}
```

`payload` 内の `view` > `state` 以下にモーダル内で入力したテキストなどが含まれています。
`state` 内の key 名や要素数は、作成したモーダルの内容によって異なります。

送られてきた中身を検証し、次のステップの操作を実装すればモーダル対応も無事達成です！

### その他いろいろ

Block Kit には様々な部品がありますし、インタラクティブな操作もボタンやモーダルだけではなく、スラッシュコマンドなどもあります。
Slack の API ドキュメントページに様々な例がまとまっているため、参考にしてキミだけの最高の Slack bot をつくろう！

## おまけ： Ruby の Slack bot ライブラリを作りかけた

- [rutan/sardonyx_ring](https://github.com/rutan/sardonyx_ring)

Socket Mode 勉強の一環としてつくってみました。
あくまでお勉強用のため、まだこれだけで Slack bot を完璧に作れる！という状態ではありません……まだまだ Slack API の気持ちを完全に理解できていない(◞‸◟)[^doyarenai]

[^doyarenai]: 本当はこのライブラリを完璧に作って「こんなん作ったでドヤ！」という記事を書くつもりでしたが、僕の想像を遥かに超えて Slack は高機能でした……！

僕がもともと Mobb を使っていたため Mobb の書き味と、 Bolt の雰囲気がごちゃっと混ざった感じのライブラリになっています。

```ruby:SardonyxRingのサンプル
require 'sardonyx_ring'

class ExampleBot < SardonyxRing::App
  def greeting
    rand < 0.5 ? 'Hello,' : 'Fooo!'
  end

  # メッセージ
  message(/Hello/) do |message|
    message.say(text: "#{greeting} <@#{message.user}>!")
  end

  message 'modal' do |message|
    message.say(
      text: 'please click button!',
      blocks: [
        # 省略：ボタン表示用の JSON
      ],
    )
  end

  # ボタンなどの操作イベント
  action 'click_open_button' do |event|
    client.views_open(
      trigger_id: event.raw_payload.trigger_id,
      view: {
        # 省略：モーダル表示用の JSON
      }
    )
  end

  # モーダル送信イベント
  view 'my_modal' do |event|
    client.chat_postMessage(
      channel: event.raw_payload.user.id,
      text: 'Thanks!'
    )
  end

  # メッセージ以外のイベント
  event 'reaction_added' do |event|
    client.chat_postMessage(
      text: "reaction :#{event.reaction}: added by <@#{event.user}> !",
      channel: event.item.channel
    )
  end

  # cron
  every '* * * * *' do
    channel = ENV['CRON_CHANNEL_ID']
    return unless channel

    client.chat_postMessage(
      text: "current time is #{Time.now}",
      channel: channel
    )
  end
end

ExampleBot.new(
  app_token: ENV['SLACK_APP_TOKEN'],
  bot_token: ENV['SLACK_BOT_TOKEN'],
).socket_start!
```

定義の仕方は Mobb っぽく、 action とか view とかの名前は Bolt っぽく……

まだイベントまわりは考慮漏れなども多いので実運用レベルかは怪しい感じですが、もうちょっと頑張って今 RTM API で動かしてる bot 達を置き換えられるようにしたいです……！