SlackでChatGPTボットアプリをつくる

# はじめに

現時点でまだSlackのChatGPT公式アプリはリリースされておりませんが
OpenAIからAPIは提供されていますのでこれを使ってSlack上でChatGPTとやりとりができるボットアプリの作成方法を共有したいと思います。

今回はPythonのFlaskを利用した例となります。
OpenAIの提供しているPython公式SDKを利用します。

# 処理仕様

ユーザーからAIへの質問の入力を受ける方法はアプリへのメンションイベントとしてEvent Subscriptionsの`app_mention`を拾うことにします。

処理設計の中で重要なポイントとしては**データベースを極力利用せずに会話履歴を保持する**方法です。
ChatGPTのWeb画面ではトピックごとにNew chatを立てていくと思いますがこれをどうやってSlackのインターフェースの中で実現していくのかがポイントとなります。

これについては**New chatごとにスレッドを立ててもらう**方法を取りました。
スレッドの内容を取得できるAPIエンドポイント`conversations.replies`で会話履歴を構築できます。

以上を考慮した処理フローは以下のようになりました。

```mermaid
sequenceDiagram
  autonumber
  actor ユーザ
  participant SL as Slack
  participant AP as Python Flask APP
  participant AI as OpenAI
  ユーザ->>SL:AI質問をメンション
  SL->>AP:app_mention
  AP->>SL:conversations.replies
  SL-->>AP:スレッド内会話一覧
  AP->>AI:プロンプト送信
  AI-->>AP:AI回答
  AP->>SL:chat.postMessage
  SL->>ユーザ:AI回答を投稿
  SL-->>AP:OK
```

# 処理内容

必要なPythonライブラリは以下となります。
```sh
pip install requests
pip install openai
```
```python
import requests
import openai
```

## メンションイベント受信

```python:example
@app.route("/openai_mention", methods=["POST"])
def openai_mention():

    # リトライを無視する対応
    if "X-Slack-Retry-Num" in request.headers and request.headers["X-Slack-Retry-Reason"] == "http_timeout":
        print('ignore retry!!!')
        return {'text': 'ignore retry!!!'}

    request_json = request.json
    print(request_json)

    # jsonから必要なパラメータを取得する
    text = request_json["event"]["text"]
    user_id = request_json["event"]["user"]
    channel_id = request_json["event"]["channel"]
    thread_ts = ''
    url = ''

    # スレッド内のメンションの場合はスレッドIDを取得する
    if "thread_ts" in request_json["event"]:
        thread_ts = request_json["event"]["thread_ts"]

```

Event SubscriptionsのRequest URLは`/openai_mention`をコールするエンドポイントを指定してあげてください。

Event Subscriptionsでは**3秒以内にレスポンスしないとリトライ**する仕様があります。
サーバーレス環境などでの運用ではアプリがコールドスタートした場合に返答にどうしてもタイムラグが発生してしまいます。
これを食らってしまうと重複処理が起こるのでリトライのリクエストをチェックし無視する対策を入れています。

イベントのパラメータからどのチャンネル`channel_id`で誰`user_id`が何を発言`text`をどのスレッド`thread_ts`でしたのかなどの情報を取得します。

## スレッド情報取得

```python:example

APP_USER_ID_RAW = '@UXXXXXXXXXX'
APP_USER_ID = f"<@{APP_USER_ID_RAW}>"

payload = {'token':APP_TOKEN, 'channel':channel, 'ts': thread_ts}
res = post("https://slack.com/api/conversations.replies", data=payload)
res_json = res.json()
messages = res_json['messages']
for message in messages:
    # アプリへのメンションの場合
    if APP_USER_ID in message['text']:
        # 制御文字を抜く
        input = message['text'].replace(APP_USER_ID, '')
        q_message = {"role": "user", "content": input}
        prompt.append(q_message)
    # AIの回答の場合
    elif APP_USER_ID_RAW in message['user']:
        input = message['text']
        a_message = {"role": "assistant", "content": input}
        prompt.append(a_message)
```

チャンネルとスレッドの情報をパラメータとして`conversations.replies`を実行するとスレッド情報一覧が取得できます。
スレッドの内のメッセージを一つずつ走査して以下の処理を行います。

* 自分の質問内容の文字列内に`APP_USER_ID`と一致するものが存在しているかで判定しそれを除去する（これを`'role': 'user'`としている）
* AIの返答については`APP_USER_ID_RAW`が`user`と一致しているかで判定する（これを`'role': 'assistant'`としている）

`APP_USER_ID_RAW`にはアプリのメンバーIDを設定します。
`APP_USER_ID`にはメンションされた際にメッセージの中に含まれるアプリのメンバーID文字列を格納しておきます。

なぜこのような制御をしてるかというとスレッド内に関係ないメッセージが紛れ込んでくる可能性がありますので明確にAIへの質問と回答だけを正しくパラメータにセットするためです。

プロンプトパラメータサンプル
```js:example
{'role': 'user', 'content': ' 令和元年の西暦は？'}, 
{'role': 'assistant', 'content': '令和元年は、西暦2019年にあたります。'}, 
{'role': 'user', 'content': ' 平成は？'}, 
{'role': 'assistant', 'content': '平成元年は、西暦1989年にあたります。'}, 
{'role': 'user', 'content': ' 昭和は？'}
```
スレッド内容を解析後最終的にはこのようなパラメータが構築されることになります。
一番最後の部分が新しいAIへの質問内容となります。

## ChatGPTプロンプト送信＆回答をSlackに投稿

```python:example
    result = openai.ChatCompletion.create(model=AI_ENGINE, messages=prompt)

    ai_response = result.choices[0].message.content
    print(ai_response)

    # メッセージ送信用のPOST
    payload = {'token':APP_TOKEN, 'channel':channel, 'text': ai_response}
    # スレッド内の場合はそこに打ち返すこと
    if thread_ts != '':
        payload['thread_ts'] = thread_ts

    res = requests.post("https://slack.com/api/chat.postMessage", data=payload)
```

AIから受け取った回答を`chat.postMessage`を指定のスレッド`thread_ts`に投稿します。
万が一`thread_ts`が未指定の場合はチャンネルにそのまま投稿しておくことにします。

## その他

必要なBot Token Scopesは以下です。

* app_mentions:read
* channels:history
* chat:write

最後に作成したアプリをチャンネルにジョインさせてあげます。
これでアプリにメンションするだけの非常にシンプルな使い方になります。