はじめに
業務と並行して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
{
"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
{
"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
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ライブラリ使用。
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
こちらも上記記事からの借り物。せっかくなので弊協会の某キャラクターになりきってもらっています。
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の取得
3. App Homeの設定
- App Display Name等を設定
4. Basic Information > App CredentialsからSigning Secretを取得
ローカルでの検証
ここまで来たら、serverless-offlineを使ってローカルでの検証を行います。
serverless.ymlの作成
先ほどペンディングにしていた .env と serverless.yml を作成します。
.env
OpenAIのAPIキーの取得方法については省略します。
SLACK_BOT_TOKENにBot User OAuth Token、SLACK_SIGNING_SECRETにSigning Secretを入れてください。
SLACK_BOT_TOKEN=xoxb-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
SLACK_SIGNING_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
OPENAI_MODEL=gpt-4-0613
BOT_NAME=<botの名前>
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の公式ガイドをベースにしていますが、いくつか手を入れています。
- region
- 任意。指定しないとデフォルトregionになります。
- memorySize: 256
- デフォルトが1,024MBと大きめ。
https://zenn.dev/snowcait/articles/fb263a89309fe4
- デフォルトが1,024MBと大きめ。
- function.slack.handler
- src以下に配置しているので、 src/index.handlerとします。
- timeout: 60
- デフォルトでは6秒になっています。
https://www.serverless.com/framework/docs/providers/aws/guide/functions
- デフォルトでは6秒になっています。
- useDotenv: true
- 環境変数の読み込みのため。
- plugins.serverless-plugin-typescript
- typescriptのコンパイルをよしなにしてくれます。
https://www.serverless.com/plugins/serverless-plugin-typescript
- typescriptのコンパイルをよしなにしてくれます。
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です。
ローカルホストのトンネリング
さて、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を記入
serverless offlineはhttp://localhost:3000/dev/slack/events
というアドレスを返すので、/dev/slack/events
を忘れずに追加してください。/dev
の部分はserverless offline --noPrependStageInUrl
とオプションをつけることで省略できます。
- bot eventsを設定
app_mention
動作テスト
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コンソールからユーザーを作成し、「セキュリティ認証情報」>「アクセスキー」から認証キーペアを作成します。
ロール付与
ここが一番苦労しました。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/
私は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にセットします。
これでデプロイ完了です。
おわりに
以上が、コード実装からデプロイまで、ひと通りの流れになります。
主な詰まりどころはTypeScript対応とIAM権限、DM内でのメンションはapp_mention
イベントに含まれないという点でした。
ServerlessフレームワークでTypeScriptのSlack botを作成したい方はご参考ください。
-
あと、Qiita書くと弊協会の代表が異常に喜ぶので... ↩