SlackのInteractive MessageをPython製bot"Errbot"で実現する

ChatOpsを実現するにあたって、Slackの[Interactive Message](https://api.slack.com/interactive-messages)は強力な手段です。詳しくはリンク先をご覧いただければと思いますが、Slack上でユーザが入力したものを受けて、対話的にBotとやり取りを行うことができます。これを応用すれば、ユーザの入力に応じて、様々な処理をBotに行わせることができるようになります。

このエントリーでは、ErrbotのみならずSlackのInteractive Messageの仕組み(振る舞い)を紹介したあと、Python製のBotである[Errbot](http://errbot.io/)を使い、SlackのInteractive Messageを実現するという2本立てでいきます。
ですので、Errbotとかよくわかんない、っていう人でも、Interactive Messageの可能性を感じてもらえれば幸いです。

完成形は、こんなイメージです。ただの技術デモですが、1日の献立を決めましょう、というネタでやっています。

![kondate.gif](https://qiita-image-store.s3.amazonaws.com/0/16458/01e016cd-af1d-2d27-4c24-1becd3454a77.gif)

1. 朝ごはん何にする？
2. 決まった朝ごはんを表示&昼ごはん何にする？
3. 決まった昼ごはんを表示&夜ごはん何にする？
4. 決まった夜ごはんを表示

という感じです。
ユーザの入力した項目を受けて、メッセージを更新して次の質問に移っていることが分かるかと思います。

# Interactive Messageの仕組み

* [Interactive messages | slack API](https://api.slack.com/interactive-messages)

以下に和訳してくださっている方がいらっしゃいます。

* [Slack Interactive Messageリファレンス
](https://qiita.com/hypermkt/items/b2ffaf610ac92235c4d6)

大まかには以下の図のイメージです。

![interactive_message.png](https://qiita-image-store.s3.amazonaws.com/0/16458/3dba140d-ce45-9e58-e748-6f64a7c63104.png)

Interactive Messageで次々と質問を追加していったり、結果を表示する仕組みは、Slackから見ると「**Messageを更新している**」ことになり、Botから見ると「**やってきたWebhookにresponse(次のattachmentを含むmessage)を返す**」ことをしています。

Botから見ると、メッセージを更新するための手段はいろいろとあります。詳しくは[Responding to message actionsの章](https://api.slack.com/interactive-messages)をご覧ください。

大まかには以下の3つです。

* webhookのrequestにresponseを返す (今回の方法)
* requestのpayloadに書かれている`response_url`に普通にメッセージをPOSTする
* `chat.update` のSlack APIを叩く

最後の方法は、ほとんど普通のメッセージ投稿である[chat.postMessage](https://api.slack.com/methods/chat.postMessage)と同じ感覚で使える一方、updateなのでSlackのメッセージに`(edited)`と表示されるので、若干かっこ悪いかもしれません。

Bot側にWebhook用のEndpointがある場合は、素直に1つめの方法を選択するのが最も楽な気がします。

# Interactive Messageでメッセージを書き換える流れ

一旦、Errbotであるかどうかは置いておいて、メッセージの流れを追ってみます。

## Bot -> Slack へ最初の質問を表示

最初に「朝ごはんは何にする？」と聞く場合は、単純にSlackに`chat.postMessage`でPOSTしているだけです。

```json:最初のchat.postMessage
{
	"text": "",
	"link_names": "1",
	"attachments": [{
		"text": "What do you eat for breakfast?",
		"callback_id": "breakfast",
		"actions": [{
			"text": "select a morning menu...",
			"data_source": "external",
			"type": "select",
			"name": "breakfast"
		}, {
			"text": "cancel",
			"type": "button",
			"confirm": {
				"text": "Wouldn't you eat?",
				"ok_text": "Yes",
				"dismiss_text": "No",
				"title": "Are you sure?"
			},
			"name": "cancel",
			"style": "danger"
		}],
		"color": "#3AA3E3"
	}],
	"as_user": true,
	"channel": "D8FT1CNUS"
}
```

選択肢は別だししているため上記には表示されませんが、単にattachmentsで質問を投げかけているのが分かるかと思います。

選択肢のリストの表示方法は別途紹介します。

## オプション選択時: Slack -> Bot へのWebhook

最初の質問を選んだ瞬間に、Slackから設定したURL endpointへWebhookが行われます。

```json:webohookのpayload
{
	"response_url": "https://hooks.slack.com/actions/.../.../...",
	"trigger_id": "302306861157.53543022048.f6fdb9a043fac6d8dc422a9bd442febf",
	"channel": {
		"id": "D8FT1CNUS",
		"name": "directmessage"
	},
	"original_message": {
		"ts": "1516543434.000057",
		"type": "message",
		"bot_id": "B8F7NKUQY",
		"user": "U8G1VF405",
		"text": " ",
		"attachments": [{
			"id": 1,
			"actions": [{
				"id": "1",
				"text": "select a morning menu...",
				"type": "select",
				"name": "breakfast",
				"data_source": "external"
			}, {
				"id": "2",
				"type": "button",
				"name": "cancel",
				"style": "danger",
				"text": "cancel",
				"value": "",
				"confirm": {
					"dismiss_text": "No",
					"text": "Wouldnt you eat?",
					"title": "Are you sure?",
					"ok_text": "Yes"
				}
			}],
			"color": "3AA3E3",
			"fallback": "What do you eat for breakfast?",
			"text": "What do you eat for breakfast?",
			"callback_id": "breakfast"
		}]
	},
	"attachment_id": "1",
	"user": {
		"id": "U1KFV9F0E",
		"name": "tkit"
	},
	"action_ts": "1516543613.552448",
	"message_ts": "1516543434.000057",
	"actions": [{
		"type": "select",
		"name": "breakfast",
		"selected_options": [{
			"value": "toast"
		}]
	}],
	"type": "interactive_message",
	"callback_id": "breakfast",
	"team": {
		"id": "<team_id>",
		"domain": "<team_domain>"
	},
	"is_app_unfurl": false,
	"token": "<slack_verification_token"
}
```

長いですが、特筆すべきは`original_message`に、最初の質問時のメッセージが全て入っていることです。
このWebhookに対して(BOTが)responseを返すことで、次々とInteractive Messageが更新されていくことになります。

## オプション選択時: Bot -> Slack へのresponse

```json:responseのpayload
{
	"ts": "1516543434.000057",
	"type": "message",
	"bot_id": "B8F7NKUQY",
	"user": "U8G1VF405",
	"text": " ",
	"attachments": [{
		"fields": [{
			"value": "@tkit eats toast!",
			"short": false,
			"title": "breakfast"
		}],
		"actions": [],
		"color": "#3AA3E3"
	}, {
		"actions": [{
			"text": "select a lunch menu...",
			"type": "select",
			"name": "lunch",
			"data_source": "external"
		}, {
			"style": "danger",
			"text": "cancel",
			"confirm": {
				"dismiss_text": "No",
				"text": "Wouldn't you eat?",
				"title": "Are you sure?",
				"ok_text": "Yes"
			},
			"type": "button",
			"name": "cancel"
		}],
		"text": "What do you eat for lunch?",
		"callback_id": "lunch"
	}]
}
```

先述の通り、`original_message`から必要な部分を書き換えてresponseとしています。

今回の例では、attachmentsの1つめ(つまり`attachments[0]`)が最初の質問項目だったので、選択済みであることを示すメッセージに書き換え、次の質問を2つめ(つまり`attachments[1]`)に追加した上でresponseとしています。

概ね、以下のようなイメージになります。

![interactive_message_2.png](https://qiita-image-store.s3.amazonaws.com/0/16458/c33ee1d8-cb2e-41ca-cb4b-4f3b4ca72bfd.png)

# ErrbotでInteractive Messageを実現する

ここまでの仕組みをErrbotで実現します。

## 試しに導入する

### 導入準備: Bot用Tokenの取得

Interactive Message導入のための、Slack側のBot用Tokenの準備などは、以下が詳しいのでご覧ください。これらは事前に行っておく必要があります。

* [Slash Commands と Slack Interactive Message 入門 | Qiita](https://qiita.com/muddydixon/items/26e7a6a8dacc0f1ea049)

### 導入準備: ngrokでローカル環境を外部と接続する

[ngrok](https://api.slack.com/tutorials/tunneling-with-ngrok)はローカルで開発しているBotを外部と繋ぐ時に便利です。

この後、サンプルとなるErrbotを立ち上げる場合は、以下のコマンドで3141ポートを外部とトンネリング接続してください。

```bash:3141ポートを外部に接続
ngrok http 3141
```

![ngrok.png](https://qiita-image-store.s3.amazonaws.com/0/16458/d9f15daa-6329-47cd-2d42-950394e5a39c.png)

Macだと`brew install ngrok`で簡単にインストールできます。
表示された`ngrok.io`のURLは、SlackのBot側の`Interactive Components`に設定します。

![interactive_components.png](https://qiita-image-store.s3.amazonaws.com/0/16458/88f0b853-85ed-aa74-6ac5-848339e86f33.png)

図の通り、`Request URL`は`/slack_request`、`Options Load URL`は`/external_options`を追記してください。

### 導入: DockerコンテナでErrbotを起動する

ErrbotのDockerコンテナを起動します。

```
docker run --name err \
           --rm -e BACKEND=Slack \
           -e BOT_TOKEN=<your_slack_tokenn> \
           -e "TZ=Asia/Tokyo" \
           -e "BOT_ADMINS=@tkit" \
           -e "BOT_LOG_LEVEL=INFO" \
           -e "BOT_ALT_PREFIXES=@testbot" \
           -e "SLACK_VERIFICATION_TOKEN=<your_slack_verification_token>" \
           -p 3141:3141 errbot:latest
```

`BOT_TOKEN`(SlackのToken)や`SLACK_VERIFICATION_TOKEN`(Verification Token)は、環境に応じたものを入れてください(SlackのBot側の設定に記載されています)。また、`BOT_ADMINS`はSlack上でBotの特権操作を許す人の名前を入れてください。

起動後、Slack上でBotがオンラインになったら、Slack上で以下を入力します。

```:Slack上でプラグインをインストール
!repos install https://github.com/tkit/errbot-plugin-example-kondate
```

`BOT_ADMINS`に含まれているユーザならば実行可能のはずです。

あとは、`@testbot kondate`と入力すれば、Interactive Messageを確認することができるかと思います。

## Errbotのプラグインの解説

ここからは、Errbotでどうやって実現しているかについての紹介です。

Pythonに別に詳しいわけではない(というかアプリケーションに詳しくない)のでコードは汚いかもしれませんが、雰囲気だけ察していただければと思います(ごめんなさい)。

### Interactive Messageに対応した送信関数を作る

参考URL: [tkit/errbot-plugin-example-kondate](https://github.com/tkit/errbot-plugin-example-kondate/blob/master/kondate.py#L65)

Errbotでリッチなメッセージを送信するためには、[send_card](http://errbot.io/en/latest/user_guide/plugin_development/messaging.html#cards)関数を利用します。
が、まず大前提として、ErrbotはAttachmentには**大まかに**対応しているのみで、他のチャットツールと共通的に振る舞わせるために、Slack特有のfieldには未対応となっています。
つまり、Errbotの標準だと、まずInteractive Messageに対応していないということに気づきます。
従って、まず行うのはInteractive Messageに対応した関数を作ることです。

といっても、既に提供されている`send_card`関数をちょっと拡張すればいいだけなので、ほとんどコピペで済みます。プラグインの`send_card`は、継承元の`errbot.botplugin`の[send_card関数](http://errbot.io/en/latest/_modules/errbot/botplugin.html#BotPlugin.send_card)を呼び、そのあとに`errbot.backends.slack`の[send_card関数](http://errbot.io/en/latest/_modules/errbot/backends/slack.html#SlackBackend.send_card)を呼んでいるので、それらを組み合わせて、より多くのfieldを入れられるようにすればよいです(もっとキレイに作る方法があれば教えてほしい…)。
[errbot-plugin-example-kondate](https://github.com/tkit/errbot-plugin-example-kondate)の`send_slack_attachment_action`関数がそれにあたります。

本当は、[Backend development](http://errbot.io/en/latest/user_guide/backend_development/)でも見て、このために拡張したBackendを作ったほうがいいのではないかと思ったのですが、面倒で諦めました…。

### 最初の命令: 質問を表示する

参考URL: [tkit/errbot-plugin-example-kondate](https://github.com/tkit/errbot-plugin-example-kondate/blob/master/kondate.py#L46)

`@botcmd`デコレータで、Slackに向けてattachment付きのメッセージを送っています。ここは、Errbotの単なる命令なので、そこまで難しくはないかと思います。

```python:kondate命令
@botcmd
def kondate(self, msg, args):
		"""plan today's breakfast, lunch, and dinner"""
		actions = [{
				"name": "breakfast",
				"text": "select a morning menu...",
				"type": "select",
				"data_source": "external"
		},
							 self.make_cancel_msg()]
		body = 'What do you eat for breakfast?'
		callback_id = 'breakfast'
		self.send_slack_attachment_action(
				body=body,
				callback_id=callback_id,
				actions=actions,
				color='#3AA3E3',
				in_reply_to=msg)
```

### 最初の質問で回答を選んだときのWebhook

参考URL: [tkit/errbot-plugin-example-kondate](https://github.com/tkit/errbot-plugin-example-kondate/blob/master/kondate.py#L140)

次に、「朝ごはんは何にする？」と質問されたあとに、ユーザがSlack上で回答を選択した場合の挙動ですが、Slackから設定されたURLに向けてWebhookが飛びます。

```python:webhookのslack_request
@webhook(form_param='payload')
def slack_request(self, payload):
  # 略
	response_message = json.dumps(message)
	self.log.debug("response message:{}".format(response_message))
	response.set_header("Content-type", "application/json")
	return response_message
```

やっていることは単純で、payloadにSlackからのrequest bodyが詰められるので、それを読み込んで、Errbotからのresponseを構築し、返事をしているだけです。

[Errbotにwebhookの機能がある](http://errbot.io/en/latest/user_guide/plugin_development/webhooks.html)ため、それを利用しています。関数名である`slack_request`が、そのままURIとして利用されています。

ポイントは、先述の通り`original_message`に最初の質問時のattachmentなどが全て入っているため、それを読み込んで利用しています。

* payloadからユーザが選択した回答を拾う
* `original_message`を読み込んで、`attachments[0]`に回答から文言を作成して埋め込む
* `attachments[1]`には「昼ご飯は何にする？」という質問を追加する
* 最終的に作ったjsonをresponseとして返す

あとはこれの繰り返しです。

# おまけ: Interactive Message中の選択肢の生成

正直なところ、今回の例では動的にする必要はないのですが、あくまでデモということで可能性があるexternalの手段を紹介します。
実際、この仕組みをChatOps化しようとすると、質問のリストは動的に持った方がよいかと思います。例えばgit branchのリストとか、サーバの一覧、などです。

## 固定的な選択肢の生成

まず、動的の前に固定的な選択肢の方法について。

* [Attaching interactive message menus | slack API](https://api.slack.com/docs/message-menus)

上記にある通り、選択肢を固定的に持つ場合は、単純に `options`を並べてしまえばよいです。この場合、省略されていますが`"data_source":"static"`が選ばれている状態になります。

```json:選択肢を固定的に持つ例
{
    "text": "Would you like to play a game?",
    "response_type": "in_channel",
    "attachments": [
        {
            "text": "Choose a game to play",
            "fallback": "If you could read this message, you'd be choosing something fun to do right now.",
            "color": "#3AA3E3",
            "attachment_type": "default",
            "callback_id": "game_selection",
            "actions": [
                {
                    "name": "games_list",
                    "text": "Pick a game...",
                    "type": "select",
                    "options": [
                        {
                            "text": "Hearts",
                            "value": "hearts"
                        },
                        {
                            "text": "Bridge",
                            "value": "bridge"
                        },
                        // (略)
                        {
                            "text": "Global Thermonuclear War",
                            "value": "war"
                        }
                    ]
                }
            ]
        }
    ]
}
```

## 動的な選択肢の生成の方法

これまでの例では、ご飯のリスト(例えば朝ごはんにトースト、とか)はどこにも含まれていませんでした。つまり、選択肢は外部にもたせていることになります。

Slackで`data_source`を`external`にすると、Slackで設定した、option用のURLに対して選択肢を問い合わせに行きます。Slack上の設定で、`Options Load URL`に`/external_options`を指定した意味はここにあります。

SlackからBotへの選択肢の問い合わせは、以下のような感じです。

```json:選択肢の問い合わせ
{
	"callback_id": "breakfast",
	"attachment_id": "1",
	"message_ts": "1516543434.000057",
	"name": "breakfast",
	"team": {
		"id": "<team_id>",
		"domain": "<team_domain>"
	},
	"user": {
		"id": "U1KFV9F0E",
		"name": "tkit"
	},
	"token": "<your_slack_verification_token>",
	"value": "",
	"action_ts": "1516543435.870713",
	"channel": {
		"id": "D8FT1CNUS",
		"name": "directmessage"
	}
}
```

ここから、`name`や`callback_id`を元にどの質問なのかを判断して、responseとして選択肢の一覧を返せば良いことになります。

```json:選択肢リスト(response)
{
	"options": [{
		"text": "toast",
		"value": "toast"
	}, {
		"text": "rice",
		"value": "rice"
	}]
}
```

## Errbotで選択肢を動的に生成する

上記をErrbotで実現します。
今回紹介するgithubのコードで、まだ紹介していないのが`external_options`関数です。

参考URL: [tkit/errbot-plugin-example-kondate](https://github.com/tkit/errbot-plugin-example-kondate/blob/master/kondate.py#L208)

payload中に入っている`callback_id`を元に動的に選択肢を判断しています。(今回は`callback_id`で判断したのですが、`name`とくらべてどっちで判断したほうがいいのでしょう？)

# まとめ

## Interactive Messageの注意点

Slackの設定を見てもらえればわかりますが、Bot1つで**質問のendpointと選択肢のendpointは1つずつ**です。
つまり、1つのBotでいろんなInteractive Messageを行ったり、いろんなOptionを生成したいとしても、必ず特定のURLにWebhookが行われるので、それを受けた側(つまり今回の例でいうBot側)は、payloadを元に判断するというロジックが必要になります。

## これが何の役に立つのか

献立を決めるもの自体は割とどうでもいいのですが、対話的にやりとりがなされ、かつその間に処理を挟み込めるというのはいろいろ応用が効くと思います。Gitリポジトリを選んでBranchを選んでデプロイとか、本番/開発環境を選んでサーバを選んで何らかの処理をしたりとか。

そうしたやり取りにこのエントリが参考になればと思いました。