Discord Interaction Endpoint を多段 Lambda 構成にしてタイムアウトを回避する

2022.11.25

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

前回の記事の続きです。

Discord の Interaction Endpoint は仕様上、Interaction リクエストから応答までに制限時間が設けられています。
制限時間を超えてしまう場合は、一旦仮の応答を返しておき、あとから HTTP リクエストで応答を修正する方法 (DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE) が用意されています。
API Gateway + Lambda で Interaction Endpoint を実装している場合は、多段 Lambda 構成にすることで実装することができます。

CDK スタックを用意する

lib/my-stack.ts

import * as cdk from "aws-cdk-lib";
import { Duration } from "aws-cdk-lib";
import { FunctionUrlAuthType } from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Construct } from "constructs";

export class AppStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const responseGeneratorHandler = new NodejsFunction(
      this,
      "generate-response",
      {
        entry: "./src/generate-response.ts",
        handler: "handler",
        environment: {
          DISCORD_APP_ID: "BotのアプリID"
        },
        timeout: Duration.seconds(30) // Lambdaのタイムアウトを延長させておく
      }
    );

    const initialHandler = new NodejsFunction(this, "initial-handler", {
      entry: "./src/initial-handler.ts",
      handler: "handler",
      environment: {
        DISCORD_PUBLIC_KEY: "BotのPublic Key",
        RESPONSE_GENERATOR_FUNCTION: responseGeneratorHandler.functionName,
      }
    });
    initialHandler.addFunctionUrl({
      authType: FunctionUrlAuthType.NONE,
    });
    responseGeneratorHandler.grantInvoke(initialHandler);
  }
}

多段 Lambda 構成なので、Webhook のハンドラー initial-handler と、実際にレスポンスを返す処理 generate-response を用意します。
initial-handlergenerate-response を呼び出すので、grantInvoke で呼び出し権限を与えます。

Lambda 関数のソースコードを用意する

Webhook のハンドラ

initial-handler は Interaction Request の検証処理と Lambda の起動処理を行います。

src/initial-handler.ts

import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda";
import {
  InteractionResponseType,
  InteractionType,
  verifyKey,
} from "discord-interactions";
import { InvokeCommand, LambdaClient } from "@aws-sdk/client-lambda";

// Interaction リクエストの署名検証 (ないと失敗する)
const verifyRequest = (event: APIGatewayProxyEventV2) => {
  const { headers, body } = event;
  const signature = headers["x-signature-ed25519"];
  const timestamp = headers["x-signature-timestamp"];
  const publicKey = process.env["DISCORD_PUBLIC_KEY"];
  if (!body || !signature || !timestamp || !publicKey) {
    return false;
  }
  return verifyKey(body, signature, timestamp, publicKey);
};

// interaction の処理
const handleInteraction = async (body: Record<string, unknown>) => {
  if (
    body.type === InteractionType.APPLICATION_COMMAND ||
    body.type === InteractionType.MESSAGE_COMPONENT
  ) {
    // 別の Lambda に処理を委譲
    const client = new LambdaClient({});
    await client.send(
      new InvokeCommand({
        FunctionName: process.env["RESPONSE_GENERATOR_FUNCTION"],
        InvocationType: "Event",
        Payload: Buffer.from(
          JSON.stringify({
            body,
          })
        ),
      })
    );
    // DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE で先に応答しておく
    return {
      type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
    };
  }
  return { type: InteractionResponseType.PONG };
};

export const handler = async (
  event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyResultV2> => {
  if (!verifyRequest(event)) {
    return {
      statusCode: 400,
    };
  }

  const { body } = event;
  const interaction = JSON.parse(body!);
  return {
    statusCode: 200,
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(await handleInteraction(interaction)),
  };
};

インタラクションを返す処理

メッセージオブジェクトを `https://discord.com/api/v9/webhooks/${applicationId}/${interactionToken}` に POST することで、遅延メッセージを確定させることができます。

src/generate-response.ts

import { APIGatewayProxyResultV2 } from "aws-lambda";
import axios from "axios";
import { InteractionType } from "discord-interactions";

export const handler = async (event: {
  body: Record<string, unknown>;
}): Promise<APIGatewayProxyResultV2> => {
  const { body } = event;
  if (!body) {
    return { statusCode: 400 };
  }
  const { token: interactionToken } = body;
  const followup = await new Promise((resolve, reject) => {
    // 5秒かかる処理
    const resp = invokeCommand(body);
    setTimeout(() => {
      resolve(resp);
    }, 5000);
  });
  try {
    const axiosResult = await axios.post(
      `https://discord.com/api/v9/webhooks/${process.env[
        "DISCORD_APP_ID"
      ]!}/${interactionToken}`,
      JSON.stringify(followup),
      {
        headers: {
          "Content-Type": "application/json",
        },
      }
    );
    return { statusCode: axiosResult.status };
  } catch (err) {
    console.error(err);
    return { statusCode: 500 };
  }
};

function invokeCommand(interaction: Record<string, unknown>) {
  if (interaction.type === InteractionType.APPLICATION_COMMAND) {
    const data = interaction.data as Record<string, unknown>;
    if (data.name === "hello") {
      return {
        content: "deferred message",
      };
    }
  }
}

やってみる

コマンドを実行するとプレースホルダーのメッセージが表示されます。

5秒後にメッセージが返ってきます。

まとめ

Interaction Endpoint のタイムアウトを延長することができるので、データベースへのアクセスなど遅くなる処理も書くことができるようになります。
今回はシンプルに多段 Lambda 構成で実装してみました。