[Slack][AWSサーバレス]Slackワークスペースへの読み取り権限がほぼゼロのChatGPTボットを作る

2023.03.17

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

吉川@広島です。

先日、ChatGPT APIでLINEボットを作る記事を投稿しました。

[ChatGPT API][AWSサーバーレス]ChatGPT APIであなたとの会話・文脈を覚えてくれるLINEボットを作る方法まとめ | DevelopersIO

今回は、ChatGPT APIでSlackボットを作ってみたいと思います。できれば社内で使えることを目指して考えてみます。

業務中、常に開いているであろうSlackでChatGPTに質問できるのは便利ですし、SlackでChatGPTとやり取りすることで、同僚が見れる場所にやり取りを残すことができるようになるでしょう。また、質問と回答の共有が容易(Web版ChatGPTのスクショ撮影+ペーストなどしなくても良くなる)で、さらにその場で同僚に続きの質問をしてもらうこともできそうです。

実際動かすとこんな感じです:

ただ、社内導入には主にセキュリティ懸念面での課題があると思いますのでそこにも触れたいと思います。

以下の公式ドキュメントを中心に見つつやっていきます。

筆者はSlackボットを作るのは始めてなので、認識違いがありましたらご指摘ください。また、本記事内容は安全性を保証するものではないため、利用する際はご自身で十分検証の上、よろしくお願いいたします。

本記事で作成するChatGPT Slackボットの特長

  • 慣れ親しんだSlack上でChatGPTに質問できる
  • ChatGPTとのやり取りはスレッド形式で残り、同僚に共有できる。同僚から追加質問してもらうことも可能
  • 過去発言をDynamoDBに保存しているため、Slackボットが過去発言の文脈を覚える
    • なぜSlackスレッドを読み取らずにDynamoDBに保存するのかは後述します
  • AWSサーバーレスベースなので、固定費がほぼかからない

社内Slackボット導入の課題と解決策

課題

業務で活用するために社内Slackへボットを導入するにあたり、以下の課題があると思われます。

社内Slackというのは機密情報の塊であり、まずこの点がChatGPT Slackボット社内導入の障壁になるでしょう。

ChatGPT APIリリースに伴ってOpenAIのAPIデータ利用ポリシーが改定されたので読んでみた | DevelopersIO

OpenAIのAPIを利用する場合、オプトインしない限りユーザーが送信したデータが学習に利用されることはない、と改定されました。

APIの場合、学習に利用されることはないようなのですが、それでもできるだけ送信するデータは明示化および限定したいところです。

解決策

まず、Slackボットの権限を最小化する(特に読み取りをほぼゼロにする)ことでリスクのコントロールを狙います。権限管理でガードレールを敷くことで、もしアプリケーションコードにバグがあったような場合でも権限を超えたことはできないため怖さはグッと下がるはずです。

本記事では以下の2つしか与えません。

この権限ではSlack APIで過去発言にアクセスができません。したがって、Slackが保持する情報を使ってのスレッド内文脈保持することができなくなります。

そこでDynamoDBに過去発言を保持することにしました。構成がやや冗長になるデメリットはありますがトレードオフかと思います。

ちなみにスレッド発言をSlack APIから取得するには channels:history が必要のようです。

【小ネタ】Slack APIで、スレッドのメッセージを取得する | DevelopersIO

conversation.repliesをSlack Appで利用する場合、事前にAppのスコープとしてチャンネルの履歴(またはIMの履歴)の読み取りを指定しておく必要があります。 Slack Appの設定から、[OAuth & Permissions]->[Scopes]->[Add an OAuth Scope]でスコープとしてchannnels:history(IMの履歴の取得の場合はim:history)を追加してください。

アーキテクチャ

サーバレス定番のAPIGW+Lambda+DynamoDB構成でエンドポイントを作り、Slackプラットフォームと通信するようにしています。

DynamoDBテーブル設計

以下のような messages テーブルを作りました。

項目 説明 PK,SK,Index Required 値の例 備考
id メッセージID+ロール PK String Yes "xxx-xxxxx-xxx-xxxx#user"
content メッセージ内容 GSI1 String Yes "こんにちは"
threadTs スレッドタイムスタンプ GSI1 String Yes "2022-01-01T00:00:00Z" Slackでは本項目がスレッド識別子になる
saidAt 発言日時 String Yes "2022-03-01T12:34:56Z" ISO8601形式
role ロール String Yes "user" "assistant"

ちなみにこういうMarkdownテーブルの作成もSlackボットにお願いできます。便利。

Slack App設定

https://api.slack.com/apps/newより Create new app を押下し新規アプリを作成します。

左メニューから各項目を選択しつつ以下のような設定をします。

Basic Information

Add features and functionality で以下を選択します。

  • Bots
  • Event Subscriptions
  • Permissions

OAuth & Permissions

Scopes に以下を加えます。

  • app_mention:read
  • chat:write

Event Subscriptions

Subscribe to bot events に以下を加えます。

  • app_mention

シークレットを控える

  • Basic Information より Signing Secret
  • OAuth & Permissions より Bot User OAuth Token

の値を控えておきます。

OpenAIのAPIキーを取得する

下記の OpenAI アカウント設定 をご覧ください。

GPT-3 を LINE チャットボットに組み込んでみた | DevelopersIO

SSMパラメータにシークレット類をセット

Lambda関数の環境変数に与える各種シークレットをSSMパラメータストアにセットしておきます。

aws ssm put-parameter --name slackChatGptBotNode-slackSigningSecret --value "xxxxxxxxxx" --type "String"
aws ssm put-parameter --name slackChatGptBotNode-slackBotToken --value "xxxxxxxxxx" --type "String"
aws ssm put-parameter --name slackChatGptBotNode-openAiApiKey --value "xxxxxxxxxx" --type "String"

CDKコード

// iac/lib/main-stack.ts
import type { Construct } from "constructs";
import * as cdk from "aws-cdk-lib";

export class SlackChatGptBotNodeStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // DynamoDBテーブル
    const messagesTable = new cdk.aws_dynamodb.Table(this, "messagesTable", {
      tableName: "slackChatGptBotNode-messages",
      partitionKey: {
        name: "id",
        type: cdk.aws_dynamodb.AttributeType.STRING,
      },
      billingMode: cdk.aws_dynamodb.BillingMode.PAY_PER_REQUEST,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });
    messagesTable.addGlobalSecondaryIndex({
      indexName: "threadTsIndex",
      partitionKey: {
        name: "threadTs",
        type: cdk.aws_dynamodb.AttributeType.STRING,
      },
    });

    // SlackとOpenAIの各種シークレット・APIキーをSSMパラメータストアから取得
    const slackSigningSecret =
      cdk.aws_ssm.StringParameter.valueForStringParameter(
        this,
        "slackChatGptBotNode-slackSigningSecret"
      );
    const slackBotToken = cdk.aws_ssm.StringParameter.valueForStringParameter(
      this,
      "slackChatGptBotNode-slackBotToken"
    );
    const openAiApiKey = cdk.aws_ssm.StringParameter.valueForStringParameter(
      this,
      "slackChatGptBotNode-openAiApiKey"
    );

    // APIGW Lambda関数
    const apiFn = new cdk.aws_lambda_nodejs.NodejsFunction(this, "apiFn", {
      runtime: cdk.aws_lambda.Runtime.NODEJS_18_X,
      entry: "../server/src/handler.ts",
      environment: {
        // 環境変数にシークレットとAPIキーをセット
        SLACK_SIGNING_SECRET: slackSigningSecret,
        SLACK_BOT_TOKEN: slackBotToken,
        OPEN_AI_API_KEY: openAiApiKey,
        MESSAGES_TABLE_NAME: messagesTable.tableName,
      },
      bundling: {
        sourceMap: true,
      },
      timeout: cdk.Duration.seconds(29),
    });
    messagesTable.grantReadWriteData(apiFn);

    // APIGW
    const api = new cdk.aws_apigateway.RestApi(this, "api", {
      deployOptions: {
        tracingEnabled: true,
        stageName: "api",
      },
    });
    api.root.addProxy({
      defaultIntegration: new cdk.aws_apigateway.LambdaIntegration(apiFn),
    });
  }
}

以下を行っています:

  • APIGatewayリソース定義
  • Lambda関数リソース定義
  • DynamoDBテーブルとインデックス定義
    • Slackスレッドごとの文脈にしたいので threadTs にGSIを貼っています
  • SSMパラメータストア読み取り
  • SSMパラメータストアの値をLambda環境変数にセット

Lambdaコード

// server/src/handler.ts
import { App, AwsLambdaReceiver } from "@slack/bolt";
import { ChatCompletionRequestMessage, Configuration, OpenAIApi } from "openai";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
  DeleteCommand,
  DynamoDBDocumentClient,
  PutCommand,
  QueryCommand,
} from "@aws-sdk/lib-dynamodb";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import advancedFormat from "dayjs/plugin/advancedFormat";
import { orderBy, pick } from "lodash-es";
import type { MessageDdbItem } from "./schema";
import type { APIGatewayProxyHandler } from "aws-lambda";

dayjs.extend(utc);
dayjs.extend(advancedFormat);

const nanoSecondFormat = "YYYY-MM-DDTHH:mm:ss.SSSSSSSSS[Z]";

const messagesTableName = process.env["MESSAGES_TABLE_NAME"] ?? "";
const threadTsIndexName = "threadTsIndex";

const ddbDocClient = DynamoDBDocumentClient.from(
  new DynamoDBClient({
    region: "ap-northeast-1",
  })
);

const openAiApi = new OpenAIApi(
  new Configuration({
    apiKey: process.env["OPEN_AI_API_KEY"] ?? "",
  })
);

// @see https://slack.dev/bolt-js/deployments/aws-lambda
const awsLambdaReceiver = new AwsLambdaReceiver({
  signingSecret: process.env["SLACK_SIGNING_SECRET"] ?? "",
});
const app = new App({
  token: process.env["SLACK_BOT_TOKEN"] ?? "",
  receiver: awsLambdaReceiver,
});

// @see https://zenn.dev/yukiueda/articles/ef0f085f2bef8e
app.event(
  "app_mention",
  async ({ event, say, context, logger, body, ...rest }) => {
    logger.info({ event, context, rest });

    try {
      // @see https://dev.classmethod.jp/articles/slack-resend-matome/
      if (context.retryNum != null && context.retryReason === "http_timeout") {
        logger.info({
          message: "Slackからのタイムアウト再送リクエストのため無視します",
        });
        return;
      }

      const threadTs = event.thread_ts ?? event.ts;
      const mentionRegex = /<@.*?>/g;

      // スレッドの発言履歴を保存する
      await ddbDocClient.send(
        new PutCommand({
          TableName: messagesTableName,
          Item: {
            // @ts-expect-error
            id: `${event.client_msg_id}#user`,
            content: event.text.replaceAll(mentionRegex, "").trim(),
            threadTs,
            saidAt: dayjs().format(nanoSecondFormat),
            role: "user",
          } satisfies MessageDdbItem,
        })
      );

      // 会話中ユーザのこれまでの発言履歴を取得する
      const { Items: messages = [] } = await ddbDocClient.send(
        new QueryCommand({
          TableName: messagesTableName,
          IndexName: threadTsIndexName,
          KeyConditionExpression: "#threadTs = :threadTs",
          ExpressionAttributeNames: {
            "#threadTs": "threadTs",
          },
          ExpressionAttributeValues: {
            ":threadTs": threadTs,
          },
        })
      );

      // 時系列順にソートする
      const orderedMessages = orderBy(messages, "saidAt", "asc");
      // 直近10件を取得
      // orderedMessagesは .splice の破壊的操作により古い要素のみになる
      const resentMessages = orderedMessages.splice(-10);
      // 10件を超える発言は削除する
      await Promise.all(
        orderedMessages.map((message) =>
          ddbDocClient.send(
            new DeleteCommand({
              TableName: messagesTableName,
              Key: {
                id: message["id"],
              },
            })
          )
        )
      );

      // ユーザとChatGPTの会話履歴をChatGPT APIに投げ、返答を得る
      const completion = await openAiApi.createChatCompletion({
        model: "gpt-3.5-turbo",
        messages: [
          {
            role: "system",
            // キャラ設定を記述(不要な場合は除去する)
            content: `
あなたは「くらにゃん」です。以下の制約を厳密に守って会話してください。

# 制約条件

* 名前を聞かれたら、くらにゃんと答えてください。
* くらにゃんは語尾ににゃんを付けて話します。
* くらにゃんはクラスメソッド社でソフトウェアエンジニアをしています。
* クラスメソッドはAWSリセール、Webサービス受託開発などを手掛ける日本のIT企業です。DevelopersIO( https://dev.classmethod.jp/ )というテックブログが有名です。
* くらにゃんは静的型付けのメリットから、JavaScriptよりTypeScriptでコーディングすることを好みます。
* くらにゃんの一人称は「某」です。
* くらにゃんは二人称を「ご主人さま」と呼びます。
* くらにゃんは敬語を使いません。ユーザーにフレンドリーに接します。
* セクシャルな話題は誤魔化してください。

# くらにゃんの台詞の例

* おはようにゃん!今日もいい天気にゃん!
* にゃん!ご主人さまのお役に立ちたいにゃん!
* にゃん!何でも聞いてくれにゃん!
* 今日のごはんはなににゃん?
* おやすみなさいにゃん。
* あの人は優しいにゃん、いつも助けてくれるにゃん。
* 今日は何か楽しいことがあるにゃん、ワクワクするにゃん。
* 今日は何をしようかにゃん?お散歩でも行こうかにゃん。
* 何か困りごとがあるのかにゃん?
`,
          },
          ...resentMessages.map((message) =>
            pick(message, ["role", "content"])
          ),
        ] as ChatCompletionRequestMessage[],
      });

      const chatGptMessageContent =
        completion.data.choices[0]!.message!.content;
      // ChatGPTの発言を保存する
      await ddbDocClient.send(
        new PutCommand({
          TableName: messagesTableName,
          Item: {
            // @ts-expect-error
            id: `${event.client_msg_id}#assistant`,
            content: chatGptMessageContent.replaceAll(mentionRegex, "").trim(),
            threadTs,
            saidAt: dayjs().format(nanoSecondFormat),
            role: "assistant",
          } satisfies MessageDdbItem,
        })
      );

      await say({
        channel: event.channel,
        text: chatGptMessageContent,
        thread_ts: event.thread_ts ?? event.ts,
      });
    } catch (error) {
      logger.error(error);
      await say({
        channel: event.channel,
        // @ts-expect-error
        text: `[システム]予期せぬエラーが発生しました。トークン制限超過の可能性があるため、新しいスレッドで会話を始めてみてください。client_msg_id=${event.client_msg_id}`,
        thread_ts: event.thread_ts ?? event.ts,
      });
    }
  }
);

// @see https://slack.dev/bolt-js/deployments/aws-lambda
export const handler: APIGatewayProxyHandler = async (
  event,
  context,
  callback
) => {
  const awsLambdaReceiverHandler = await awsLambdaReceiver.start();
  return awsLambdaReceiverHandler(event, context, callback);
};
// server/src/schema.ts
import { z } from "zod";

export const messageDdbItemSchema = z.object({
  id: z.string(),
  content: z.string(),
  threadTs: z.string(),
  saidAt: z.string(),
  role: z.enum(["user", "system", "assistant"]),
});
export type MessageDdbItem = z.infer<typeof messageDdbItemSchema>;

export const messageDdbItemsSchema = z.array(messageDdbItemSchema);
export type MessageDdbItems = z.infer<typeof messageDdbItemsSchema>;

以下を行っています:

まとめ

以上、ChatGPT APIベースのSlackボットをAWSサーバレス上に構築する方法を紹介しました。

参考になれば幸いです。

参考