🤖

Cloudflare Workers に Google Apps Script を挟んで処理に時間の掛かる Discord Bot を作る

2024/04/29に公開

はじめに

最近、論文要約 bot を作成しました。スラッシュコマンドで DOI(Digital Object Identifier) を入力すると論文概要を3行に要約してくれる優れもので、Cloudflare Workers 上で作動します。(OpenAI API の利用料を除いて)実行料金も無料です。ソースコードは GitHub 上に公開しています。

https://github.com/inaniwaudon/ronbun-bot/

処理自体は、スラッシュコマンドで投げられた内容をスクレイピングして GPT に投げるだけの簡単なものです。一方でこれを bot に載せるためには、以下の問題に対処する必要があります。

Discord Bot のスラッシュコマンドと 3 秒レスポンス

Discord Bot のスラッシュコマンド機能を用いると、予め登録したコマンドが呼び出される度に、Webhook に POST リクエストが送信されます。このリクエストに JSON 形式でレスポンスすることで、サーバを常時起動させることなく、AWS Lambda や Cloud Functions 等を用いてサーバレスに bot を構築することが可能となりました。Cloudflare Workers は 10 万リクエスト/日まで無料枠があるため、基本的に無料で bot を運用することができます。このあたりは前回の記事に詳しいです。

https://zenn.dev/inaniwaudon/articles/2ce2643abc9e08

一方で、スラッシュコマンドは短時間での返答が求められており、3 秒以内に何らかのレスポンスがないとユーザにエラーが通知されてしまいます。したがって、LLM に大量の文章を投げる際には工夫が必要です。また、Cloudflare Workers(以下 Workers)は CPU の実行時間が 10ms と厳しい制約があるため、スクレイピング等の重い処理には適していません。

今回は Google Apps Script(GAS)を挟んで、以下のフローで処理を行うことで、この問題に対処します。

  1. Workers のエンドポイント / で、スラッシュコマンドの Webhook を受信(……①)
  2. ① から GAS の URL を fetch(……②)
  3. この時点で ① はレスポンス DeferredChannelMessageWithSource を返却
  4. ② のスクリプト上で重い処理を実行
  5. ② から Workers 側のエンドポイント /callback を fetch(…… ③)
  6. ③ から Discord の Webhook を呼び、bot の返答内容を送信

各処理段階を詳しく見ていきます。

1, 2, 3. Workers でスラッシュコマンドの Webhook を処理

Hono を用いて、discord-interactions-type のサンプルにあるエンドポイントを作成します。ユーザがスラッシュコマンドを呼び出した場合、このエンドポイントが初めに呼び出され、この時点でユーザには「(bot)が考え中…」といった表記が通知されます。

「論文要約くん が考え中…」と表示される Discord のスクリーンショット

本エンドポイントでは以下の処理を行います。

  • ウェブアプリケーションとしてデプロイした GAS の URL に、適当なパラメータおよび interactionToken(後ほど使用)を含めて fetch
  • レスポンスとして InteractionResponseType.DeferredChannelMessageWithSource(後ほど改めて本文を送信することを示す)を返答
    • この際 fetch を waitUntil で囲うことで、レスポンスを返却しつつ以後 30 秒間に亘って処理を継続することが可能となる

以下にサンプルコードを示します。なお、記事中のサンプルでは認証処理、InteractionType.Ping に対する処理、および例外処理を省略しています。

server.ts
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
gas.js
// 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 上に戻してから処理を行っています。

https://qiita.com/Chrysanthemum94/items/25ffa5683beda15d4917

server.ts
// 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 でも無料かつサーバレスに作成することができます。

「論文要約くん」という bot が論文の要約を提示する Discord のスクリーンショット
15 秒後の実行結果

脚注
  1. https://discord.com/developers/docs/reference#user-agent ↩︎

Discussion