Notion-MW
Notion-MW
 
(同じ利用者による、間の4版が非表示)
2行目: 2行目:


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


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


[https://tech-blog.cloud-config.jp/2022-12-12-serverless-discord-bot-by-azure-functions Azure FunctionsでサーバーレスDiscord Botを作る | cloud.config Tech Blog]
[https://tech-blog.cloud-config.jp/2022-12-12-serverless-discord-bot-by-azure-functions Azure FunctionsでサーバーレスDiscord Botを作る | cloud.config Tech Blog]
* これも良さそう<br />
[https://zenn.dev/inaniwaudon/articles/2ce2643abc9e08 スラッシュコマンドでぬいぐるみとおしゃべりする Discord Bot]


ロジック部分はJavascriptを使う。
ロジック部分はJavascriptを使う。
70行目: 73行目:


[https://zenn.dev/azechi/scraps/a98add131e8410 DiscordのSlash Commandをwebhookで受信する方式で作ってみる]とか[https://discord.com/developers/docs/interactions/application-commands#registering-a-command Discord Developer Portal — Documentation — Application Commands]にある通りに適当にリクエストを投げる。
[https://zenn.dev/azechi/scraps/a98add131e8410 DiscordのSlash Commandをwebhookで受信する方式で作ってみる]とか[https://discord.com/developers/docs/interactions/application-commands#registering-a-command Discord Developer Portal — Documentation — Application Commands]にある通りに適当にリクエストを投げる。
こちらでも一応適当なコード例を載せておく。
<div style="overflow:auto;width: 100%; max-width: 1200px; white-space:nowrap; border:1px solid; padding-left: 10px; padding-right: 10px;" class="mw-collapsible mw-collapsed">
<div>コード例</div>
<div class="mw-collapsible-content">
<syntaxhighlight lang="javascript">import requests
import os
CLIENT_ID = os.environ['CLIENT_ID']
CLIENT_SECRET = os.environ['CLIENT_SECRET']
def get_token():
    data = {'grant_type': 'client_credentials','scope': 'applications.commands.update'}
    headers = {'Content-Type': 'application/x-www-form-urlencoded'}
    r = requests.post('https://discord.com/api/v10/oauth2/token', data=data, headers=headers, auth=(CLIENT_ID, CLIENT_SECRET))
    r.raise_for_status()
    return r.json()['access_token']
headers = {"authorization": "Bearer " + get_token()}
#type ... 1 = slash command,  2 = right click for USER,  3 = right click for MESSAGE
pin_command = {"type":3,"name":"pin"}
unpin_command = {"type":3,"name":"unpin"}
commands = [pin_command,unpin_command]
guilds=["", "/guilds/xxxxxxxx"]
for guild in guilds:
    url = f"https://discord.com/api/v10/applications/{CLIENT_ID}{guild}/commands"
    # get application ids
    # r = requests.get(url, headers=headers)
    # print(r.text)
    # continue
    for command in commands:
        name = command["name"]
        r = requests.post(url, headers=headers, json=command)
        print(r.status_code)
        if not (200 <= r.status_code < 300):
            print(f"Failed to create {name} command. Error: {r.text}")
        else:
            print(f"Successfully created {name} command")</syntaxhighlight>
</div></div>


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


[https://www.netlify.com/blog/intro-to-serverless-functions/#traditional-serverless-functions https&#58;//www.netlify.com/blog/intro&#45;to&#45;serverless&#45;functions/&#35;traditional&#45;serverless&#45;functions]
[https://www.netlify.com/blog/intro-to-serverless-functions/#traditional-serverless-functions https&#58;//www.netlify.com/blog/intro&#45;to&#45;serverless&#45;functions/&#35;traditional&#45;serverless&#45;functions]
以下ではこれの通りCommonJS形式で説明する。ES Modules形式の場合の注意点も↑に書いてある。


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


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


* [https://docs.netlify.com/cli/get-started/ https&#58;//docs.netlify.com/cli/get&#45;started/] ここに For repositories on GitHub, you can also connect your repository manually, if you prefer to give more limited, repository&#45;only access for your repositories on GitHub って書いてあった。てかinitのメニューで確かにそんなん出てたな
* [https://docs.netlify.com/cli/get-started/ https&#58;//docs.netlify.com/cli/get&#45;started/] ここに For repositories on GitHub, you can also connect your repository manually, if you prefer to give more limited, repository&#45;only access for your repositories on GitHub って書いてあった。てかinitのメニューで確かにそんなん出てたな
130行目: 177行目:
     } else {
     } else {
     console.log("! returning pong")
     console.log("! returning pong")
     return {statusCode: 200, body: JSON.stringify({type: InteractionResponseType.PONG} )};
     return {statusCode: 200, 'headers': { 'Content-Type': 'application/json' }, body: JSON.stringify({type: InteractionResponseType.PONG} )};
     }
     }
}</syntaxhighlight>
}</syntaxhighlight>
137行目: 184行目:
<ul>
<ul>
<li><p>最初試していたとき、Netlify上では応答を返すところまで上手くいっているのに「インタラクションに失敗しました」interaction failed(一旦画面移動してから見ると「アプリケーションが応答しませんでした」application did not respondに変わっている)と出続けていたが、応答に</p>
<li><p>最初試していたとき、Netlify上では応答を返すところまで上手くいっているのに「インタラクションに失敗しました」interaction failed(一旦画面移動してから見ると「アプリケーションが応答しませんでした」application did not respondに変わっている)と出続けていたが、応答に</p>
<syntaxhighlight lang="javascript">'headers': {
<syntaxhighlight lang="javascript">'headers': { 'Content-Type': 'application/json'},</syntaxhighlight>
            'Content-Type': 'application/json',
        },</syntaxhighlight>
<p>を付けたら通った…</p></li>
<p>を付けたら通った…</p></li>
<li><p>関連リンク</p>
<li><p>関連リンク</p>
149行目: 194行目:
=== エンドポイントの分割 ===
=== エンドポイントの分割 ===


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


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


具体的には、関数名を<strong>pin</strong>として、これを2回呼び出すようにした。つまり、1度目の呼び出しでは「自分自身を再び呼び出した上で、対象メッセージがピン留め操作中であることを示す初期メッセージを返す」という操作を行い、2度目の呼び出しでは「ピン留めを行い、ログチャンネルにログを送信し(ここでも前述のように、最初は表示名べた書きの仮メッセージを送ってから後で編集してメンションを付ける)、初期メッセージを消す」という操作を行う。
もちろん、1回目と2回目で完全に別の関数に分けてもよいが、あえて同じ関数を複数回呼び出すことで、<strong>Discordの署名の検証メカニズムをそのまま使える</strong>というメリットがある。つまり、1回目の呼び出しから2回目を呼び出す際に、<strong>Discordから送られてきたHTTPリクエストのヘッダーの署名とbodyをすべてそのまま2回目の呼び出しに渡す</strong>ことで、2回目についても同様に署名を検証すればDiscord以外から呼び出されることを防げる、というわけである。bodyが変えられないならどうやって2回目の呼び出しであるかを判定するのか?という点については、<strong>HTTPリクエストのヘッダにカスタムデータを入れる</strong>ことで対処できる。また、関数を1つにまとめることで、共通するロジック(ユーザー名の取得など)も1か所に書くだけでよくなるという副次的なメリットもある。
* なお、<code>verifyKey</code>関数による署名の検証においては、timestampが古すぎないかどうかの検証はない(←ChatGPT情報)らしいので、ネットワーク遅延などで2回目の呼び出し多少が遅くなっても問題はなさそうである。
* 参考サイト [https://dev.classmethod.jp/articles/discord-interaction-endpoint-deferred-multi-lambda/ Discord Interaction Endpoint を多段 Lambda 構成にしてタイムアウトを回避する | DevelopersIO] 今回はNetlifyなので直接は使えないが、参考までに。
* 参考サイト [https://dev.classmethod.jp/articles/discord-interaction-endpoint-deferred-multi-lambda/ Discord Interaction Endpoint を多段 Lambda 構成にしてタイムアウトを回避する | DevelopersIO] 今回はNetlifyなので直接は使えないが、参考までに。
* 3秒以内に返さなきゃいけないほうの「初期メッセージ」と、後からメンションを付加する前の「仮メッセージ」という2つは全く別物なので注意!
* 3秒以内に返さなきゃいけないほうの「初期メッセージ」(ピン留めを呼び出したチャンネルに出る)と、後からメンションを付加する前の「仮メッセージ」(ログチャンネルに出る)という2つを混同しないよう注意
* ちなみに初期メッセージはAPIからの応答をDiscordが表示するものなので、Bot側にメッセージ送信権が無くても表示される。さらに、今回の実装では、応答にEPHEMERALフラグを指定することで、右クリックメニューを使用したユーザーだけに初期メッセージが表示されるようにし、他のユーザーに(特にピン留め解除時に)(送信されたメッセージが即座に消されることによる)ゴースト通知が表示されるのを防ぐ。
* ちなみに初期メッセージはAPIからの応答をDiscordが表示するものなので、Bot側にメッセージ送信権が無くても表示される。さらに、今回の実装では、応答にEPHEMERALフラグを指定することで、右クリックメニューを使用したユーザーだけに初期メッセージが表示されるようにし、他のユーザーに(特にピン留め解除時に)(送信されたメッセージが即座に消されることによる)ゴースト通知が表示されるのを防ぐ。


160行目: 210行目:
==== TLSハンドシェイクの待ち時間 ====
==== TLSハンドシェイクの待ち時間 ====


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


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


ChatGPTによると例えば日本とブラジル(のクラウドサービス)間とかになるとネットワーク遅延は最大250ms&#45;350msくらい、TLSハンドシェイクにかかるのはその3&#45;4倍の時間らしいので、そういうときは1秒以上くらい待つのが安全かもしれない。ただ今回は呼ぶ側も呼ばれる側もNetlifyなので、一応500msくらい待っているが全然エラーが出ることはない。
ChatGPTによると例えば日本とブラジル(のクラウドサービス)間とかになるとネットワーク遅延は最大250ms&#45;350msくらい、TLSハンドシェイクにかかるのはその3&#45;4倍の時間らしいので、そういうときは1秒以上くらい待つのが安全かもしれない。ただ今回は呼ぶ側も呼ばれる側もNetlifyなので、一応500msくらい待っているが全然エラーが出ることはない。
==== エンドポイント間の認証 ====
pininit関数側はリクエストの検証を実装したが、pin関数は何もしなければどこからでも呼べてしまう。セキュリティにはあまり詳しくないのでどれがベストかわからないが、いくつか方法はあると思う。
* 固定のパスワード(合言葉的な)を用いる…漏洩したらおしまい
* 鍵ペアを用意して現在時刻を暗号化して送り、復号して確かめる(ワンタイムパスワード風)…鍵ペアが漏洩したらおしまい、暗号化・復号の処理が重い
* Discordが送ってくる署名付きリクエストをそのまま転送し、全く同様に検証…Discord側が鍵を管理してくれるが、処理が重いのは変わらない。
* Netlify Identityを用いてどうにかする…ブラウザじゃなくてAPIでそれやる意味ある?
今回は3を採用する。この場合、pininit関数だけでなくpin関数に関しても、Discordのエンドポイントとして登録できるかどうかで、検証部分の動作確認ができる。
なお、<code>verifyKey</code>関数による署名の検証においては、timestampが古すぎないかどうかの検証はない(←ChatGPT情報)らしいので、ネットワーク遅延などで遅くなっても問題はなさそうである。


=== コード ===
=== コード ===


特にエラーハンドリングなどが色々と適当だがスルーしてほしい。
特にエラーハンドリングなどが色々と適当だがスルーしてほしい。
<span id="pininitjs"></span>
==== pininit.js ====


<syntaxhighlight lang="javascript">const { InteractionResponseType, InteractionType, verifyKey } = require('discord-interactions')
<syntaxhighlight lang="javascript">const { InteractionResponseType, InteractionType, verifyKey } = require('discord-interactions')
192行目: 226行目:
const CLIENT_PUBLIC_KEY = process.env.CLIENT_PUBLIC_KEY;
const CLIENT_PUBLIC_KEY = process.env.CLIENT_PUBLIC_KEY;
const PIN_ENDPOINT = process.env.PIN_ENDPOINT;
const PIN_ENDPOINT = process.env.PIN_ENDPOINT;
const BOT_TOKEN = process.env.BOT_TOKEN;


exports.handler = async (event, context) => {
exports.handler = async (event, context) => {
206行目: 241行目:
     if (interaction && interaction.type === InteractionType.APPLICATION_COMMAND) {
     if (interaction && interaction.type === InteractionType.APPLICATION_COMMAND) {
         console.log("called as an interaction")
         console.log("called as an interaction")
 
         //各種情報を取得
         //初期メッセージのための各種情報を取得
         const mess_data = interaction.data.resolved.messages
         const mess_data = interaction.data.resolved.messages
         const message_id = Object.keys(mess_data)[0]
         const message_id = Object.keys(mess_data)[0]
         const channel_id = mess_data[message_id].channel_id
         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)
         const dispname = interaction.member.nick ? interaction.member.nick : (interaction.member.user.global_name ? interaction.member.user.global_name : interaction.member.user.username)
        if ( event.headers['x-pin-agent-second-call'] == 1){
            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
        }
         //ピン留め用endpointを呼び出す。awaitしないで、リクエストを投げたら終了
         //ピン留め用endpointを呼び出す。awaitしないで、リクエストを投げたら終了
         fetch(PIN_ENDPOINT, {
         fetch(PIN_ENDPOINT, {
219行目: 259行目:
                 'x-signature-ed25519': signature,
                 'x-signature-ed25519': signature,
                 'x-signature-timestamp': timestamp,
                 'x-signature-timestamp': timestamp,
                'x-pin-agent-second-call': 1,
                 'Content-Type': 'application/json' // データ形式をJSONに指定
                 'Content-Type': 'application/json' // データ形式をJSONに指定
             },
             },
232行目: 273行目:
     } else {
     } else {
         console.log("! returning pong")
         console.log("! returning pong")
         return { statusCode: 200, body: JSON.stringify({ type: InteractionResponseType.PONG }) };
         return { statusCode: 200, 'headers': { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: InteractionResponseType.PONG }) };
     }
     }
}</syntaxhighlight>
}
<span id="pinjs"></span>
==== pin.js ====
 
<syntaxhighlight lang="javascript">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) {
async function runMessageCommand(guild_id, channel_id, message_id, commandName, user_info) {
     //既にピン留めされているかどうかを判定
     //既にピン留めされているかどうかを判定
303行目: 335行目:
         }
         }
     });
     });
}
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 }) };
    }
}</syntaxhighlight>
}</syntaxhighlight>
CLIENT_PUBLIC_KEYとBOT_TOKENはそれぞれDiscordのサイトから取る。PIN_ENDPOINTは各自の設定にあわせて<code>https&#58;//</code><em><strong><code>foo</code></strong></em><code>.netlify.app/.netlify/functions/</code><em><strong><code>bar</code></strong></em>のようになる。3つともNetlifyの環境変数に入れる。
CLIENT_PUBLIC_KEYとBOT_TOKENはそれぞれDiscordのサイトから取る。PIN_ENDPOINTは各自の設定にあわせて<code>https&#58;//</code><em><strong><code>foo</code></strong></em><code>.netlify.app/.netlify/functions/</code><em><strong><code>bar</code></strong></em>のようになる。3つともNetlifyの環境変数に入れる。
355行目: 354行目:


もし、bot追加時に要求されるこれらの権限のチェックボックスを外せば、デフォルトでつけられる権限もその分だけ減る。また、チェックボックスを全て外せば、そもそもロール自体が作られなくなるようである(事実上は、何も権限のないロールが付いているのと同じ)。このような場合でも、適宜ロールを割り当てたりBot対象に権限を与えたりすることで、botが以上3つの権限を行使できるようになれば、正しく動く。
もし、bot追加時に要求されるこれらの権限のチェックボックスを外せば、デフォルトでつけられる権限もその分だけ減る。また、チェックボックスを全て外せば、そもそもロール自体が作られなくなるようである(事実上は、何も権限のないロールが付いているのと同じ)。このような場合でも、適宜ロールを割り当てたりBot対象に権限を与えたりすることで、botが以上3つの権限を行使できるようになれば、正しく動く。
* ちなみに、今回のようにサーバーの一因として行動(ピン留めやメッセージの送信など)するのではなく、独立した情報を返すだけ(例えば天気予報を流すとか)のコマンド(スラッシュコマンドや右クリックメニュー)であれば、botとして招待する必要はなく、単なるapplication commandの権限のみで十分である。


=== その他仕様 ===
=== その他仕様 ===


右クリックメニューは、メッセージ送信権限があるチャンネルでないと表示されない仕様となっている。[https://github.com/discord/discord-api-docs/discussions/5097 Allow slash commands to be used when Send Message is Disabled · discord/discord&#45;api&#45;docs · Discussion &#35;5097]
右クリックメニューは、自分がメッセージ送信権限を持っているチャンネルでないと表示されない仕様となっている。[https://github.com/discord/discord-api-docs/discussions/5097 Allow slash commands to be used when Send Message is Disabled · discord/discord&#45;api&#45;docs · Discussion &#35;5097]
[[Category:IT]]{{#seo:|title={{FULLPAGENAME}} - Turgenev's Wiki}}
[[Category:IT]]{{#seo:|title={{FULLPAGENAME}} - Turgenev's Wiki}}