0
1

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

Posted at

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

はじめに

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

準備

Hono及びCloudflareを初めて使用する場合は、最初にhonowranglerをインストールし、 Hono+Cloudflare Workersのプロジェクトを作成します。

$ 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!と表示されれば、デプロイが成功していることを確認できます。

参考

実装

処理の流れ

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

SlackのBotを作成

  1. https://api.slack.com/apps 」にアクセスし「Create New App」
  2. 「From an app manifest」から以下のマニフェストを入力
    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.namefeatures.bot_user.display_nameは任意の名称を、settings.event_subscriptions.request_urlは先ほどデプロイしたプロジェクトのURLを設定します。
  3. 作成後「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. 環境変数の読み込み
      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の機能で暗号化して保存することができます(後述)。
    2. Botがメンションされたスレッドのメッセージを取得する
      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で取得できます。
      スレッドを特定するために必要なパラメータであるchanneltsは、app_mentionが呼び出された際のリクエストボディから取得します。
      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自身の返答メッセージを除き、タイムスタンプ順に並べて,で結合しています。
    3. ChatGPTの処理を非同期で実行する
      // 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で元のスレッドに通知することができるようになりました。

参考

環境変数と機密情報の設定

環境変数

Cloudflare Workersでは環境変数をwrangler.tomlファイルにて設定できます。
プロジェクトの作成時に生成される以下のテンプレートの通り、[vars]に記載するだけでデプロイ時に自動で環境変数を設定してくれます。

- # [vars]
- # MY_VAR = "my-variable"
+ [vars]
+ PROMPT = "ChatGPTのプロンプト"

機密情報

ファイルに記載すべきではないトークンやシークレットキーなどの機密情報を管理する手段として、Secretsという機能が用意されています。
以下のようにwrangler secret putコマンドで登録することができます。

wrangler secret put SLACK_BOT_USER_OAUTH_TOKEN

環境変数はCloudflareのコンソールの「設定→変数」から確認できますが、wrangler secret putで登録された機密情報は「値が暗号化されました」と表示されます。

settings-bindings.png

参考

AI Gateway

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

ai-gateway.png

参考

おわりに

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

0
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
0
1