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


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

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

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

<img align="center" width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/04d49662-66d7-f53b-a556-64cbccc058b5.png">


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

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

https://slack.dev/bolt-js/ja-jp/deployments/aws-lambda

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

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

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

```mermaid
graph BT
  style AWS fill:#99c99
  SF["Serverless Framework"]
  SL["Slack"]
  subgraph AWS["Amazon Web Services"]
    CF["Cloud Formation"]
    S3["S3"]
    GW["Gateway"]
    L["Lambda"]
    CW["Cloud Watch"]
    CF --> S3
    CF --> GW
    CF --> CW
  end
  SF --> CF
  SL -->|"send events"| GW
  GW -->|"dev/prod"| L
  L -->|"Reply"| SL
```
OpenAI APIはLambda内で利用します。

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

### ディレクトリ構成
`src`以下にコードを配置します。エントリーポイントは`index.ts`としています。

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

### package.json
```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
```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での実装

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

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

### index.ts

```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ライブラリ使用。

```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
こちらも上記記事からの借り物。せっかくなので弊協会の某キャラクターになりきってもらっています。

https://corporate.piano.or.jp/ptnyan/index.html

```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. アプリの作成
  - https://api.slack.com/apps
## 2. OAuth & Permissionsの設定 〜 Bot User OAuth Tokenの取得
  - Bot Token Scopesを設定
    - channels:read / channels:history / chat:write
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/5f3c3247-5e8e-6061-4dfe-8e4d1927cd2d.png)
  - Workspaceへのインストール
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/0fa0080f-4f08-0448-4e4d-e71632557098.png)
  - **Bot User OAuth Token**の取得
    - インストール後に発行されます。  
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/85a7d206-dcfd-1ae9-70a8-ab2d429de0cf.png)

## 3. App Homeの設定
  - App Display Name等を設定 

## 4. Basic Information > App CredentialsからSigning Secretを取得
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/c918b4e5-2cc3-d57f-d316-915a726cda56.png)

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

## serverless.ymlの作成
先ほどペンディングにしていた *.env* と *serverless.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の公式ガイドをベースにしていますが、いくつか手を入れています。

https://slack.dev/bolt-js/ja-jp/deployments/aws-lambda

:::note warn
- region
  - 任意。指定しないとデフォルトregionになります。 
- memorySize: 256
  - デフォルトが1,024MBと大きめ。
https://zenn.dev/snowcait/articles/fb263a89309fe4
- function.slack.handler
  - src以下に配置しているので、 **src/index.handler**とします。
- timeout: 60
  - デフォルトでは6秒になっています。
https://www.serverless.com/framework/docs/providers/aws/guide/functions
- useDotenv: true
  - 環境変数の読み込みのため。
- plugins.serverless-plugin-typescript
  - typescriptのコンパイルをよしなにしてくれます。
https://www.serverless.com/plugins/serverless-plugin-typescript
:::

## serverless offlineでローカル起動
まだnpm installしていなければ実行してください。
```sh
$npm install
```
`serverless offline`を実行します。

```sh
$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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/3741d416-feaa-51e0-46ec-e79537a96049.png)


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

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

https://blog.solunita.net/posts/develop-slack-app-comfortably-fast-with-bolt-and-typescript/

```sh
ssh -R 80:localhost:3000 serveo.net

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

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

https://ngrok.com/ 

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

- メニューの**Event Subscriptions**を選択
- Request URLに取得したURLを記入
  - challengeへの対応はBolt、AwsLambdaReceiverが処理してくれます。 
  - ここでエラーが出る場合は`SLACK_SIGNING_SECRET`を確認してください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/7e3a88d0-426a-9320-4249-e89a52ba9146.png)

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

- bot eventsを設定
  - `app_mention` 
 
|  |  |
|:-:|:-:|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/a2c7ce8a-ec22-3163-f165-9db41cdd7968.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/5919b45d-549f-be4c-30f0-355c3e2259f0.png)|

- 再インストール
  - 画面上部にアラートが出ますので、テキストリンクからアプリの再インストールを行います。 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/01de866c-3d96-c795-94b3-9337f03f1db5.png)

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

<img width=300 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/b955605c-68d3-b568-262b-1a3e0e911337.png">

:::note warn
- 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の認証設定をします。

https://www.serverless.com/framework/docs/providers/aws/guide/credentials/

## IAMユーザーの作成
AWSコンソールからユーザーを作成し、「セキュリティ認証情報」>「アクセスキー」から認証キーペアを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/0e7af921-5874-f863-8922-ac2c61becc90.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/b2abb7a9-b252-13b3-5575-2764512ddabf.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/dbd4565c-ada8-017b-66bc-b410819d9f1d.png)

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

- AmazonAPIGatewayAdministrator
- AmazonS3FullAccess
- AWSCloudFormationFullAccess
- AWSLambda_FullAccess
- CloudWatchLogsFullAccess
- IAMFullAccess

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

### 参考記事

https://zenn.dev/peg/articles/93d844f6dd11c9


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

https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html

```
$aws configure 
```

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


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

```sh
serverless deploy
```

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

```sh
serverless deploy --stage=prod
```

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

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

![modified_image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/469e57d8-ea6b-7a57-75bb-aeaef6599cd8.png)

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

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

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

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