Cloudflare Workers に Google Apps Script を挟んで処理に時間の掛かる Discord Bot を作る
はじめに
最近、論文要約 bot を作成しました。スラッシュコマンドで DOI(Digital Object Identifier) を入力すると論文概要を3行に要約してくれる優れもので、Cloudflare Workers 上で作動します。(OpenAI API の利用料を除いて)実行料金も無料です。ソースコードは GitHub 上に公開しています。
処理自体は、スラッシュコマンドで投げられた内容をスクレイピングして GPT に投げるだけの簡単なものです。一方でこれを bot に載せるためには、以下の問題に対処する必要があります。
Discord Bot のスラッシュコマンドと 3 秒レスポンス
Discord Bot のスラッシュコマンド機能を用いると、予め登録したコマンドが呼び出される度に、Webhook に POST リクエストが送信されます。このリクエストに JSON 形式でレスポンスすることで、サーバを常時起動させることなく、AWS Lambda や Cloud Functions 等を用いてサーバレスに bot を構築することが可能となりました。Cloudflare Workers は 10 万リクエスト/日まで無料枠があるため、基本的に無料で bot を運用することができます。このあたりは前回の記事に詳しいです。
一方で、スラッシュコマンドは短時間での返答が求められており、3 秒以内に何らかのレスポンスがないとユーザにエラーが通知されてしまいます。したがって、LLM に大量の文章を投げる際には工夫が必要です。また、Cloudflare Workers(以下 Workers)は CPU の実行時間が 10ms と厳しい制約があるため、スクレイピング等の重い処理には適していません。
今回は Google Apps Script(GAS)を挟み、以下のフローで処理を行うことで、この問題に対処します。
- Workers のエンドポイント
/
で、スラッシュコマンドの Webhook を受信(……①) - ① から GAS の URL を fetch(……②)
- この時点で ① はレスポンス
DeferredChannelMessageWithSource
を返却 - ② のスクリプト上で重い処理を実行
- ② から Workers 側のエンドポイント
/callback
を fetch(…… ③) - ③ から Discord の Webhook を呼び、bot の返答内容を送信
各処理段階を詳しく見ていきます。
1, 2, 3. Workers でスラッシュコマンドの Webhook を処理
Hono を用いて、discord-interactions-type のサンプルにあるエンドポイントを作成します。ユーザがスラッシュコマンドを呼び出した場合、このエンドポイントが初めに呼び出され、この時点でユーザには「(bot)が考え中…」といった表記が通知されます。
本エンドポイントでは以下の処理を行います。
- ウェブアプリケーションとしてデプロイした GAS の URL に、適当なパラメータおよび interactionToken(後ほど使用)を含めて fetch
- レスポンスとして
InteractionResponseType.DeferredChannelMessageWithSource
(後ほど改めて本文を送信することを示す)を返答- この際 fetch を waitUntil で囲うことで、レスポンスを返却しつつ以後 30 秒間に亘って処理を継続することが可能となる
以下にサンプルコードを示します。なお、記事中のサンプルでは認証処理、InteractionType.Ping に対する処理、および例外処理を省略しています。
import { APIInteraction, ApplicationCommandType, InteractionResponseType, InteractionType } from "discord-api-types/v10";
import { Hono } from "hono";
import { createDOIResponseMessage } from "./message";
const app = new Hono();
// GAS の URL を記述
const GAS_URL = "<GAS_URL>";
app.post("/", async (c) => {
// TODO: 本来は認証処理を書く
const interaction: APIInteraction = await c.req.json();
if (
interaction.type === InteractionType.ApplicationCommand &&
interaction.data.type === ApplicationCommandType.ChatInput &&
interaction.data.name.toLowerCase() === "doi"
) {
// GAS を fetch した後、DeferredChannelMessageWithSource をレスポンス
c.executionCtx.waitUntil(createGASRequest(interaction, c.env));
return c.json({
type: InteractionResponseType.DeferredChannelMessageWithSource,
});
}
return c.text("Bad Request", 400);
});
const createGASRequest = async (
interaction: any,
) => {
const doi: string = interaction.data.options.find
((option: any) => option.name === "doi").value;
const response = await fetch(
`${GAS_URL}?doi=${doi}&interactionToken=${interaction.token}`,
);
};
app.fire();
4, 5. GAS で重い処理を実行し、Workers に返す
以下に示すスクリプトを、ウェブアプリケーションとして GAS にデプロイします。本スクリプトでは以下の処理を行います。
-
doGet
関数で適当なパラメータおよび interactionToken を受け取る - スクレイピングや API への問い合わせなど、時間の掛かる処理を実行
- Workers 上にデプロイしたコールバック用のエンドポイントに、bot の返答内容および interactionToken を含めて fetch
// TODO: 本来はスクリプトプロパティに記述する
const DISCORD_APPLICATION_APP = "<DISCORD_APPLICATION_APP>";
const DISCORD_BOT_TOKEN = "<DISCORD_BOT_TOKEN>";
const CALLBACK_ENDPOINT = "<CALLBACK_ENDPOINT>";
const responseToDiscord = (interactionToken, content) => {
const body = { interactionToken, content };
UrlFetchApp.fetch(CALLBACK_ENDPOINT, {
method: "POST",
crossDomain: true,
contentType: "application/json",
payload: JSON.stringify(body),
});
};
const doGet = (e) => {
const doi = e.parameter.doi;
const interactionToken = e.parameter.interactionToken;
// TODO: 本来はスクレイピングや API への問い合わせなど、時間の掛かる処理を挟む
Utilities.sleep(5000)
const content = "bot の返答内容";
responseToDiscord(interactionToken, content);
};
6. Workers から Webhook を呼び、bot を返答させる
最後に、Workers に用意したコールバック用エンドポイントから、Discord の Webhook を利用して、bot の返答内容を送信します。Webhook のエンドポイントには DISCORD_APPLICATION_APP
および interactionToken を含めます。
Discord の API にリクエストに送信する際は、ユーザエージェントを User-Agent: DiscordBot ($url, $versionNumber)
のように指定する必要がありますが[1]、GAS からのリクエストではユーザエージェントを変更できないため、Workers 上に戻してから処理を行っています。
// TODO: 本来は環境変数に書く
const DISCORD_BOT_TOKEN = "<DISCORD_BOT_TOKEN>";
const DISCORD_ENDPOINT = "https://discord.com/api/v10/webhooks";
app.post("/callback", async (c) => {
const { interactionToken, content } = await c.req.json();
const url = `${DISCORD_ENDPOINT}/${DISCORD_APPLICATION_APP}/${interactionToken}`;
// Discord の Webhook を用いて bot の応答を送信
const res = await fetch(
url,
{
method: "POST",
headers: {
Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
"User-Agent": "DiscordBot (xxxxxxxx, 9)",
"Content-Type": "application/json",
},
body: JSON.stringify({ content }),
},
);
return c.json(await res.json());
});
このように、Workers と GAS を上手く組み合わせることで、比較的長時間(論文要約 bot では 15 秒)の実行時間が要求される Bot でも無料かつサーバレスに作成することができます。
15 秒後の実行結果
Discussion