SlackのBolt(Python)を使ってChatOpsしようとしたら割と苦労した

今や一家に一台のSlackですが、やはりSlackを使う以上はChatOpsしないといけないよね、
ということでナウいBoltを使ってSocketModeで実装してみました。

# Slackの設定
まずSlack側でBoltを使う準備をしなければなりません。
割とやることがあるので、詳しくは[公式ページ](https://slack.dev/bolt-python/ja-jp/tutorial/getting-started)をみて頑張って下さい。

手順を掻い摘んで書くと、

- https://api.slack.com/apps にアクセス
- `Create New App` or `Create an App` をクリック
- モーダルが表示されるので `From scratch` をクリック
- `App Name` を入力して作成先のWorkspaceをリストから選択したら `Create App` をクリック
- 左メニューの`Features -> OAuth & Permissions`をクリック
- 画面を下にスクロールし、`Scopes`にて`Add an OAuth Scope`をクリックし、BotTokenScopesを適宜設定
- 左メニューの`Settings -> Socket Mode`をクリック
- `Enable Socket Mode`のトグルをONにする
- Token設定画面が出てくるので、`Token name`を入れて`Add Scope`で適宜Scopeを追加したらGenerateする
- **app token**が表示されるのでメモる
- 左メニューの`Features -> Event Subscriptions`をクリック
- `Enable Events`のトグルをONにする
- 下の`Subscribe to bot events`を展開
- `Add Bot User Event`をクリックしてsubするイベントを適宜追加し、右下の`Save Changes`をクリックする
- `Settings -> Install App`をクリックする
- `Install to Workspace`をクリックする
- 画面遷移し、権限リクエストされるので`許可する`をクリックする
- **bot token**が表示されるのでメモる

という感じです。画面遷移が多くて滅入ります。

# Boltの苦労話
ここまできてようやくBoltでBotを構築していけます。
今回は、「メンションを飛ばしたらいい感じにモーダル開いてコマンドを実行できるようなやつ」を目指したんですが...割と挫折しました。

### やろうとしたこと
まず最初に、「@bot check server」みたいに呼びかけたら、モーダルがボボッって出てそこから対象サーバーとコマンドを指定できるようなやつを作ろうとしました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/161568/bcdb9c42-d457-d534-d366-c152a2a8c4ae.png" width=400/>

### 挫折ポイントその1
ところがどっこい、**メンションから直接モーダルを起動する手段がありません**でした。
モーダルを起動するには、[ここ](https://slack.dev/bolt-js/ja-jp/concepts#creating-modals)に書いてある通り、`trigger_id`が必要になります。
が、trigger_idがPayloadに含まれるのは、スラッシュコマンド、ボタンの押下、メニューの選択、ショートカットなどになるので、メンションだとダメなんです :innocent: 

### 挫折ポイントその2
ということなので、最初はショートカットを利用しようとしました。
ショートカットには`Global`と`On messages`の二種類があるんですが、メッセージからコマンドを呼び出すのは意味がわからないので、Global一択となります。
`/`で起動できるし、まぁいいかなと思ったのも束の間、**GlobalShortcutsだと起動元のChannel情報が取れませんでした**。
今回想定しているのはサーバーに対するクエリなので、誰がいつ実行したかをSlack上で把握できるようにしておきたい、証跡を残したいんです。
そのために、実行元のChannelに対して、Botに「こういうコマンドを誰々が実行しました」みたいなメッセージを実行後に出したくなるのは自然だと思います。
が、Globalだと実行元Channel情報がないため、これができません :innocent: 

ちなみに、On messagesの方であればChannel情報が入るようなのですが、やっぱりメッセージからコマンドを呼び出すのは意味がわからないのでやってません。

### 挫折ポイントその3
ショートカットを諦め、Channel起因でアクションが発生するボタンの押下をモーダルの契機とすることにしました。
メンションに反応してBotにAttachmentを表示させ、その中にモーダル起動ボタンを配置します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/161568/4959316e-dac0-15cd-45c2-6fea08aa8ff8.png" width=300/>

これでOKだろうと思ったのも束の間、 **Channel情報がモーダルのsubmitで発火した関数のBodyに入ってきていない...**
おそらくモーダルもこれまたChannel情報を持たないんでしょうね...ということで、当初目標のコマンド実行の要件を満たさず、土日を使って収穫0かよ！と、割と絶望しました :innocent: 

### 福音
で、この記事を書くために調べ物していたら、たまたまこの記事に邂逅しました。
https://qiita.com/irico/items/63b98127644bb0f50fa6

**private_metadataっていうのが使えるのかい！？**

ということで、早速private_metadataを利用したところ...

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/161568/ae6f69f4-3db1-2c2d-9437-026d8e669721.png" width=300 />

**キターーーーーー！！**

Qiitaさまさまです :pray: 
正直なところ、Boltはドキュメントがやや弱いので、ソースコードを読まざるを得ない状況にもなったりしたんですが、APIのリファレンスを見れば良いとは...盲点でした。

# 実装にあたってのポイント解説
完成品は[GitHub](https://github.com/hasegit/bot_test)をご覧ください。

## 関数の分散
公式の例だと、app.pyにdecoratorくっつけた関数をひたすら羅列していくという感じでしたが、それだとモノリシックになってやばいことになるのは明白なので、分散しました。
処理をクラスで分けようと思ったんですが、`@app.deco` みたいな感じでAppのインスタンス経由でデコろうとする場合、グローバル変数にappを持たせないとうまくデコれませんでした(@self.app.decoは無理)。
なので、クラスにはinitの段階でappのインスタンスを渡すようにし、init内でデコ相当の処理を行うようにしました。

```python
# 前
@app.event("app_mention")
def mention:
  ...

# 後
class Hoge:
  def __init__(self, app):
    app.event("app_mention")(self.mention)

  def mention(self, event, say):
    ...
```
まぁこれがベストな分散方法かはわかりませんが...。

## blockはjson化して外出し
コードの中でblockを管理するとコードの見通しが悪くなるので、同じ階層でjsonファイルとして管理しています。
で、I/Oが頻発するのも嫌なので、initで読み込み、インスタンス変数に格納するようにしてます。
```python
    def __init__(self, app):
        with open("bot_test/modules/check_server/attachment.json", encoding="utf-8") as f:
            self.attachment = json.loads(f.read())

    def mention(self, event, say):
        say(attachments=self.attachment["attachments"])

```

## private_metadataによるデータ連携
参考記事の処理をBoltで実装すると以下のような感じになります。

```python
# モーダル表示側
def action_check_server(self, ack, body, client):
        ack()
        channel = body["channel"]["id"]
        check_view = deepcopy(self.check_view)
        check_view["private_metadata"] = channel
        client.views_open(trigger_id=body["trigger_id"], view=check_view)

# モーダルsubmit受信側
def handle_check_server_submission(self, ack, client, body, view):
        (略)
        channel = view["private_metadata"]
```

## Blockの作成
blockの作成は、基本的に[Block Kit builder](https://app.slack.com/block-kit-builder)を使って行いますが、この時に単純にコピペしただけでは利用出来ませんので注意が必要です。

#### attachment
- fallbackという項目を設定しないと、Bolt起動時にWarningが出て鬱陶しいので設定しましょう。(要約メッセージを表示する時に使うみたいです）
- action_idはわかりやすいものに変更しましょう。`app.action`の引数に指定するのがaction_idですので、パッと見てどういう動作をするか判った方が良いです。

#### modal
- callback_idを設定しましょう。`app.view`の引数に指定するのがcallback_idです。これを指定することにより、当該callback_idを持つモーダルのsubmitを拾うことができるようになります。
- actionはattachmentと同様、わかりやすく変更するのが良いと思います。

# 終わりに
RTM時代にslackbotを使っていましたが、公式がBotをPythonでサポートしたということで、かなりやりやすくなったなと思いました。
Socket Modeのおかげでローカルでのテストがめちゃくちゃ楽になったので、開発も捗ります。

と同時に、そんなに難しくないと思っていたやりたいこと(関数分割とかchannel受け渡し)がなかなか出来ず、知見もドキュメントもない状態で途方に暮れてうーんと思ったのも事実です。
ドキュメントは今後に期待しつつ、とりあえず色々使って知見を深めようかと思いました。