LoginSignup
5
1
お題は不問!Qiita Engineer Festa 2023で記事投稿!

ChatGPT APIを使ったSlack botをTypeScript / Boltフレームワーク / Serverless Frameworkで実装する 〜セットアップからデプロイまで〜

Last updated at Posted at 2023-07-20

はじめに

業務と並行してOpenAI APIで遊んでいますが、遊んでばかりだと顰蹙を買いそうなので、社内のお役立ちツールとして、みんな大好きSlack Botを作りました。

作ったBotは、控えめに言って、"よくあるやつ"です。

  • メンションで起動
  • スレッドの場合はスレッドの会話を踏まえて回答

たいへんよくある量産型botではあるので、スムーズに作れるだろうと思っていましたが、Serverless Frameworkを使ったAWSへのデプロイでたいへん詰まりました。 やれやれ、これだから人生ってやつは。

Serverless Frameworkを使った公式ガイドがあるのですが、これがJavaScriptの例で、TypeScriptの場合は少し手入れが必要でした。

本記事で実装するSlack botの内容はn番煎じなのですが、Serverless FrameworkでのSlack botデプロイの実例は何らか価値がありそうなので1、セットアップからデプロイまで、とくにTypeScript / Boltフレームワーク / Serverless Frameworkでのデプロイの詰まりどころに力点を置いて記述します。

構成図

今回作るものの構成図を掲示します。

OpenAI APIはLambda内で利用します。

コード実装

実装例は多いので、簡単に結果だけ掲示します。

ディレクトリ構成

src以下にコードを配置します。エントリーポイントはindex.tsとしています。

ディレクトリ構成
📦slack_GPT
 ┣ 📂node_modules
 ┣ 📂src
 ┃ ┣ 📜chat.ts
 ┃ ┣ 📜index.ts
 ┃ ┗ 📜prompt.ts
 ┣ 📜.env
 ┣ 📜.gitignore
 ┣ 📜package-lock.json
 ┣ 📜package.json
 ┣ 📜serverless.yml
 ┗ 📜tsconfig.json

package.json

package.json
{
  "name": "slack_gpt",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@slack/bolt": "^3.12.2",
    "dotenv": "^16.0.3",
    "openai": "^3.3.0"
  },
  "devDependencies": {
    "serverless-offline": "^12.0.4",
    "serverless-plugin-typescript": "^2.1.5",
    "typescript": "^4.9.5"
  },
  "engines": {
    "node": "18.x"
  }
}

tsconfig.json

tsconfig.json

{
  "compilerOptions": {
    "outDir": ".build",
    "module": "commonjs",
    "moduleResolution": "node",
    "removeComments": true,
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "sourceMap": true,
    "strict": true,
    "target": "es2017",
    "noImplicitAny": false,
    "esModuleInterop": true
  },
  "compileOnSave": true,
  "exclude": [
    "node_modules",
    "**/*.spec.ts"
  ],
}

src

下記の記事に大きく負っています。

  • AWSでのデプロイ用にhandlerの変更
  • スレッドメッセージ以外でも反応
  • chatCompletionでの実装

をカスタムし、それ以外はそのまま参考にさせていただきました。

index.ts

index.ts
import * as dotenv from 'dotenv'
import { App, AwsLambdaReceiver } from "@slack/bolt"
import { chatCompletion, postSlackBotMessage } from './chat';
import { defaultPrompt } from './prompt';

// credit: https://zenn.dev/ryo_kawamata/articles/291c95b41baeb7

const awsLambdaReceiver = new AwsLambdaReceiver({
  signingSecret: process.env.SLACK_SIGNING_SECRET!,
});

dotenv.config()

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  receiver: awsLambdaReceiver,
  processBeforeResponse: true, // FaaSにホストする際の設定
});

// アプリへのメンションの場合のみ発火
app.event("app_mention", async ({ event, context, client }) => {
  // Slackに3秒でレスポンスを返せなかった場合の再送イベント対策
  if (context.retryNum) {
    console.log(
      `skipped retry. retryReason: ${context.retryReason}`
    );
    return;
  }

  const { thread_ts: threadTs, bot_id: botId, text } = event;

  if (botId) {
    return;
  }

  console.table({ user: event.user, question: text })

  try {
    // 待機中の仮メッセージ
    const thinkingMessageResponse = await postSlackBotMessage({
      client,
      channel: event.channel,
      threadTs: threadTs ? threadTs : event.event_ts,
      text: "...",
    });

    // スレッド内メッセージの場合はスレッドのメッセージを取得
    let prevMessageText = "";
    if (threadTs) {
      const threadMessagesResponse = await client.conversations.replies({
        channel: event.channel,
        ts: threadTs,
      });
      const messages = threadMessagesResponse.messages?.sort(
        (a, b) => Number(a.ts) - Number(b.ts)
      );

      const prevMessages =
        messages!.length < 6 ? messages!.slice(0, -1) : messages!.slice(-6, -1);

      prevMessageText =
        prevMessages.map((m) => `- ${m.text}`).join("\n") || "";
    }

    // Open AIの処理はchat.tsで処理させる
    const prompt = defaultPrompt(prevMessageText, text);
    const message = await chatCompletion(prompt);

    console.table({ user: event.user, question: text, answer: message })

    // 仮のメッセージを削除する
    await client.chat.delete({
      channel: event.channel,
      ts: thinkingMessageResponse.ts!,
    });

    if (message) {
      await postSlackBotMessage({
        client,
        channel: event.channel,
        threadTs: threadTs ? threadTs : event.event_ts,
        text: message
      });
    } else {
      throw new Error("message is empty");
    }
  } catch (e) {
    console.error(e);
    await postSlackBotMessage({
      client,
      channel: event.channel,
      threadTs: threadTs ? threadTs : event.event_ts,
      text: `申し訳ございません。エラーです。`,
    });
  }
});

export const handler = async (event, context, callback) => {
  const handler = await awsLambdaReceiver.start();
  return handler(event, context, callback);
}

chat.ts

OpenAI系の処理はここにまとめています。エラー処理はサボっています。Node.jsライブラリ使用。

chat.ts
import { WebClient } from "@slack/web-api"
import { Configuration, OpenAIApi } from "openai";
import { defaultSystemPrompt } from "./prompt";

async function chatCompletion(prompt: string) {
  const configuration = new Configuration({
    apiKey: process.env.OPENAI_API_KEY!,
  });
  const openAIClient = new OpenAIApi(configuration);
  const completions = await openAIClient.createChatCompletion({
    model: process.env.OPENAI_MODEL!,
    messages: [
      { role: "system", content: defaultSystemPrompt },
      { role: "user", content: prompt }
    ],
    max_tokens: 1000,
    top_p: 0.5,
    frequency_penalty: 1,
  });
  const message = completions.data.choices[0].message?.content
  console.log(completions.statusText)
  console.log(message)

  return message
}

const postSlackBotMessage = async ({
  client,
  channel,
  threadTs,
  text,
}: {
  client: WebClient;
  channel: string;
  threadTs: string;
  text: string;
}) => {
  return await client.chat.postMessage({
    channel,
    thread_ts: threadTs,
    icon_emoji: ":musical_note:",
    username: process.env.BOT_NAME,
    text,
  });
}

export { postSlackBotMessage, chatCompletion }

prompt.ts

こちらも上記記事からの借り物。せっかくなので弊協会の某キャラクターになりきってもらっています。

prompt.ts
export const defaultSystemPrompt = `あなたは${process.env.BOT_NAME}という名前の優秀なSlackBotです。あなたの知識とこれまでの会話の内容を考慮した上で、今の質問に正確な回答をしてください。
注意点として、回答に「@」によるメンションは用いないでください。

[あなたのプロフィール]
名前:ピィ先生(ぴてぃにゃんの先生)
職業:ピアノの先生・合唱の伴奏
趣味:ヨガ
誕生日:3月19日(ミュージックの日)
`
export const defaultPrompt = (prevMessageText: string, text: string) => `
### これまでの会話:
${prevMessageText}

### 今の質問:
${text}

### 今の質問の回答:
`

Slackアプリのセットアップ : アプリ作成〜Token取得

続いてSlackの設定に移ります。

1. アプリの作成

2. OAuth & Permissionsの設定 〜 Bot User OAuth Tokenの取得

  • Bot Token Scopesを設定
    • channels:read / channels:history / chat:write
      image.png
  • Workspaceへのインストール
    image.png
  • Bot User OAuth Tokenの取得
    • インストール後に発行されます。
      image.png

3. App Homeの設定

  • App Display Name等を設定

4. Basic Information > App CredentialsからSigning Secretを取得

image.png

ローカルでの検証

ここまで来たら、serverless-offlineを使ってローカルでの検証を行います。

serverless.ymlの作成

先ほどペンディングにしていた .envserverless.yml を作成します。

.env

OpenAIのAPIキーの取得方法については省略します。
SLACK_BOT_TOKENにBot User OAuth Token、SLACK_SIGNING_SECRETにSigning Secretを入れてください。

.env
SLACK_BOT_TOKEN=xoxb-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
SLACK_SIGNING_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
OPENAI_MODEL=gpt-4-0613
BOT_NAME=<botの名前>

serverless.yml

serverless.yml
service: slack-GPT
frameworkVersion: '3'
provider:
  name: aws
  region: us-west-2
  memorySize: 256
  runtime: nodejs18.x
  environment:
    SLACK_SIGNING_SECRET: ${env:SLACK_SIGNING_SECRET}
    SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN}
    OPENAI_API_KEY: ${env:OPENAI_API_KEY}
    OPENAI_MODEL: ${env:OPENAI_MODEL}
    BOT_NAME: ${env:BOT_NAME}
functions:
  slack:
    handler: src/index.handler
    timeout: 60
    events:
      - http:
          path: slack/events
          method: post
useDotenv: true
plugins:
  - serverless-plugin-typescript
  - serverless-offline

Slackの公式ガイドをベースにしていますが、いくつか手を入れています。

serverless offlineでローカル起動

まだnpm installしていなければ実行してください。

$npm install

serverless offlineを実行します。

$serverless offline

Compiling with Typescript...
Using local tsconfig.json - tsconfig.json
Typescript compiled.
Watching typescript files...

Starting Offline at stage dev (us-west-2)

Offline [http for lambda] listening on http://localhost:3002

下記のように http://localhost:3000/dev/slack/eventsに立ち上がればOKです。
image.png

ローカルホストのトンネリング

さて、Slackからのイベントをローカルに転送できるようにトンネリングします。

私は下記の記事に出ているServeoを使用しました。

ssh -R 80:localhost:3000 serveo.net

Forwarding HTTP traffic from https://<hoge>.serveo.net

もちろんngrokその他でも構いません。

Slack App : Event Subscriptionsの設定

Slack Appの設定画面に戻り、取得したURLを設定します。

  • メニューのEvent Subscriptionsを選択
  • Request URLに取得したURLを記入
    • challengeへの対応はBolt、AwsLambdaReceiverが処理してくれます。
    • ここでエラーが出る場合はSLACK_SIGNING_SECRETを確認してください。
      image.png

serverless offlinehttp://localhost:3000/dev/slack/eventsというアドレスを返すので、/dev/slack/eventsを忘れずに追加してください。/devの部分はserverless offline --noPrependStageInUrlとオプションをつけることで省略できます。

  • bot eventsを設定
    • app_mention
image.png image.png
  • 再インストール
    • 画面上部にアラートが出ますので、テキストリンクからアプリの再インストールを行います。
      image.png

動作テスト

Slackでアプリをインストールしたワークスペースを開き、任意のチャンネルでメンションを付けて動作テストします。

  • Subscribeしたapp_mentionイベントにDM(ダイレクトメッセージ)は含まれません。したがって、アプリのDM欄でメンションをしても動作しません。

Messages sent to your app in direct message conversations are not dispatched via app_mention, including messages sent from other apps, regardless of whether your app is explicitly mentioned or otherwise.
https://api.slack.com/events/app_mention


AWSへのデプロイ

ラスボス戦です。

まずserverless deployを行う前のAWSの認証設定をします。

IAMユーザーの作成

AWSコンソールからユーザーを作成し、「セキュリティ認証情報」>「アクセスキー」から認証キーペアを作成します。
image.png

ロール付与

ここが一番苦労しました。serverless deployのエラーを見ながら一つずつ足していきましたが、Serverless Frameworkのガイドでは、威風堂々とAdministratorAccessを付与していることに後で気づきました。

Click on Attach existing policies directly. Search for and select AdministratorAccess then click Next: Review.
https://www.serverless.com/framework/docs/providers/aws/guide/credentials/

image.png
image.png

私はAdministratorAccessは避けて、ひとつずつ追加していき、下記のポリシーで通りました。

  • AmazonAPIGatewayAdministrator
  • AmazonS3FullAccess
  • AWSCloudFormationFullAccess
  • AWSLambda_FullAccess
  • CloudWatchLogsFullAccess
  • IAMFullAccess

FullAccessを許可しているので、これでも広めだと思います。

参考記事

AWS CLIのインストール

下記より環境別にインストールしてください。

$aws configure 

プロンプトに従ってAWS Access Key IDとAWS Secret Access Keyに前項で取得したキーを登録します。

serverless deploy

最後にデプロイを行います。

serverless deploy

環境を指定したい場合は、

serverless deploy --stage=prod

などとします。だいたい2分くらいでデプロイが完了し、API GatewayのURLが発行されます。

Slack AppのRequest URLを更新

発行したURLをSlack AppのEvent Subscriptions > Request URLにセットします。

modified_image.png

これでデプロイ完了です。

おわりに

以上が、コード実装からデプロイまで、ひと通りの流れになります。

主な詰まりどころはTypeScript対応とIAM権限、DM内でのメンションはapp_mentionイベントに含まれないという点でした。

ServerlessフレームワークでTypeScriptのSlack botを作成したい方はご参考ください。

  1. あと、Qiita書くと弊協会の代表が異常に喜ぶので...

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1