Closed22

勉強会用のDiscordbotをCloudflare Workers + Honoで作ってみる

ryo_kawamataryo_kawamata

HonoとCloudflareの入門がてら勉強会用のDiscordbotを作ってみる

作りたいアプリはこの記事で紹介している以前Slack用に作って今も運用しているもの。

https://qiita.com/ryo2132/items/1a7d5e2a1b80414700e3

今回、勉強会のコミュニケーションツールをSlackからDiscordに移行するので、作り直すこととなった。

正直Honoも、Cloudflareも、Discordbotもなにもわかってない。。なのでチュートリアルを見ながら手探りで進める。

ryo_kawamataryo_kawamata

たぶんDiscordbotからサーバーにPOSTリクエストを送って、それを検証して何らかのデータを返す感じになると思うので、とりあえずHonoでPOSTリクエストを受信するエンドポイントを作ってみる。

ryo_kawamataryo_kawamata

POSTリクエスを受け取れるようにする。

src/index.tsに追記

app.post('/greeting', async (c) => {
  const body = await c.req.json()

  if ('name' in body) {
    return c.json({ message: `hello ${body.name}` })
  } else {
    c.status(400)
    return c.json({ message: 'name is required' })
  }
})

正常

異常

ryo_kawamataryo_kawamata

とりえあずDiscord上でbotユーザーをつくるっぽい。

https://discord.com/developers/applications

にアクセスしてbotを作成。トークン等々をメモしておく。

OAuth2タブよりpermissionを設定。下に出てくるURLにアクセスして、Botを対象のDiscordチャネルに追加

次にメモしたトークンなどなどをcloudflareのsecretに入れる。

https://developers.cloudflare.com/workers/configuration/secrets/

ローカルように、.dev.varsを作成

DISCORD_TOKEN=xxx
DISCORD_PUBLIC_KEY=xxx
DISCORD_APPLICATION_ID=xxx

リモートにも追加

ryo_kawamataryo_kawamata

https://github.com/discord/discord-interactions-js

これが使えそう。
追加する。

bun i discord-interactions

リクエストの検証を試す。

index.tsに以下を追記

app.post('/interaction', async (c) => {
  const { DISCORD_PUBLIC_KEY } = env<{ DISCORD_PUBLIC_KEY: string }>(c)

  const signature = c.req.header('X-Signature-Ed25519')!;
  const timestamp = c.req.header('X-Signature-Timestamp')!;
  const isValidRequest = verifyKey(c.res.body as any, signature, timestamp, DISCORD_PUBLIC_KEY);
  if (!isValidRequest) {
    c.status(401)
    return c.json({ message: 'invalid request signature' });
  } else {
    return c.json({ message: 'ok' });
  }
})

workersにdeploy後に、Discordボットの設定にURLを追記

ryo_kawamataryo_kawamata

設定できない。たぶんエンドポイントの処理がダメ
Discordbotの基礎がわかってないので調べる

ryo_kawamataryo_kawamata
app.post('/interaction', async (c) => {
  const { DISCORD_PUBLIC_KEY } = env<{ DISCORD_PUBLIC_KEY: string }>(c)

  const signature = c.req.header('X-Signature-Ed25519')!;
  const timestamp = c.req.header('X-Signature-Timestamp')!;
  const isValidRequest = verifyKey(await c.req.raw.clone().text(), signature, timestamp, DISCORD_PUBLIC_KEY);

  if (!isValidRequest) {
    c.status(401)
    return c.json({ message: 'invalid request signature' });
  } else {
    const body = await c.req.json();
    if (body.type === InteractionResponseType.PONG) {
      return c.json({ type: InteractionResponseType.PONG });
    } else {
      return c.json({
        type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
        data: {
          content: 'Hello world',
        },
      });
    }
  }
})

このコードでエンドポイント登録はできた。pingに対して特定のレスポンスを返す必要があったぽい。

なんか検証部分が冗長なので、middlewareか何かで書けないかな

ryo_kawamataryo_kawamata

Honoのcustom middlewareを作ってみた

import { InteractionResponseType, verifyKey } from "discord-interactions";
import { Context } from "hono";
import { env } from "hono/adapter";
import { BlankInput, Env, Next } from "hono/types";

export const verifyDiscordInteraction = async (c: Context<Env, string , BlankInput>, next: Next) => {
  const { DISCORD_PUBLIC_KEY } = env<{ DISCORD_PUBLIC_KEY: string }>(c)
  const signature = c.req.header('X-Signature-Ed25519')!;
  const timestamp = c.req.header('X-Signature-Timestamp')!;

  const rawBody = await c.req.raw.clone().text();
  const isValidRequest = verifyKey(rawBody, signature, timestamp, DISCORD_PUBLIC_KEY);

  if (!isValidRequest) {
    return c.json({ message: 'invalid request signature' }, 401);
  }

  const body = JSON.parse(rawBody);
  if(body.type === InteractionResponseType.PONG) {
    return c.json({ type: InteractionResponseType.PONG });
  }
  await next()
}

使う側

app.use('/interaction', verifyDiscordInteraction)
app.post('/interaction', async (c) => {
  return c.json({
    type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
    data: {
      content: 'Hello world',
    },
  });
})
ryo_kawamataryo_kawamata

試しに公式のやつをやってみた。

const url = "https://discord.com/api/v10/applications/xxxxx/commands";

// This is an example CHAT_INPUT or Slash Command, with a type of 1
const json = {
    "name": "blep",
    "type": 1,
    "description": "Send a random adorable animal photo",
    "options": [
        {
            "name": "animal",
            "description": "The type of animal",
            "type": 3,
            "required": true,
            "choices": [
                {
                    "name": "Dog",
                    "value": "animal_dog"
                },
                {
                    "name": "Cat",
                    "value": "animal_cat"
                },
                {
                    "name": "Penguin",
                    "value": "animal_penguin"
                }
            ]
        },
        {
            "name": "only_smol",
            "description": "Whether to show only baby animals",
            "type": 5,
            "required": false
        }
    ]
}

// For authorization, you can use either your bot token
const headers = {
    "Content-Type": "application/json",
    "Authorization": "Bot xxx"
}


fetch(url, {
    method: 'POST',
    headers: headers,
    body: JSON.stringify(json)
}).then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Error:', JSON.stringify(error, null, 2)));
ryo_kawamataryo_kawamata

Modalを表示する。

APIのパラメーターの組み立てで楽したいので以下を追加

https://github.com/discordjs/discord-api-types

bun add discord-api-types

index.tsを修正する

app.post('/interaction', verifyDiscordInteraction, async (c) => {
  const modalResponse: APIModalInteractionResponse = {
    type: InteractionResponseType.Modal,
    data: {
      custom_id: "mokumoku",
      title: "もくもくBOT",
      components: [
        {
          type: ComponentType.ActionRow,
          components: [
            {
              type: ComponentType.TextInput,
              custom_id: "profile",
              label: "自己紹介",
              style: TextInputStyle.Paragraph,
              min_length: 1,
              max_length: 2000,
              required: true,
              placeholder: "山田太郎です。水戸でエンジニアをしています。趣味は俳句です。"
            },
          ]
        },
        {
          type: ComponentType.ActionRow,
          components: [
            {
              type: ComponentType.TextInput,
              custom_id: "todo",
              label: "今日やること",
              style: TextInputStyle.Paragraph,
              min_length: 1,
              max_length: 2000,
              required: true,
              placeholder: "今日は新しい機能の実装をします。"
            }
          ]
        }
      ]
    },

  }
  return c.json(modalResponse);
})

表示された :tada:

ryo_kawamataryo_kawamata

次は、モーダルからのレスポンスを受けて、内容を投稿するまで。

このスクラップは2ヶ月前にクローズされました