ChatOpsを実現するにあたって、SlackのInteractive Messageは強力な手段です。詳しくはリンク先をご覧いただければと思いますが、Slack上でユーザが入力したものを受けて、対話的にBotとやり取りを行うことができます。これを応用すれば、ユーザの入力に応じて、様々な処理をBotに行わせることができるようになります。
このエントリーでは、ErrbotのみならずSlackのInteractive Messageの仕組み(振る舞い)を紹介したあと、Python製のBotであるErrbotを使い、SlackのInteractive Messageを実現するという2本立てでいきます。
ですので、Errbotとかよくわかんない、っていう人でも、Interactive Messageの可能性を感じてもらえれば幸いです。
完成形は、こんなイメージです。ただの技術デモですが、1日の献立を決めましょう、というネタでやっています。
- 朝ごはん何にする?
- 決まった朝ごはんを表示&昼ごはん何にする?
- 決まった昼ごはんを表示&夜ごはん何にする?
- 決まった夜ごはんを表示
という感じです。
ユーザの入力した項目を受けて、メッセージを更新して次の質問に移っていることが分かるかと思います。
Interactive Messageの仕組み
以下に和訳してくださっている方がいらっしゃいます。
大まかには以下の図のイメージです。
Interactive Messageで次々と質問を追加していったり、結果を表示する仕組みは、Slackから見ると「Messageを更新している」ことになり、Botから見ると「やってきたWebhookにresponse(次のattachmentを含むmessage)を返す」ことをしています。
Botから見ると、メッセージを更新するための手段はいろいろとあります。詳しくはResponding to message actionsの章をご覧ください。
大まかには以下の3つです。
- webhookのrequestにresponseを返す (今回の方法)
- requestのpayloadに書かれている
response_url
に普通にメッセージをPOSTする -
chat.update
のSlack APIを叩く
最後の方法は、ほとんど普通のメッセージ投稿であるchat.postMessageと同じ感覚で使える一方、updateなのでSlackのメッセージに(edited)
と表示されるので、若干かっこ悪いかもしれません。
Bot側にWebhook用のEndpointがある場合は、素直に1つめの方法を選択するのが最も楽な気がします。
Interactive Messageでメッセージを書き換える流れ
一旦、Errbotであるかどうかは置いておいて、メッセージの流れを追ってみます。
Bot -> Slack へ最初の質問を表示
最初に「朝ごはんは何にする?」と聞く場合は、単純にSlackにchat.postMessage
でPOSTしているだけです。
{
"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が行われます。
{
"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
{
"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としています。
概ね、以下のようなイメージになります。
ErrbotでInteractive Messageを実現する
ここまでの仕組みをErrbotで実現します。
試しに導入する
導入準備: Bot用Tokenの取得
Interactive Message導入のための、Slack側のBot用Tokenの準備などは、以下が詳しいのでご覧ください。これらは事前に行っておく必要があります。
導入準備: ngrokでローカル環境を外部と接続する
ngrokはローカルで開発しているBotを外部と繋ぐ時に便利です。
この後、サンプルとなるErrbotを立ち上げる場合は、以下のコマンドで3141ポートを外部とトンネリング接続してください。
ngrok http 3141
Macだとbrew install ngrok
で簡単にインストールできます。
表示されたngrok.io
のURLは、SlackのBot側のInteractive Components
に設定します。
図の通り、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上で以下を入力します。
!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
Errbotでリッチなメッセージを送信するためには、send_card関数を利用します。
が、まず大前提として、ErrbotはAttachmentには大まかに対応しているのみで、他のチャットツールと共通的に振る舞わせるために、Slack特有のfieldには未対応となっています。
つまり、Errbotの標準だと、まずInteractive Messageに対応していないということに気づきます。
従って、まず行うのはInteractive Messageに対応した関数を作ることです。
といっても、既に提供されているsend_card
関数をちょっと拡張すればいいだけなので、ほとんどコピペで済みます。プラグインのsend_card
は、継承元のerrbot.botplugin
のsend_card関数を呼び、そのあとにerrbot.backends.slack
のsend_card関数を呼んでいるので、それらを組み合わせて、より多くのfieldを入れられるようにすればよいです(もっとキレイに作る方法があれば教えてほしい…)。
errbot-plugin-example-kondateのsend_slack_attachment_action
関数がそれにあたります。
本当は、Backend developmentでも見て、このために拡張したBackendを作ったほうがいいのではないかと思ったのですが、面倒で諦めました…。
最初の命令: 質問を表示する
参考URL: tkit/errbot-plugin-example-kondate
@botcmd
デコレータで、Slackに向けてattachment付きのメッセージを送っています。ここは、Errbotの単なる命令なので、そこまで難しくはないかと思います。
@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
次に、「朝ごはんは何にする?」と質問されたあとに、ユーザがSlack上で回答を選択した場合の挙動ですが、Slackから設定されたURLに向けてWebhookが飛びます。
@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の機能があるため、それを利用しています。関数名であるslack_request
が、そのままURIとして利用されています。
ポイントは、先述の通りoriginal_message
に最初の質問時のattachmentなどが全て入っているため、それを読み込んで利用しています。
- payloadからユーザが選択した回答を拾う
-
original_message
を読み込んで、attachments[0]
に回答から文言を作成して埋め込む -
attachments[1]
には「昼ご飯は何にする?」という質問を追加する - 最終的に作ったjsonをresponseとして返す
あとはこれの繰り返しです。
おまけ: Interactive Message中の選択肢の生成
正直なところ、今回の例では動的にする必要はないのですが、あくまでデモということで可能性があるexternalの手段を紹介します。
実際、この仕組みをChatOps化しようとすると、質問のリストは動的に持った方がよいかと思います。例えばgit branchのリストとか、サーバの一覧、などです。
固定的な選択肢の生成
まず、動的の前に固定的な選択肢の方法について。
上記にある通り、選択肢を固定的に持つ場合は、単純に options
を並べてしまえばよいです。この場合、省略されていますが"data_source":"static"
が選ばれている状態になります。
{
"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への選択肢の問い合わせは、以下のような感じです。
{
"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として選択肢の一覧を返せば良いことになります。
{
"options": [{
"text": "toast",
"value": "toast"
}, {
"text": "rice",
"value": "rice"
}]
}
Errbotで選択肢を動的に生成する
上記をErrbotで実現します。
今回紹介するgithubのコードで、まだ紹介していないのがexternal_options
関数です。
参考URL: tkit/errbot-plugin-example-kondate
payload中に入っているcallback_id
を元に動的に選択肢を判断しています。(今回はcallback_id
で判断したのですが、name
とくらべてどっちで判断したほうがいいのでしょう?)
まとめ
Interactive Messageの注意点
Slackの設定を見てもらえればわかりますが、Bot1つで質問のendpointと選択肢のendpointは1つずつです。
つまり、1つのBotでいろんなInteractive Messageを行ったり、いろんなOptionを生成したいとしても、必ず特定のURLにWebhookが行われるので、それを受けた側(つまり今回の例でいうBot側)は、payloadを元に判断するというロジックが必要になります。
これが何の役に立つのか
献立を決めるもの自体は割とどうでもいいのですが、対話的にやりとりがなされ、かつその間に処理を挟み込めるというのはいろいろ応用が効くと思います。Gitリポジトリを選んでBranchを選んでデプロイとか、本番/開発環境を選んでサーバを選んで何らかの処理をしたりとか。
そうしたやり取りにこのエントリが参考になればと思いました。