Hono+CloudflareWokersでChatGPTのAPIを実行するSlackのBotを作成する

:::note warn
本記事は2024年4月頃の実装に基づくため、公開時点で最新の状況と異なる可能性があります。
:::

# はじめに
本記事では、（N番煎じか分かりませんが）SlackからChatGPTのAPIを呼び出すBotを作成する方法について説明します。
`Hono`+`Cloudflare Wokers`を触ってみたい、というのが今回のBot作成の理由のため複雑な処理ではないですが、Slackのスレッド内でBotにメンションすると、スレッドの投稿をすべて収集してChatGPTに投げるという汎用的な処理になっているので、参考になれば幸いです。

# 準備
`Hono`及び`Cloudflare`を初めて使用する場合は、最初に`hono`と`wrangler`をインストールし、 `Hono`+`Cloudflare Workers`のプロジェクトを作成します。
```bash
$ npm install hono
$ npm install -g wrangler
$ wrangler login
$ npm create hono@latest my-app
Ok to proceed? (y)
? Which template do you want to use? cloudflare-workers
? Do you want to install project dependencies? yes
? Which package manager do you want to use? npm
```
プロジェクトの作成後`cd my-app && npm run deploy`とするだけで、`Cloudflare Wokers`にアプリケーションがデプロイされます。
Honoの初期テンプレートでは、`GET /`で`Hello Hono!`というテキストを返すようになっています。
```index.ts
import { Hono } from 'hono'

const app = new Hono()

app.get('/', (c) => {
  return c.text('Hello Hono!')
})

export default app

```
デプロイ完了後に表示される`https://my-app.***.workers.dev`をブラウザで開き、`Hello Hono!`と表示されれば、デプロイが成功していることを確認できます。

### 参考
- https://hono.dev/getting-started/cloudflare-workers
- [Hono + Cloudflare Workers で REST API を作ってみよう](https://zenn.dev/azukiazusa/articles/hono-cloudflare-workers-rest-api)

# 実装

## 処理の流れ
1. ユーザーがSlackでボットにメンションを送ると`app_mention`イベントが発火
1. イベントをトリガーに`Cloudflare Workers`を起動
1. `Workers`から`Slack API`を呼び出し、メンションのあったスレッドの会話履歴を取得
1. 会話履歴を`ChatGPT API`に渡して、ボットの返答を生成
1. 生成された返答を、`Slack API`を通じて元のスレッドに投稿

## SlackのBotを作成
1. 「 https://api.slack.com/apps 」にアクセスし「Create New App」
1. 「From an app manifest」から以下のマニフェストを入力
    ```yaml
    display_information:
      name: 表示名
    features:
      bot_user:
        display_name: Bot名
        always_online: true
    oauth_config:
      scopes:
        bot:
          - app_mentions:read
          - chat:write
          - groups:history
          - channels:history
    settings:
      event_subscriptions:
        request_url: https://my-app.***.workers.dev
        bot_events:
          - app_mention
      org_deploy_enabled: false
      socket_mode_enabled: false
      token_rotation_enabled: false
    ```
    ※ `display_information.name`、`features.bot_user.display_name`は任意の名称を、`settings.event_subscriptions.request_url`は先ほどデプロイしたプロジェクトのURLを設定します。
1. 作成後「Install App」からワークスペースにインストールし、`xoxb-`から始まる`Bot User OAuth Token`を記録

## Cloudflare Workersの処理を作成
1. 処理の実装
    コードの全文を記載し、要点のみを説明します
    ```index.ts
    import { Hono } from "hono";
    
    type Bindings = {
    	SLACK_BOT_USER_OAUTH_TOKEN: string;
    	OPENAI_API_SECRET_KEY: string;
    	OPENAI_API_ENDPOINT: string;
    	PROMPT: string;
    };
    const app = new Hono<{ Bindings: Bindings }>();
    
    interface SlackMessage {
    	type: string;
    	user: string;
    	text: string;
    	thread_ts: string;
    	reply_count?: number;
    	subscribed?: boolean;
    	last_read?: string;
    	unread_count?: number;
    	ts: string;
    	parent_user_id?: string;
    }
    
    interface SlackConversationsRepliesResponse {
    	messages: SlackMessage[];
    	has_more: boolean;
    	ok: boolean;
    	response_metadata: {
    		next_cursor: string;
    	};
    }
    
    interface AiChatCompletionsResponse {
    	id: string;
    	model: string;
    	created: number;
    	usage: {
    		prompt_tokens: number;
    		completion_tokens: number;
    		total_tokens: number;
    	};
    	object: string;
    	choices: {
    		index: number;
    		finish_reason: string;
    		message: {
    			role: string;
    			content: string;
    		};
    		delta: {
    			role: string;
    			content: string;
    		};
    	}[];
    }
    
    app.post("/", async (c) => {
    	const req = await c.req.json();
    
    	if (req.type === "url_verification") {
    		return c.text(req.challenge);
    	}
    
    	if (req.event.type === "app_mention") {
    		// slack info
    		const token: string = c.env.SLACK_BOT_USER_OAUTH_TOKEN;
    		const channelId: string = req.event.channel;
    		const threadTs: string = req.event.thread_ts || req.event.ts;
    		const botUser: string = req.event.text.match(/<@([A-Z0-9]+)>/)[1];
    		// openai info
    		const apiKey: string = c.env.OPENAI_API_SECRET_KEY;
    		const endPoint: string = c.env.OPENAI_API_ENDPOINT;
    		const prompt: string = c.env.PROMPT;
    
    		// main logic
    		c.executionCtx.waitUntil(
    			main(token, channelId, threadTs, botUser, apiKey, endPoint, prompt),
    		);
    
    		return c.text("ok");
    	}
    });
    
    export default app;
    
    async function main(
    	token: string,
    	channelId: string,
    	threadTs: string,
    	botUser: string,
    	apiKey: string,
    	endPoint: string,
    	prompt: string,
    ): Promise<void> {
    	const messages = await slackConversationsReplies(token, channelId, threadTs);
    	const messageText: string = messages
    		.filter(
    			(message) => message.user !== botUser && !message.text.includes(botUser),
    		)
    		.sort((a, b) => a.ts.localeCompare(b.ts))
    		.map((message) => message.text)
    		.join(",");
    
    	const resultText = await aiChatCompletions(
    		apiKey,
    		endPoint,
    		messageText,
    		prompt,
    	);
    
    	await slackChatPostMessage(token, channelId, threadTs, resultText);
    }
    
    async function slackChatPostMessage(
    	token: string,
    	channelId: string,
    	threadTs: string,
    	text: string,
    ) {
    	const url = "https://slack.com/api/chat.postMessage";
    	const params = {
    		channel: channelId,
    		thread_ts: threadTs,
    		text: text,
    	};
    	await fetch(url, {
    		method: "POST",
    		headers: {
    			"Content-Type": "application/json",
    			Authorization: `Bearer ${token}`,
    		},
    		body: JSON.stringify(params),
    	});
    }
    
    async function slackConversationsReplies(
    	token: string,
    	channelId: string,
    	threadTs: string,
    ): Promise<SlackMessage[]> {
    	const url = `https://slack.com/api/conversations.replies?channel=${channelId}&ts=${threadTs}`;
    	const response = await fetch(url, {
    		method: "GET",
    		headers: {
    			Authorization: `Bearer ${token}`,
    		},
    	});
    	const data: SlackConversationsRepliesResponse = await response.json();
    	return data.messages;
    }
    
    async function aiChatCompletions(
    	apiKey: string,
    	endPoint: string,
    	messageText: string,
    	prompt: string,
    ): Promise<string> {
    	const params = {
    		model: "gpt-3.5-turbo",
    		messages: [
    			{ role: "system", content: prompt },
    			{ role: "user", content: messageText },
    		],
    	};
    	const response = await fetch(`${endPoint}/chat/completions`, {
    		method: "POST",
    		headers: {
    			"Content-Type": "application/json",
    			Authorization: `Bearer ${apiKey}`,
    		},
    		body: JSON.stringify(params),
    	});
    	const data: AiChatCompletionsResponse = await response.json();
    	return data.choices[0].message.content;
    }
    ```
    1. 環境変数の読み込み
        ```ts
        type Bindings = {
        	SLACK_BOT_USER_OAUTH_TOKEN: string;
        	OPENAI_API_SECRET_KEY: string;
        	OPENAI_API_ENDPOINT: string;
        	PROMPT: string;
        };
        const app = new Hono<{ Bindings: Bindings }>();
        ~
        // slack info
		const token: string = c.env.SLACK_BOT_USER_OAUTH_TOKEN;
        ~
		// openai info
		const apiKey: string = c.env.OPENAI_API_SECRET_KEY;
		const endPoint: string = c.env.OPENAI_API_ENDPOINT;
		const prompt: string = c.env.PROMPT;
        ```
        Slackの`Bot User OAuth Token`、ChatGPTのAPIキー、APIエンドポイント（後述）、ChatGPTのAPIで実行するプロンプトを環境変数から取得します。
        各種トークンやAPIキーなどの機密情報は、`Cloudflare Workers`の機能で暗号化して保存することができます（後述）。
    1. Botがメンションされたスレッドのメッセージを取得する
        ```ts
		const channelId: string = req.event.channel;
		const threadTs: string = req.event.thread_ts || req.event.ts;
        ~
        async function slackConversationsReplies(
        	token: string,
        	channelId: string,
        	threadTs: string,
        ): Promise<SlackMessage[]> {
        	const url = `https://slack.com/api/conversations.replies?channel=${channelId}&ts=${threadTs}`;
        	const response = await fetch(url, {
        		method: "GET",
        		headers: {
        			Authorization: `Bearer ${token}`,
        		},
        	});
        	const data: SlackConversationsRepliesResponse = await response.json();
        	return data.messages;
        }
        ```
        スレッドのメッセージ一覧は`GET　conversations.replies`で取得できます。
        スレッドを特定するために必要なパラメータである`channel`と`ts`は、`app_mention`が呼び出された際のリクエストボディから取得します。
        ```ts
        const botUser: string = req.event.text.match(/<@([A-Z0-9]+)>/)[1];
        ~
        const messages = await slackConversationsReplies(token, channelId, threadTs);
        const messageText: string = messages
            .filter(
                (message) => message.user !== botUser && !message.text.includes(botUser),
            )
            .sort((a, b) => a.ts.localeCompare(b.ts))
            .map((message) => message.text)
            .join(",");
          ```
          メッセージの一覧をChatGPTへ問い合わせる文字列に変換するため、Botを呼び出す際のメッセージやBot自身の返答メッセージを除き、タイムスタンプ順に並べて`,`で結合しています。
    1. ChatGPTの処理を非同期で実行する
        ```ts
        // main logic
		c.executionCtx.waitUntil(
			main(token, channelId, threadTs, botUser, apiKey, endPoint, prompt),
		);

		return c.text("ok");
        ```
        大きく詰まったポイントとして、Slackの`Events API`における3秒ルールというものがあります。
        これは、「We wait longer than 3 seconds to receive a valid response from your server.」という記載の通り、Botの呼び出しから3秒以内に200レスポンスを返さなければ処理が失敗するというものです。
        スレッドのテキスト量やプロンプトの内容によって、ChatGPTからのレスポンスが3秒以上かかる場合もあり、処理が成功したり失敗したりという状況でした。
        そこで、実際の処理を`main()`関数にまとめて、`c.executionCtx.waitUntil`で実行することで、Slackには先に200レスポンスを返しつつ時間のかかる処理を非同期で実行し、ChatGPTの処理が完了後に結果をSlackの`POST chat.postMessage`で元のスレッドに通知することができるようになりました。

### 参考
- https://api.slack.com/methods/conversations.replies
- [Slackの会話履歴の取得をTypeScriptで実装する ~conversations.replies~](https://qiita.com/ishikawa-atsuki-valuesccg/items/f0b4b5c0f73ed3f2aeb5)
- https://api.slack.com/apis/connections/events-api#failure

# 環境変数と機密情報の設定
## 環境変数
`Cloudflare Workers`では環境変数を`wrangler.toml`ファイルにて設定できます。
プロジェクトの作成時に生成される以下のテンプレートの通り、`[vars]`に記載するだけでデプロイ時に自動で環境変数を設定してくれます。
```diff
- # [vars]
- # MY_VAR = "my-variable"
+ [vars]
+ PROMPT = "ChatGPTのプロンプト"
```

## 機密情報
ファイルに記載すべきではないトークンやシークレットキーなどの機密情報を管理する手段として、`Secrets`という機能が用意されています。
以下のように`wrangler secret put`コマンドで登録することができます。
```bash
wrangler secret put SLACK_BOT_USER_OAUTH_TOKEN
```
環境変数はCloudflareのコンソールの「設定→変数」から確認できますが、`wrangler secret put`で登録された機密情報は「値が暗号化されました」と表示されます。

![settings-bindings.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/174948/e57c9ade-b969-e5dd-b5ef-8d0bac1008cc.png)

### 参考
- https://developers.cloudflare.com/workers/configuration/secrets/

# AI Gateway
ChatGPTを含む、様々な生成AIサービスのAPIのエンドポイントを、AI Gatewayの提供するエンドポイントに置き換えることで、ログの記録や、リクエスト数やトークン数、コストなどの指標を可視化できるようになるほか、キャッシュ、レート制限、リクエストの再試行、フォールバックモデルの定義など様々な機能が提供されています。詳細は後述のURLを参照してください。
今回はログの参照やコストの確認などの用途でしか使用できていませんが、ChatGPTにリクエストが送信されているかの確認や、gpt-4の値段にびっくりしてgpt-3に戻すなどに役立ちました。

![ai-gateway.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/174948/c8dbe3db-7773-935b-2302-a6eb633f82b9.png)

### 参考
- https://developers.cloudflare.com/ai-gateway/
- [AI Gatewayの発表：AIアプリケーションの可観測性、信頼性、スケーラビリティを高める](https://blog.cloudflare.com/ja-jp/announcing-ai-gateway-ja-jp)

# おわりに
`Hono`と`Cloudflare Workers`は初めて触りましたが非常にとっつきやすく、特にプロジェクト作成からデプロイまでが爆速で開発体験の良さを実感しました。ちょっとしたサービスやシステムを作る際の選択肢として、この組み合わせは今後も積極的に検討していきたいと思います。
CloudflareにはWorkers以外にも面白そうなサービスがいくつもあるので、活用できそうなアイデアが浮かんだらまた触ってみたいです。