社内SlackにAIを召喚してみた【ChatGPT】

こんにちは、サーバーサイドエンジニアの竹田です。

今回は昨今話題になっているChatGPTを社内Slackで使えるようにしたいと声があり、それをSlack Botで実装したので、手順やつまづいたところなどを話していきます。

また、上記画像はGPT-4にいい感じの表現を書いてもらって、niji journey5で生成してもらいました。

リポジトリ

https://github.com/ambr-tech/chat-gpt-slack

システム構成図

構成は上記のようになっており、SlackでユーザーがBotにメンションすることでイベントが発生します。 ChatGPTへ質問を投げて回答が得られたら、ユーザーが送信したメッセージのスレッド内に返信します。

以下のような具合で動作します。(レスポンスに時間がかかるため、頑張って生成中であることを絵文字で表現してます)

実装した機能

  • Botへメンションをつけて質問を投げると、ChatGPTからのレスポンスがスレッド内で返ってくる
    • パブリックチャンネル、BotへのDMで利用可能
  • スレッド内にある過去のBotへのメンション付きのリプライもChatGPTへ送信するようにした
  • 一度送信したメッセージを編集するとレスポンスも再生成される
  • setコマンドでChatGPTへのsystemロールのcontentを設定できる
  • listコマンドで設定内容を確認できる
  • Slackが発行するSigning Secretを利用した認証

セットアップ

Slackアプリの作成

まず、Slackのアプリの作成します。

api.slack.com

上記のページに行き、右上にある「Create New App」をクリックします。

「From scratch」をクリックします。

「App Name」には適当な名前を入力し、「Pick a workspace to develop your app in:」にはインストール先のワークスペースを選択します。

Slackアプリの設定

OAuth & Permissions

左のサイドメニューから「OAuth & Permissions」をクリックします。

ページ中部あたりにある、Scopesを以下のように設定します。 Botがアクセスできる範囲を指定します。

  • app_mentions:read
    • Botがメンション付きのメッセージを読み取れる
  • channels:history
    • Botが追加されたパブリックチャンネルで過去のメッセージを読み取れる
  • chat:write
    • Botが投稿できる
  • chat:write.customize
    • Botがカスタマイズされたユーザー名とアバター画像で投稿できる
  • im:history
    • Botが追加されたDMで過去のメッセージを読み取れる

ページ上部にいき、「Install to Workspace」をクリックします。

「Allow」をクリックします。

インストールが完了して、「OAuth & Permissions」のページに戻ると、「OAuth Tokens for Your Workspace」にトークンが表示されます。

今回は「Bot User OAuth Token」を使って、BotからSlackへメッセージを送信するのに使用します。

App Home

BotへのDMができるように、以下の設定をする必要があります。

左のサイドメニューから「App Home」をクリックし、ページ下部あたりにある「Show Tabs」の以下の画像のように設定してください。

Slackアプリの設定は一旦ここまでにして、次はAWS上で環境を構築します。

リポジトリのクローン

リポジトリのリンク: https://github.com/ambr-tech/chat-gpt-slack

まずはリポジトリをクローンし、ワーキングディレクトリをリポジトリへ移動します。

$ git clone https://github.com/ambr-tech/chat-gpt-slack
$ cd chat-gpt-slack

このリポジトリにはAWSのインフラを作成するcdkフォルダと、SlackでユーザーがBotへメッセージを送った時に実際に実行されるコード、appフォルダがそれぞれあります。

$ ls -d -1 */
app/
cdk/

インフラの構築

Lambdaのビルド

まずはLambdaのビルドを行います。

LambdaはPython3.9を使っていますので、お好きなツールでバージョンを切り替えてください。

今回はpyenvを使ってバージョンを切り替えます。

$ pyenv install 3.9

$ pyenv global 3.9

$ python -V
Python 3.9.16

$ make
help                           Show help message
lint-lambda                    Lint lambda
lint-fix-lambda                Lint fix lambda
build                          Build lambda function
build-ignore-lib               Build lambda function, but not re-installing the libraries
deploy                         Deploy using AWS CDK

$ make build

ビルドが成功するとapp/dist/lambda.zipが作成されます。

$ ls -1 app/dist
lambda.zip
Systems ManagerのParameter Storeへキーの登録

CDKのデプロイに必要な以下のキーを、AWS Systems ManagerのParameter Storeに登録します。

  • /chat-gpt-slack/OPEN_AI_API_KEY
  • /chat-gpt-slack/SLACK_BOT_TOKEN
    • BotがSlackへアクセスする際に必要なトーク
    • SlackAPIのサイトのサイドメニューにあるOAuth & PermissionsからBot User OAuth Token
  • /chat-gpt-slack/SLACK_SIGNING_SECRET
    • Lambdaを実行する際に、リクエストがSlackから来たのかを検証するのに必要なシークレット
    • SlackAPIのサイトのサイドメニューにあるBasic InformationからAppCredentialsにあるSigning Secret

以下コマンドで登録します。

<>で囲んでいるものをそれぞれ変えて実行してください。

aws ssm put-parameter --name "/chat-gpt-slack/OPEN_AI_API_KEY" --value <OPEN_AI_API_KEY> --type "String"
aws ssm put-parameter --name "/chat-gpt-slack/SLACK_BOT_TOKEN" --value <SLACK_BOT_TOKEN> --type "String"
aws ssm put-parameter --name "/chat-gpt-slack/SLACK_SIGNING_SECRET" --value <SLACK_SIGNING_SECRET> --type "String"

登録が完了するとAWSマネジメントコンソールから確認できます。

https://ap-northeast-1.console.aws.amazon.com/systems-manager/parameters

CDKを使ってデプロイ

Makefileを使ってCDKをデプロイします。

CDKを使ったデプロイが初めての場合、cd cdk && cdk bootstrap <AWS_ACCOUNT_NUMBER>/<REGION>する必要があります。1

$ make deploy

デプロイが成功するとAWSマネジメントコンソールから確認できます。

https://ap-northeast-1.console.aws.amazon.com/cloudformation/home

該当のスタックを選択し、「Resources」タブから作成したリソースを確認できます。

SlackのEvent Subscriptionsの設定

作成されたLambdaのページに行き、「Configuration」タブからサイドメニューにある「Triggers」を押下します。

URLが表示されているので、末尾が/prod/のものをコピーしてください。

Slack APIのページに行き、サイドメニューから「Event Subscriptions」をクリックします。

以下の画像のように、「Enable Events」をOnにし、RequestURLにLambdaからコピーしたURLを貼り付けます。

そして、「Subscribe to bot events」に以下のイベントを追加します。

  • app_mention
    • Botへメンションが投げられたときイベントを発火する
  • message.im
    • DMでメッセージが投稿されたときイベントを発行する

これらで設定は完了です。

SlackでパブリックチャンネルにBotを招待してメンションをつけてメッセージを送るとレスポンスが返ってくるようになります。

実装内容についての説明

ts, thread_tsについて

Botからユーザーへレスポンスを送る際にスレッド内で返信を送るようにしているのですが、送るのに必要なものがtsまたはthread_tsです。

ts及びthread_tsはチャンネルごとにユニークなタイムスタンプです。 これを使用することでスレッド内の返信が行えるようになります。

今回は以下のように順に取得しています。

まずbody_eventからthread_tsを取得してみます。これがあれば、すでにスレッドが生成されている状態です。

ない場合、body_event.messageを取得し、あればbody_event.message.thread_tsを取得します。
body_event.messageはDM上でユーザーがメッセージを変更した際に送信されるもので、body_event.tsの前にbody_event.message.thread_tsを確認します。

最後まで何も見つけられなければ、body_event.tsを取得します。

thread_ts = body_event.get("thread_ts")
if not thread_ts:
    message = body_event.get("message")
    if message:
        thread_ts = message.get("thread_ts")
    if not thread_ts:
        thread_ts = body_event.get("ts")

過去のリプライもChatGPTへ送信

1回切りのやりとりではなく、過去の会話内容も含めた上でChatGPTにレスポンスを生成してもらいたかったので、 Slackの「OAuth & Permissions」にchannels:history(パブリックチャンネル内の履歴読み取り権限)とim:history(BotとのDM内の履歴読み取り権限)を付与し、 以下のようなコードを書いて履歴を取得し、フィルタしてself.thread_messagesに詰め込んでいます。

メッセージ履歴からユーザーかを判別し、ユーザだった場合はメンションがついているかを確認し、メンションを除去した上でroleuserとして追加しています。

Botだった場合は、メンションを除去した上でroleassistantとして追加しています。

def _append_assistant_role(self, text: str) -> dict:
    return self.thread_messages.append(
        {"role": "assistant", "content": text})

def _append_user_role(self, text: str) -> dict:
    return self.thread_messages.append({"role": "user", "content": text})

def thread_replies(self, updated_text: str = None) -> List[Dict]:
    messages: list = self.client.conversations_replies(
        channel=self.channel, ts=self.thread_ts
    ).get("messages")
    logger.info(f'THREAD REPLIES: {messages}')

    for message in messages:
        text = message.get("text")

        # プログレスメッセージは無視する
        if text == constants.SLACK_PROGRESS_MESSAGE:
            continue

        # Botが送信したメッセージの場合
        if message.get("bot_id"):
            text = utils.remove_mention(text)
            self._append_assistant_role(text)
            continue

        # ユーザがメンション指定している場合
        if utils.mention_matches(text):
            text = utils.remove_mention(text)
            if text != "":
                self._append_user_role(text)

            continue
    # ...

    return self.thread_messages

BotへのDMの対応

SlackのEvent Subscriptionsの「Subscribe to bot events」にmessage.imを追加することで、 DMでBot宛てにメッセージを送信すると反応するようになりますが、 message.imが検知するイベントは幅広く、以下全てをLambda側でキャッチし、ChatGPTのレスポンス生成をしないようにしなければなりません。

  • BotがDM上でメッセージを送信した場合
  • BotがDM上でメッセージを変更した場合
  • BotがDM上でメッセージを削除した場合
def event_triggered_by_bot(body_event: dict) -> bool:
  # Botによるメッセージ送信がトリガーとなったとき
  bot_id = body_event.get("bot_id")
  if bot_id:
      return True

  subtype = body_event.get("subtype")

  # Botによるメッセージ変更、削除がトリガーとなったとき
  if subtype in [
          SLACK_MESSAGE_SUB_TYPE_MESSAGE_CHANGED, # message_changed
          SLACK_MESSAGE_SUB_TYPE_MESSAGE_DELETED # message_deleted
  ]:
      message = body_event.get("message")
      if message:
          bot_id = message.get("bot_id")
          if bot_id:
              return True

  return False
  • ユーザがメッセージを削除した場合
  • ユーザがメッセージを削除後に、削除したメッセージがSlackBotの投稿に変わるとき

ユーザがメッセージを削除後に、削除したメッセージがSlackBotの投稿に変わるとき

上記に関しては中々トリッキーで、ユーザーがメッセージを削除したとき、以下のような画像になりますが、変わった瞬間にtombstoneなるイベントが発生します。

def event_triggered_by_user_message_delete_on_dm(body_event: dict) -> bool:
  subtype = body_event.get("subtype")

  if subtype == SLACK_MESSAGE_SUB_TYPE_MESSAGE_DELETED: # message_deleted
      previous_message = body_event.get("previous_message")
      if previous_message:
          client_msg_id = previous_message.get("client_msg_id")
          if client_msg_id:
              return True

  # メッセージが削除された後に、削除したメッセージがSlackBotの投稿に変わってイベントが発生してしまう
  if subtype == SLACK_MESSAGE_SUB_TYPE_MESSAGE_CHANGED: # message_changed
      message = body_event.get("message")
      if message:
          message_subtype = message.get("subtype")
          if message_subtype == SLACK_MESSAGE_SUB_TYPE_MESSAGE_TOMBSTONE: # tombstone
              return True

  return False

ユーザーがメッセージを変更したときのChatGPTのレスポンス再生成

ユーザーが送ったメッセージに何か誤字が合った場合などに、すでに送ったメッセージを編集してChatGPTからのレスポンスを再生成して欲しい際に対応できるようにしました。

まず、以下のようにユーザーがメッセージを変更したことと、変更したメッセージを取得しておいて、

user_edited_message = False
# DMでユーザがメッセージを変更したとき
if event_triggered_by_user_message_edit_on_dm(body_event):
    text = body_event.get("message").get("text")
    user_edited_message = True
# チャンネルでユーザがメッセージを変更したとき
if event_triggered_by_user_message_edit_on_channel(body_event):
    text = body_event.get("text")
    user_edited_message = True

ユーザーがメッセージを変更したことを格納する変数user_edited_messageTrueであれば、 ChatGPTへリクエストする際に含めるメッセージの一番後ろに追加するようにしました。

def _append_user_role(self, text: str) -> dict:
    return self.thread_messages.append({"role": "user", "content": text})

def thread_replies(self, updated_text: str = None) -> List[Dict]:
    # ...

    # ユーザがメッセージを変更したとき、最新のメッセージとして扱う
    if updated_text:
        self._append_user_role(updated_text)

    return self.thread_messages

各ユーザーでChatGPTのsystemロールのcontentの設定

元々はLambdaに定数としてsystemロールのcontentをハードコードしていましたが、ユーザーごとに設定した方がいいだろうとのことで対応しました。

データの保存先はDynamoDBにしました。

また、Slack上でBotへの問い合わせでsystemロールのcontentを設定できるようにしたかったので、setコマンドを用意しました。

例として以下のようなコマンドが実行できます。

  • @bot_name set system_role_content あなたの名前はAliceです

まずユーザーがBotへメッセージを送信した時に、setから始まるコマンドであることを確認し、2つ目の引数(system_role_content)が設定可能なフィールドであることを確認。最後に3つ目の引数(あなたの名前はAliceです)を設定します。

if utils.is_set_command(text):
    result, message = utils.validate_set_command(text)
    if not result:
        slackClient.send_text_to_channel(message)
        return Response.success()

    set_command = SetCommand(text, user_id)
    set_command.set_key_value()
    slackClient.send_text_to_channel(
        f"SET {set_command.key}: {set_command.value}"
    )
    return Response.success()

また、設定内容を確認するためにlistコマンドも確認しました。

例として以下のようなコマンドが実行できます。

  • @bot_name list user_config

まずユーザーがBotへメッセージを送信した時に、listコマンドであることを確認し、2つ目の引数(user_config)が存在しているテーブルであることを確認し、あれば内容を表示します。

elif utils.is_list_command(text):
    result, message = utils.validate_list_command(text)
    if not result:
        slackClient.send_text_to_channel(message)
        return Response.success()

    list_command = ListCommand(text, user_id)
    message = list_command.list_key_value()
    slackClient.send_text_to_channel(message)
    return Response.success()

Signing Secretを利用した認証

LambdaへリクエストされるものがSlackから来たかを認証するために、Signing Secretを利用します。

以下の方法で認証します。

  • ヘッダーからX-Slack-Request-TimestampX-Slack-Signatureを取得します。

    • 片方がなければ無効な署名です。
  • X-Slack-Request-Timestampが現在の時間から5分より前から来ていないことを確認します

    • replay attack(反射攻撃)を防止するために確認しています。
    • 最近のリクエストであることを確認します。
  • Signing Secretを利用してv0:timestamp:bodyをHMAC SHA256でハッシュ化し、そのハッシュの16進数文字列を取得します。

    • X-Slack-Signatureと計算したハッシュが同じかを確認します。
def has_valid_signature(headers: dict, body: dict) -> bool:
    timestamp = headers.get("X-Slack-Request-Timestamp")
    signature = headers.get("X-Slack-Signature")
    if not timestamp or not signature:
        return False

    time_diff = int(time.time()) - int(timestamp)
    if time_diff > 60 * 5:
        return False

    request_body_sig = "v0=" + hmac.new(
        constants.SLACK_SIGNING_SECRET,
        f'v0:{timestamp}:{body}'.encode(),
        hashlib.sha256
    ).hexdigest()
    if signature != request_body_sig:
        return False

    return True

余談

弊社でChatGPTを導入し始めたのは3月末あたりからで、その頃はgpt-3.5-turboを使っていました。
ですがウェイトリスト待ちだったgpt-4が5月頃にようやく解禁されました!
gpt-4は、gpt-3.5-turboより抜群に文脈を理解してくれますね。
解禁までおおよそ1ヶ月くらいでしたので、目安にしていただければと思います。

参考

https://pkg.go.dev/github.com/aws/aws-cdk-go/awscdk/v2

https://boto3.amazonaws.com/v1/documentation/api/latest/index.html

https://api.slack.com/methods

https://qiita.com/melty_go/items/a6929b0a341e75d24f01

https://dev.classmethod.jp/articles/slack-chat-gpt-bot/

https://cly7796.net/blog/other/validate-requests-from-slack-using-signing-secret/

https://api.slack.com/authentication/verifying-requests-from-slack

https://e-words.jp/w/%E5%8F%8D%E5%B0%84%E6%94%BB%E6%92%83.html