🗨

SlackでChatGPTとの会話履歴をスレッドごとに分けて運用する

2023/03/21に公開

Slackでのコミュニケーションを最適化したいエンジニアの皆さん、ChatGPTとのやり取りをスレッドごとに整理することで、ストレスなく生産性を高めることができます。さあ、手軽に実践してみましょう。
※このイントロ文は、ChatGPTに作ってもらいました。

まえおき

皆さん、ChatGPTを使い倒していらっしゃいますか?
ほとんどの方はChatGPTを試すのにWebサイトから行われているかと思いますが、企業などで利用される場合が想定され、ChatGPT APIを使ってSlack Next-gen platformでSlack Botを動かす方法が、LayerXさんの記事で紹介されています。

https://tech.layerx.co.jp/entry/2023/03/06/chatgpt-on-slack-new-platform

こちらの記事を思いっきり参考にさせていただき、弊社でもSlackワークスペースからChatGPTを呼び出せるようにしました。
しかし、単にこれだけでは単発のプロンプトにのみ回答が得られるだけで、対話的に回答を引き出していく方法が取れません。

そこで、会話履歴を保持し、一定のコンテキストが維持できるよう改修することにしました。
Slack Next-gen platform で会話履歴を保持する方法については、Leaner Technologiesさんの記事で紹介されています。こちらは先程のLayerXさんの記事を参考にされているということで、流れ的にも非常に参考になりました。

https://zenn.dev/leaner_dev/articles/20230309-slack-miku-chatgpt

弊社では

  • 社内の様々なメンバーがそれぞれChatGPTを有効活用できるようにしつつ
  • プロンプトをどのように工夫して利用しているかお互いに見えるようにしてノウハウ共有を促したい

この2つの目的があったため

  • 会話履歴はあくまでスレッド単位で保持する
  • 最初のChatGPTからの回答のみ本スレッドにも投稿

という仕様で実装しました。
今回は、この実装を行う上で詰まってしまったポイントなどを紹介したいと思います。

実装の注意点

thread_tsapp_mentioned イベントでは直接受け取れない

スレッドで行われる会話に対してChatGPTに回答させる場合、必要になるのはSlackメッセージの中の thread_ts という値です。
thread_ts は、スレッド内のメッセージから見て、スレッドがぶら下がっている親メッセージのIDを示すもので、ChatGPTの新しい回答はこのthread_ts を指定して投稿することになります。

しかし、この thread_tsapp_mentioned イベントには付与されていません。
https://api.slack.com/future/triggers/event#response-object

そのため、このイベントトリガーの仕組みで取得したい場合は message_posted イベントに変更する必要があります。
それに合わせ、functionの処理の冒頭で「自分へのメンションでない場合は終了する」という処理を入れる必要があります。

...
    const botUserId = env.BOT_USER_ID;
    const regex = new RegExp("<\@" + botUserId + "\>", "g");
    if (!inputs.question.match(regex)) {
      // Botへのメンションで無ければ終了
      return await { outputs: {} };
    }
...

message_posted イベントでは filter の設定が必須

ただし message_posted に対応する場合、注意点があります。それはトリガーの定義の中で filter の設定が必須になるということです。

これは、Botが投稿した内容をBot自身が拾ってしまい、またBotが投稿して・・・というような無限ループを防ぐためにSlack側で設けられている制約です。
https://api.slack.com/future/triggers/event#loops

しかし、この filter ですが記法が詳細にドキュメント上で書かれているわけではなく・・・意図通りのfilterを書くことができず、ドハマりしてしまいました。
最終的に、下記で提供されているサンプルコード集の中から記法のサンプルを見つけ出したことで解消しました。

https://github.com/slack-samples

例えば「アプリでないユーザー投稿かつスレッドへの投稿でない」としたい場合は以下のように記載します。

...
filter: {
      version: 1,
      root: {
        operator: "AND",
        inputs: [
          {
            operator: "NOT",
            inputs: [{
                // Filter out posts by apps
                statement: "{{data.user_id}} == null"
            }],
          },
          {
            // Filter out thread replies
            statement: "{{data.thread_ts}} == null",
          },
        ],
      },
    },
...

みなさんは、statementの中で != するのではなく、operator NOT を利用するということをお忘れなきよう。

トリガー定義ファイル内のNPEがログもなく処理されてしまう

以下のように thread_ts があればワークフローの入力に含めるように定義していました。

...
inputs: {
    channel_id: { value: "{{data.channel_id}}" },
    user_id: { value: "{{data.user_id}}" },
    message_ts: { value: "{{data.message_ts}}" },
    thread_ts: { value: "{{data.thread_ts}}" },
    question: { value: "{{data.text}}" },
  },
...

data.thread_ts は Nullableであることは把握していたものの、ワークフローの任意パラメータとしていたため問題ないと考えていました。しかし、実際に thread_ts が nullなデータが来た場合、エラーログが何も表示されないまま処理が止まります。
前述のfilterの問題と相まって、全くログもなくワークフローが起動しないという状態に陥ってしまい、時間が溶けてしまいました。

まとめ

Slack Next-gen platformを利用して、スレッド内でのChatGPT実装を行う際に特にハマったポイントを記載しました。
みなさんも、この点に注意して実装してみてくださいね。

最後に、弊社で修正したファイルをいくつか紹介して終わりたいと思います。

トリガー

どちらも同じワークフローを呼び出しますが、thread_ts パラメータが渡される場合とそうでない場合がある点にのみ差があります。これは前述の、data.thread_ts がNullableのときの挙動が影響しており、同じファイルで処理させることができなかったためです。

triggers/message_trigger.ts
import { Trigger } from "deno-slack-api/types.ts";
import ChatGPTWorkflow from "../workflows/chatgpt_workflow.ts";

const messagePostedTrigger: Trigger<typeof ChatGPTWorkflow.definition> = {
  type: "event",
  name: "Trigger workflow with message posted",
  workflow: `#/workflows/${ChatGPTWorkflow.definition.callback_id}`,
  event: {
    event_type: "slack#/events/message_posted",
    channel_ids: [
      "XXXXXX", // customize your channel id.
    ],
    filter: {
      version: 1,
      root: {
        operator: "AND",
        inputs: [
          {
            operator: "NOT",
            inputs: [{ statement: "{{data.user_id}} == null" }],
          },
          {
            statement: "{{data.thread_ts}} == null",
          },
        ],
      },
    },
  },
  inputs: {
    channel_id: { value: "{{data.channel_id}}" },
    user_id: { value: "{{data.user_id}}" },
    message_ts: { value: "{{data.message_ts}}" },
    question: { value: "{{data.text}}" },
  },
};

export default messagePostedTrigger;
triggers/thread_message_trigger.ts
import { Trigger } from "deno-slack-api/types.ts";
import ChatGPTWorkflow from "../workflows/chatgpt_workflow.ts";

const threadMessageTrigger: Trigger<typeof ChatGPTWorkflow.definition> = {
  type: "event",
  name: "Trigger workflow with thread message posted",
  workflow: `#/workflows/${ChatGPTWorkflow.definition.callback_id}`,
  event: {
    event_type: "slack#/events/message_posted",
    channel_ids: [
      "XXXXXX",  // customize your channel id.
    ],
    filter: {
      version: 1,
      root: {
        operator: "AND",
        inputs: [
          {
            operator: "NOT",
            inputs: [{ statement: "{{data.user_id}} == null" }],
          },
          {
            operator: "NOT",
            inputs: [{ statement: "{{data.thread_ts}} == null" }],
          },
        ],
      },
    },
  },
  inputs: {
    channel_id: { value: "{{data.channel_id}}" },
    user_id: { value: "{{data.user_id}}" },
    message_ts: { value: "{{data.message_ts}}" },
    thread_ts: { value: "{{data.thread_ts}}" },
    question: { value: "{{data.text}}" },
  },
};

export default threadMessageTrigger;

ファンクション

datastoreから、質問があったthread_ts内の会話履歴があるかを確認し、それを付け加えつつ次の回答を生成します。

functions/chatgpt_function.ts
...
export default SlackFunction(
  ChatGPTFunction,
  async ({ inputs, env, token }) => {
    const botUserId = env.BOT_USER_ID;
    const regex = new RegExp("<\@" + botUserId + "\>", "g");
    if (!inputs.question.match(regex)) {
      // Botへのメンションで無ければ終了
      return await { outputs: {} };
    }

    const role = "user";
    const content = inputs.question.replaceAll(regex, " ");
    const apiKey = env.OPENAI_API_KEY;
    const client = SlackAPI(token);

    const historyResponse = await client.apps.datastore.get({
      datastore: "talkHistories",
      id: inputs.thread_ts ? inputs.thread_ts : inputs.message_ts,
    });
    const history = (historyResponse.item.history || []).map((json: string) => {
      // 参照元と同様、object型だとうまくいかずJSON文字列形式で保存している
      return JSON.parse(json);
    });

    const answer = await requestOpenAI(content, role, apiKey, history);

    if (answer.outputs) {
      await client.chat.postMessage({
        channel: inputs.channel_id,
        thread_ts: inputs.thread_ts ? inputs.thread_ts : inputs.message_ts,
        reply_broadcast: !inputs.thread_ts, // 初回のChatGPT回答だけチャンネルにも送信する
        text: answer.outputs.answer,
      });

      const newHistories = [
        ...history,
        { role: "user", content },
        { role: "assistant", content: answer.outputs.answer },
      ].slice(MAX_CONVERSATIONS * -1);

      const thread_ts = inputs.thread_ts ? inputs.thread_ts : inputs.message_ts;
      await client.apps.datastore.update({
        datastore: "talkHistories",
        item: {
          thread_ts,
          history: newHistories.map((v) => JSON.stringify(v)),
        },
      });
    } else {
      await client.chat.postMessage({
        channel: inputs.channel_id,
        thread_ts: inputs.thread_ts ? inputs.thread_ts : inputs.message_ts,
        reply_broadcast: !inputs.thread_ts,
        text: answer.error,
      });
    }

    return await { outputs: {} };
  },
);
...

おまけ

もし message_posted を使わず、 app_mentioned のままで thread_ts を使いたい場合は、app_mentioned で受け取った message_ts を以下のWebAPIの conversations.replies メソッドにパラメータとして与えることでも取得できます。
https://api.slack.com/methods/conversations.replies

(実装し終えてから気付いてしまったのですが、こちらのほうがトリガーを複数作らなくて済みそうですね。)

TimeTree Tech Blog

Discussion