Discord bot

提供:Turgenev's Wiki
2023年11月24日 (金) 19:55時点におけるTurgenev (トーク | 投稿記録)による版 (Notion-MW)

Slash CommandsでサーバレスなDiscordアプリを作る | loop.run_forever() などによると、Discordでのアプリ等の実装には大きく分けて2つの方式がある。

  • 従来からある、Gateway APIを使う方法。リアルタイム通信のためには、常時稼働しているサーバーが必要(Herokuなど、PaaSに分類されるもの)。
  • 新しく追加された、Application Commands(と呼ぶのが厳密に正確かはわからない)を使う方法。この場合、Webhookを用いてロジック部分を実装できるため、常時稼働のサーバーが不要(AWS LambdaなどのFaaS(サーバレス)に分類されるものでOK)。

この記事では前者の例として「スレッドが新規作成されたら”宣伝”という名前のチャンネルに通知する」bot、後者の例として「メッセージを右クリックして出てくるメニューからメッセージのピン留め/ピン留め解除を代行してくれる」bot、をそれぞれ実装する。

なお、Application Commandsを用いつつイベントをWebhookではなくGateway APIで受け取ることも可能である。が、今回はやらない。

  • スラッシュコマンド呼び出しに限らず、(Gateway APIが使える)全イベントをwebhookで受け取れるようにしてくれ!という要望も一応ある。コストはかかりそうだが… Outgoing Webhook for Gateway Events – Discord

Bot画面の見方

クライアントIDとアプリIDが同じもの(?)ということに注意。要するに、Bot(という仮想的なDiscordユーザー)のIDがそれである。

アプリとしてのプロフィールとBotとしてのプロフィールは別なようである。ただしアイコンは共通?

更新が反映されないときは一旦連携解除してから再び招待するとよい。

メッセージの編集によるメンション

今回使う2つのbotではいずれも操作記録のようなものをログとしてbotに発言させる。

ユーザー名をログに入れたいが、表示名などはいつでも変えられてしまって微妙なので、不変のIDに紐づいた情報が欲しい。しかしDiscordでは@を使ってユーザーをメンションすると、強めの通知(赤丸ではなく数字まで出るやつ)が出てしまう。

そこで、最初にメンションなしの仮メッセージを送っておいてから、それを直後に編集してメンションを追加するという方法にする。これだと通知が行かない。

なお、仮メッセージは、一瞬で編集されるのでどうでもいいかと思いきや、プッシュ通知にはガッツリ表示される。そこで、メンションのかわりとしてユーザーの表示名に使ったものを仮メッセージとして送信することにする。

ライブラリ

DiscordはAPIが整備されていて適切な権限を与えてやればほとんどの操作はプログラムから行える。ただしGateway APIに関しては直接操作するのは煩雑であるため、discord.js(Node.js)、Pycord(Python)、Discord.py(Python)のようなライブラリを使う場合が多いと思う。

今回は、スレッド通知botをdiscord.jsを使用して実装する。ピン留めbotではこの種のライブラリは使用しない。

global_nameについて

DiscordのIDの仕組みが大きく変わるのにあわせて、2023年5~6月くらいに仕様が変更され、サーバー固有のニックネームとは別に全サーバー共通の表示名を設定できるようになった。これはglobal_nameと呼ばれている。実装時点でDiscord.jsはこれに未対応だったので、これを取得する部分では直接Discord APIを呼んだ。

スレッド通知bot

こちらは従来からある方式で、比較的情報も多いため簡潔に済ませる。

まず無料のPaaSを選ぶ。ひと昔前まではHerokuが名高かったが、無料プランが廃止されてしまったので、移行先として名高いrender.comをかわりに使用する。

本体の実装は、discord.jsのドキュメントを読めばいいので簡単。

render側にWebアプリケーションが正しく起動したことを認識させるため、bot部分の準備(client.on()の設定後に、client.login())が終わったら、0.0.0.0:10000をlistenして適当な文字列を返すようにでも設定しておく。

renderで新規デプロイが走る際は、0.0.0.0:10000を使用するのが新しいバージョンに置き換わってから旧バージョンが完全に停止するまでラグがあるため、botが二重に稼働する(二重にメッセージが投稿される)時間帯が10~20秒ほどある。

仕様について

比較的最近Discordに追加された「フォーラム」のスレッドも、内部的には通常のチャンネルのスレッドと同じなので、全く同様に通知される。

ピン留めbot

  • 参考サイト

Azure FunctionsでサーバーレスDiscord Botを作る | cloud.config Tech Blog

ロジック部分はJavascriptを使う。

FaaSとしてはNetlify Functions(AWS Lambdaがバックエンド?)を使用する。最終的には使わなかったが無料だと他にもCloudflare Workersでも動作確認できた(コードの細部は変えている)。

右クリックメニューへの登録

エンドポイントの作成(&Discord側への登録)をやっていなくても、とりあえず右クリックメニューを出すところまではできるので、まずここからやってみるとよい。

DiscordのSlash Commandをwebhookで受信する方式で作ってみるとかDiscord Developer Portal — Documentation — Application Commandsにある通りに適当にリクエストを投げる。

表示された右クリックメニューを実行して、エラーが戻ってくることを確認しよう。

一度登録が済んでしまえば、メニュー内容に変更が無い限りはこのコードはもう使わない。

Netlifyの基本

まず以下を読む。

https://www.netlify.com/blog/intro-to-serverless-functions/#traditional-serverless-functions

ntl initで全部やってもいいが、loginは別でやったほうがわかりやすいかも

githubへのアクセスを認めなきゃならんのはなんでなのかよくわかってない

  • https://docs.netlify.com/cli/get-started/ ここに For repositories on GitHub, you can also connect your repository manually, if you prefer to give more limited, repository-only access for your repositories on GitHub って書いてあった。てかinitのメニューで確かにそんなん出てたな
  • export const handlerはES6記法で、v16以前のnodeでそのまま動かしたら怒られたのでexports.handledに変える必要があった。v17以降なら動くはず。

これでとりあえずnetlify上でサンプル的なエンドポイントが稼働する。

リクエストの検証の実装、動作確認

Discordにエンドポイントを登録するには、署名の検証が必要である。要するに、「このIDのアプリを用いて、これこれこのような操作が行われましたよ」という内容にDiscordが秘密の鍵で署名して送ってくるので、公開鍵を使ってそれが正しいかどうか検証するということである。署名の検証のところが正しく実装できていないと、エラーが出て、設定を保存することができない(設定保存の前に、正しいリクエストと偽造リクエストをDiscordが送ってきてテストされる)。

DiscordのSlash Commandをwebhookで受信する方式で作ってみるにあるhttps://github.com/discord/discord-interactions-jsを使うとよさそう。これをNetlify(AWS Lambda)向けに書き換えて、以下のようになる(前述サイトの内容に加えて、使用された対象のメッセージリンクを送るようにしている)。これが動作するかどうか、まずはテストしてみよう。

const { InteractionResponseType, InteractionType, verifyKey } = require('discord-interactions')

const CLIENT_PUBLIC_KEY = process.env.CLIENT_PUBLIC_KEY;

exports.handler = async (event, context) => {
    console.log(event)
    const signature = event.headers['x-signature-ed25519'];
    const timestamp = event.headers['x-signature-timestamp'];
    console.log(signature, timestamp)
    console.log(event["body"])
    const isValidRequest = verifyKey(event["body"], signature, timestamp, CLIENT_PUBLIC_KEY);
    if (!isValidRequest) {
        console.log("! returning 401")
      return {
        statusCode: 401,
        body: 'Bad request signature'}
    }
    const interaction = JSON.parse(event["body"]);

    if(interaction && interaction.type === InteractionType.APPLICATION_COMMAND) {
        const guild_id = interaction.guild_id
        const mess = interaction.data.resolved.messages
        const message_id = Object.keys(mess)[0]
        const channel_id = mess[message_id].channel_id

        const ret = JSON.stringify({
            type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
            data: {content:`${interaction.data.name} was used on https://discord.com/channels/${guild_id}/${channel_id}/${message_id}`},
        })
        return {statusCode: 200,'headers': {'Content-Type': 'application/json'},body: ret}
    } else {
    console.log("! returning pong")
    return {statusCode: 200, body: JSON.stringify({type: InteractionResponseType.PONG} )};
    }
}

CLIENT_PUBLIC_KEYはnetlifyの画面の環境変数から設定する(よくあるやつ)。

  • 最初試していたとき、Netlify上では応答を返すところまで上手くいっているのに「インタラクションに失敗しました」interaction failed(一旦画面移動してから見ると「アプリケーションが応答しませんでした」application did not respondに変わっている)と出続けていたが、応答に

    'headers': {
                'Content-Type': 'application/json',
            },

    を付けたら通った…

  • 関連リンク

エンドポイントの分割

Webhookを利用するbotには、3秒以内に最初の応答メッセージを返さなければいけないという決まりがある。「最初の」というのは、応答メッセージを一旦返した後にその内容を編集したり削除したりといったことは許容されているということである。これを行うには、初期メッセージを返すものとは別でWebリクエストをDiscordに送る必要が生じる。サーバレス関数で実装するなら、複数の関数に分ける必要がある。

今回は、ピン留めを行うだけなのでそこまで長い時間はかからないがそれでも1秒程度はかかる(特にNetlify Functionのコールドスタート時)ことがあるのと、Discordの画面上で場所を取りすぎる初期メッセージを削除したかったので、複数の関数に分ける実装をした。具体的には、「pin関数を呼び、対象メッセージがピン留め操作中であることを示す初期メッセージを返す」という操作を行うpininit関数と、「ピン留めを行い、ログチャンネルにログを送信し(ここでも前述の通り、最初は表示名べた書きの仮メッセージを送ってから後で編集してメンションを付ける)、初期メッセージを消す」という操作を行うpin関数、の2つを実装する。Discord側にエンドポイントとして登録するのはpininitの方であるから、署名の検証などはこちらに実装することになる。

  • 参考サイト Discord Interaction Endpoint を多段 Lambda 構成にしてタイムアウトを回避する | DevelopersIO 今回はNetlifyなので直接は使えないが、参考までに。
  • 3秒以内に返さなきゃいけないほうの「初期メッセージ」と、後からメンションを付加する前の「仮メッセージ」という2つは全く別物なので注意!
  • ちなみに初期メッセージはAPIからの応答をDiscordが表示するものなので、Bot側にメッセージ送信権が無くても表示される。さらに、今回の実装では、応答にEPHEMERALフラグを指定することで、右クリックメニューを使用したユーザーだけに初期メッセージが表示されるようにし、他のユーザーに(特にピン留め解除時に)(送信されたメッセージが即座に消されることによる)ゴースト通知が表示されるのを防ぐ。

TLSハンドシェイクの待ち時間

pininit関数の中でpin関数の応答を待っていては元も子もないので、pin関数を呼ぶだけ呼んで終了するということになる。

しかし、リクエストを投げた直後に終了すると、おそらくhttpsのためのTLSハンドシェイクが終わっていないという感じのエラーが出てしまった。

ChatGPTによると例えば日本とブラジル(のクラウドサービス)間とかになるとネットワーク遅延は最大250ms-350msくらい、TLSハンドシェイクにかかるのはその3-4倍の時間らしいので、そういうときは1秒以上くらい待つのが安全かもしれない。ただ今回は呼ぶ側も呼ばれる側もNetlifyなので、一応500msくらい待っているが全然エラーが出ることはない。

エンドポイント間の認証

pininit関数側はリクエストの検証を実装したが、pin関数は何もしなければどこからでも呼べてしまう。セキュリティにはあまり詳しくないのでどれがベストかわからないが、いくつか方法はあると思う。

  • 固定のパスワード(合言葉的な)を用いる…漏洩したらおしまい
  • 鍵ペアを用意して現在時刻を暗号化して送り、復号して確かめる(ワンタイムパスワード風)…鍵ペアが漏洩したらおしまい、暗号化・復号の処理が重い
  • Discordが送ってくる署名付きリクエストをそのまま転送し、全く同様に検証…Discord側が鍵を管理してくれるが、処理が重いのは変わらない。
  • Netlify Identityを用いてどうにかする…ブラウザじゃなくてAPIでそれやる意味ある?

今回は3を採用する。この場合、pininit関数だけでなくpin関数に関しても、Discordのエンドポイントとして登録できるかどうかで、検証部分の動作確認ができる。

なお、verifyKey関数による署名の検証においては、timestampが古すぎないかどうかの検証はない(←ChatGPT情報)らしいので、ネットワーク遅延などで遅くなっても問題はなさそうである。

コード

特にエラーハンドリングなどが色々と適当だがスルーしてほしい。

pininit.js

const { InteractionResponseType, InteractionType, verifyKey } = require('discord-interactions')

const fetch = require("node-fetch-commonjs");

const CLIENT_PUBLIC_KEY = process.env.CLIENT_PUBLIC_KEY;
const PIN_ENDPOINT = process.env.PIN_ENDPOINT;

exports.handler = async (event, context) => {
    //署名の検証が必要
    const signature = event.headers['x-signature-ed25519'];
    const timestamp = event.headers['x-signature-timestamp'];
    const isValidRequest = verifyKey(event["body"], signature, timestamp, CLIENT_PUBLIC_KEY);
    if (!isValidRequest) {
        console.log("! returning 401")
        return { statusCode: 401, body: 'Bad request signature' }
    }
    const interaction = JSON.parse(event["body"]);

    if (interaction && interaction.type === InteractionType.APPLICATION_COMMAND) {
        console.log("called as an interaction")

        //初期メッセージのための各種情報を取得
        const mess_data = interaction.data.resolved.messages
        const message_id = Object.keys(mess_data)[0]
        const channel_id = mess_data[message_id].channel_id
        const dispname = interaction.member.nick ? interaction.member.nick : (interaction.member.user.global_name ? interaction.member.user.global_name : interaction.member.user.username)
        //ピン留め用endpointを呼び出す。awaitしないで、リクエストを投げたら終了
        fetch(PIN_ENDPOINT, {
            method: 'POST', // POSTメソッドを指定
            headers: {
                // 署名をそのまま転送
                'x-signature-ed25519': signature,
                'x-signature-timestamp': timestamp,
                'Content-Type': 'application/json' // データ形式をJSONに指定
            },
            body: event["body"] // そのまま転送
        });
        //wait some time for TLS handshake
        await new Promise(resolve => setTimeout(resolve, 500))
        const ret = JSON.stringify({//1 << 6 means EPHEMERAL
            type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
            data: { flags: 1 << 6, content: `${dispname} ${interaction.data.name}ned: https://discord.com/channels/${interaction.guild_id}/${channel_id}/${message_id}` },
        })
        return { statusCode: 200, 'headers': { 'Content-Type': 'application/json' }, body: ret }
    } else {
        console.log("! returning pong")
        return { statusCode: 200, body: JSON.stringify({ type: InteractionResponseType.PONG }) };
    }
}

pin.js

const { InteractionResponseType, InteractionType, verifyKey } = require('discord-interactions')
const fetch = require("node-fetch-commonjs");

const CLIENT_PUBLIC_KEY = process.env.CLIENT_PUBLIC_KEY;
const BOT_TOKEN = process.env.BOT_TOKEN;

//do pin or unpin
async function runMessageCommand(guild_id, channel_id, message_id, commandName, user_info) {
    //既にピン留めされているかどうかを判定
    const pinsResponse = await fetch(`https://discord.com/api/v10/channels/${channel_id}/pins`,
        { headers: { 'Authorization': `Bot ${BOT_TOKEN}` } });
    const pins = await pinsResponse.json();
    const pinned = pins.find(pin => pin.id === message_id)
    if (commandName == "pin" ^ !pinned) return `the message is already ${commandName}ned`;
    const reqtype = commandName == "pin" ? 'PUT' : 'DELETE'
    const pinreq = await fetch(`https://discord.com/api/v10/channels/${channel_id}/pins/${message_id}`,
        { method: reqtype, headers: { 'Authorization': `Bot ${BOT_TOKEN}` } });
    //権限不足とか、システムメッセージに対してピン留めを実行したりするとエラー
    if (pinreq.status != 204) return await pinreq.text()
    //エラーにならなければ成功
    console.log(commandName + "ned!!")
    //"pin-agent-log"チャンネルを探す
    const channelsResponse = await fetch(`https://discord.com/api/v10/guilds/${guild_id}/channels`,
        { headers: { 'Authorization': `Bot ${BOT_TOKEN}` } });
    const channels = await channelsResponse.json();
    const targetChannel = channels.find(channel => channel.name === "pin-agent-log");
    if (!targetChannel) return 'Channel pin-agent-log not found!!!!';
    //メッセージの述語部分
    const maintext = `${commandName}ned https://discord.com/channels/${guild_id}/${channel_id}/${message_id} .`
    //仮メッセージを送ってそのIDを取得
    const mes_result = await fetch(`https://discord.com/api/v10/channels/${targetChannel.id}/messages`, {
        method: 'POST',
        headers: {
            'Authorization': `Bot ${BOT_TOKEN}`,
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({
            content: `${user_info.dispname} ${maintext}`,
        }),
    });
    const mes_body = await mes_result.json()
    //仮メッセージを編集してメンションを付加
    await fetch(`https://discord.com/api/v10/channels/${targetChannel.id}/messages/${mes_body.id}`, {
        method: 'PATCH',
        headers: {
            'Authorization': `Bot ${BOT_TOKEN}`,
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({ content: `<@${user_info.id}> ${maintext}` }),
    });
    return "pin and log ok"
}

function deleteOrgMes(interaction_token, appId) {
    //いつ初期メッセージが送られるかわからないので、4回試行してできるだけ早いうちに削除
    //見た感じ、ほぼ確実に1回目で削除できているようだが…
    return new Promise(async (resolve) => {
        for (let i = 0; i < 4; i++) {
            const res = await fetch(`https://discord.com/api/v10/webhooks/${appId}/${interaction_token}/messages/@original`, { method: 'DELETE' });
            //204だったら削除成功
            if (res.status === 204) resolve("delete ok: " + i)
            console.log(res.statusText)
            await new Promise((r) => setTimeout(r, 1100)); // 1.1秒待つ
        }
    });
}

exports.handler = async (event, context) => {
    const signature = event.headers['x-signature-ed25519'];
    const timestamp = event.headers['x-signature-timestamp'];
    const isValidRequest = verifyKey(event["body"], signature, timestamp, CLIENT_PUBLIC_KEY);
    if (!isValidRequest) {
        console.log("! returning 401")
        return {
            statusCode: 401,
            body: 'Bad request signature'
        }
    }
    const interaction = JSON.parse(event["body"]);

    if (interaction && interaction.type === InteractionType.APPLICATION_COMMAND) {
        //各種情報を取得
        const mess_data = interaction.data.resolved.messages
        const message_id = Object.keys(mess_data)[0]
        const channel_id = mess_data[message_id].channel_id
        const dispname = interaction.member.nick ? interaction.member.nick : (interaction.member.user.global_name ? interaction.member.user.global_name : interaction.member.user.username)

        let deleteProm = deleteOrgMes(interaction.token, interaction.application_id);
        console.log(await runMessageCommand(interaction.guild_id, channel_id, message_id, interaction.data.name, { id: interaction.member.user.id, dispname: dispname }));
        console.log(await deleteProm);
        return {
            statusCode: 200,
            body: "ok"
        }
    } else {
        console.log("! returning pong")
        return { statusCode: 200, body: JSON.stringify({ type: InteractionResponseType.PONG }) };
    }
}

CLIENT_PUBLIC_KEYとBOT_TOKENはそれぞれDiscordのサイトから取る。PIN_ENDPOINTは各自の設定にあわせてhttps://foo.netlify.app/.netlify/functions/barのようになる。3つともNetlifyの環境変数に入れる。

ログ用チャンネルのpin-agent-logの名前はハードコードされている。チャンネルが存在しなかったり書き込み権限がなかったりしたら何も起こらない(エラー終了はしない)。

Botの権限

ピン留めには以下の3つの権限が必要である。

  • チャンネルの閲覧
  • メッセージの管理(削除もできる)
  • メッセージ履歴の閲覧(常時接続のbotで、bot追加前のメッセージをピン留めする必要がなければ不要?)

Separate 'Manage Messages' into 'Pin Messages' and 'Delete Messages' – Discord

そこで、このbotは追加時にこれらの権限を要求するようにしている。そうするとbotはデフォルトで(例えばチャンネルの作成直後に)これらの権限が行使できるようになる。

もし、bot追加時に要求されるこれらの権限のチェックボックスを外せば、デフォルトでつけられる権限もその分だけ減る。また、チェックボックスを全て外せば、そもそもロール自体が作られなくなるようである(事実上は、何も権限のないロールが付いているのと同じ)。このような場合でも、適宜ロールを割り当てたりBot対象に権限を与えたりすることで、botが以上3つの権限を行使できるようになれば、正しく動く。

その他仕様

右クリックメニューは、メッセージ送信権限があるチャンネルでないと表示されない仕様となっている。Allow slash commands to be used when Send Message is Disabled · discord/discord-api-docs · Discussion #5097