「Discord bot」の版間の差分
(Notion-MW) |
(Notion-MW) |
||
127行目: | 127行目: | ||
[https://www.netlify.com/blog/intro-to-serverless-functions/#traditional-serverless-functions https://www.netlify.com/blog/intro-to-serverless-functions/#traditional-serverless-functions] | [https://www.netlify.com/blog/intro-to-serverless-functions/#traditional-serverless-functions https://www.netlify.com/blog/intro-to-serverless-functions/#traditional-serverless-functions] | ||
以下ではこれの通りCommonJS形式で説明する。ES Modules形式の場合の注意点も↑に書いてある。 | |||
ntl initで全部やってもいいが、loginは別でやったほうがわかりやすいかも | ntl initで全部やってもいいが、loginは別でやったほうがわかりやすいかも | ||
githubへのアクセスを認めなきゃならんのはなんでなのかよくわかってない | <s>githubへのアクセスを認めなきゃならんのはなんでなのかよくわかってない</s> | ||
* [https://docs.netlify.com/cli/get-started/ 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のメニューで確かにそんなん出てたな | * [https://docs.netlify.com/cli/get-started/ 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のメニューで確かにそんなん出てたな |
2024年11月23日 (土) 01:19時点における最新版
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して適当な文字列を返すようにでも設定しておく(Web Services | Render Docs)。
renderで新規デプロイが走る際は、0.0.0.0:10000を使用するのが新しいバージョンに置き換わってから旧バージョンが完全に停止するまでラグがあるため、botが二重に稼働する(二重にメッセージが投稿される)時間帯が10~20秒ほどある。
仕様について
比較的最近Discordに追加された「フォーラム」のスレッドも、内部的には通常のチャンネルのスレッドと同じなので、全く同様に通知される。
ピン留めbot
- 参考サイト
Azure FunctionsでサーバーレスDiscord Botを作る | cloud.config Tech Blog
- これも良さそう
スラッシュコマンドでぬいぐるみとおしゃべりする Discord Bot
ロジック部分はJavascriptを使う。
FaaSとしてはNetlify Functions(AWS Lambdaがバックエンド?)を使用する。最終的には使わなかったが無料だと他にもCloudflare Workersでも動作確認できた(コードの細部は変えている)。
右クリックメニューへの登録
エンドポイントの作成(&Discord側への登録)をやっていなくても、とりあえず右クリックメニューを出すところまではできるので、まずここからやってみるとよい。
DiscordのSlash Commandをwebhookで受信する方式で作ってみるとかDiscord Developer Portal — Documentation — Application Commandsにある通りに適当にリクエストを投げる。
こちらでも一応適当なコード例を載せておく。
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")
表示された右クリックメニューを実行して、エラーが戻ってくることを確認しよう。
一度登録が済んでしまえば、メニュー内容に変更が無い限りはこのコードはもう使わない。
Netlifyの基本
まず以下を読む。
https://www.netlify.com/blog/intro-to-serverless-functions/#traditional-serverless-functions
以下ではこれの通りCommonJS形式で説明する。ES Modules形式の場合の注意点も↑に書いてある。
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, 'headers': { 'Content-Type': 'application/json' }, 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として、これを2回呼び出すようにした。つまり、1度目の呼び出しでは「自分自身を再び呼び出した上で、対象メッセージがピン留め操作中であることを示す初期メッセージを返す」という操作を行い、2度目の呼び出しでは「ピン留めを行い、ログチャンネルにログを送信し(ここでも前述のように、最初は表示名べた書きの仮メッセージを送ってから後で編集してメンションを付ける)、初期メッセージを消す」という操作を行う。
もちろん、1回目と2回目で完全に別の関数に分けてもよいが、あえて同じ関数を複数回呼び出すことで、Discordの署名の検証メカニズムをそのまま使えるというメリットがある。つまり、1回目の呼び出しから2回目を呼び出す際に、Discordから送られてきたHTTPリクエストのヘッダーの署名とbodyをすべてそのまま2回目の呼び出しに渡すことで、2回目についても同様に署名を検証すればDiscord以外から呼び出されることを防げる、というわけである。bodyが変えられないならどうやって2回目の呼び出しであるかを判定するのか?という点については、HTTPリクエストのヘッダにカスタムデータを入れることで対処できる。また、関数を1つにまとめることで、共通するロジック(ユーザー名の取得など)も1か所に書くだけでよくなるという副次的なメリットもある。
- なお、
verifyKey
関数による署名の検証においては、timestampが古すぎないかどうかの検証はない(←ChatGPT情報)らしいので、ネットワーク遅延などで2回目の呼び出し多少が遅くなっても問題はなさそうである。 - 参考サイト Discord Interaction Endpoint を多段 Lambda 構成にしてタイムアウトを回避する | DevelopersIO 今回はNetlifyなので直接は使えないが、参考までに。
- 3秒以内に返さなきゃいけないほうの「初期メッセージ」(ピン留めを呼び出したチャンネルに出る)と、後からメンションを付加する前の「仮メッセージ」(ログチャンネルに出る)という2つを混同しないよう注意
- ちなみに初期メッセージはAPIからの応答をDiscordが表示するものなので、Bot側にメッセージ送信権が無くても表示される。さらに、今回の実装では、応答にEPHEMERALフラグを指定することで、右クリックメニューを使用したユーザーだけに初期メッセージが表示されるようにし、他のユーザーに(特にピン留め解除時に)(送信されたメッセージが即座に消されることによる)ゴースト通知が表示されるのを防ぐ。
TLSハンドシェイクの待ち時間
pin関数の中でpin関数の応答を待っていては元も子もないので、pin関数を呼ぶだけ呼んで終了するということになる。
しかし、リクエストを投げた直後に終了すると、おそらくhttpsのためのTLSハンドシェイクが終わっていないという感じのエラーが出てしまった。
ChatGPTによると例えば日本とブラジル(のクラウドサービス)間とかになるとネットワーク遅延は最大250ms-350msくらい、TLSハンドシェイクにかかるのはその3-4倍の時間らしいので、そういうときは1秒以上くらい待つのが安全かもしれない。ただ今回は呼ぶ側も呼ばれる側もNetlifyなので、一応500msくらい待っているが全然エラーが出ることはない。
コード
特にエラーハンドリングなどが色々と適当だがスルーしてほしい。
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;
const BOT_TOKEN = process.env.BOT_TOKEN;
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)
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しないで、リクエストを投げたら終了
fetch(PIN_ENDPOINT, {
method: 'POST', // POSTメソッドを指定
headers: {
// 署名をそのまま転送
'x-signature-ed25519': signature,
'x-signature-timestamp': timestamp,
'x-pin-agent-second-call': 1,
'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, 'headers': { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: InteractionResponseType.PONG }) };
}
}
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秒待つ
}
});
}
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つの権限を行使できるようになれば、正しく動く。
- ちなみに、今回のようにサーバーの一因として行動(ピン留めやメッセージの送信など)するのではなく、独立した情報を返すだけ(例えば天気予報を流すとか)のコマンド(スラッシュコマンドや右クリックメニュー)であれば、botとして招待する必要はなく、単なるapplication commandの権限のみで十分である。
その他仕様
右クリックメニューは、自分がメッセージ送信権限を持っているチャンネルでないと表示されない仕様となっている。Allow slash commands to be used when Send Message is Disabled · discord/discord-api-docs · Discussion #5097