動機
自分がよく使っているdiscordのBOTがどう動いているのかが気になった。
仕組み
自分なりに解釈したものが混ざっています。
図が下手なので言葉で。
送受信はwebsocketを使います。
- websocketでゲートウェイを開拓。
- string形式でwebsocketにtokenを送り、自分の情報を伝える。(send_str()のop: 2で承認を求める)
https://discord.com/developers/docs/topics/opcodes-and-status-codes#gateway-gateway-opcodes
string = {
"op": 2,
"d": {
"token": "YOUR_BOT_TOKEN",
"properties": {},
"intents": 3276799
}
}
-
op
: discord側からの通知の種類の番号。2 はIdentify
、
Starts a new session during the initial handshake.
新しいセッションを開始する時に送る必要があります。
参考
-
d
: "op" が2の時にこちらが送る必要のあるデータ
- botのtoken, こちらの使っているos等の詳細(中身はカラでも動く。), intents(v8以上は必要)
- intents: https://discord.com/developers/docs/topics/gateway#list-of-intents
- すべてのIntentsを選択すると3276799になる。
この送信直後にdiscord側から通知が来ます。
{
"t": null,
"s": null,
"op": 10,
"d":{
"heartbeat_interval": 41250,
"_trace": [
[
"gateway-prd-main-1s8j",
{
"micros":0.0
}
]
]
}
}
-
t
: イベントの名前。この場合、定義されていないためnull
が返る。 -
s
: セッションとハートビートの再開に使用されるシーケンス番号。詳細は後程 -
op
: discord側からの通知の種類の番号。10 はHello
、
Sent immediately after connecting, contains the heartbeat_interval to use.
接続直後に送られます。ハートビート(ms)が含まれています。
参考
ハートビート:こちらの接続が生きていることを確認するために定期的に送信するために開ける時間。
また、一緒に READY
(準備完了)のイベントも(ほぼ)同時に通知されます。
結構長いので一部省略。(ソースコードも下に載せておくので実行してみてください。)
{
"t": "READY",
"s": 1,
"op": 0,
"d": {
"v": 6,
"user_settings": {...},
"user": {...},
"session_type": "normal",
"session_id": "8bf0d536635553442483b9fc42745ff4",
"resume_gateway_url": "wss://gateway.discord.gg/",
"relationships": [...],
"private_channels": [...],
"presences": [...],
"lazy_private_channels": [...],
"guilds": [...]
}
}
-
t
:READY
(準備完了) -
s
: セッション番号。詳細は下に。 -
op
: 番号は0。Dispatch
An event was dispatched.
イベントが実行されました。
イベントの内容はいろいろあり、discord.pyやpycordなどを触っている人ならある程度分かると思います。
そう。on_ready
や on_message
などのon_
で始まるあのイベントのことです。
今回はREADYなので、 on_ready
に当たります。
このイベントで重要なのは d
の中にある session_id
です。
これは先ほどのheartbeatごとに送る必要のあるものです。
さて、あとで説明するといった シーケンス番号、セッションIDについて書きます。
- シーケンス番号: イベントが実行された回数。イベント一覧のリンクはこちら。
普通に行くとREADYが1となります。その後に続いて2, 3, 4, ...となります。
接続直後は所属しているすべてのサーバーデータが返ります。 - セッションID: まず、自分がハートビートで指定された時間ごとにこちらの情報を再送信します。しかし、discord側は複数の接続を受けているので、自分の情報を正確に送る必要があります。
その中の情報にdiscord側から伝えられたセッションIDを入れることで正しい接続であることがdiscord側にも分かります。
ここまでで一応最初の接続までが終わりました。
イベントの受信
すべてのイベントの詳細はこちら
イベント(一部)
イベント名 | 内容 |
---|---|
READY | 準備完了 |
GUILD_CREATE | 接続時に最初に渡されるその時点での参加しているサーバーデータ |
MESSAGE_CREATE | メッセージが送信されたとき |
MESSAGE_DELETE | メッセージが削除されたとき |
CHANNEL_CREATE | チャンネルが作られたとき |
Botが動いている間、Bot自身が監視できるイベントを取得します
on_message(...)
がMESSAGE_CREATE
、on_guild_member_join(...)
が Guild_Member_Add
という感じです。
例えば、
メッセージを送信した時:
詳細
{
"t": "MESSAGE_CREATE",
"s": 6,
"op": 0,
"d": {
"type": 0,
"tts": false,
"timestamp": "...",
"referenced_message": null,
"pinned": false,
"nonce": "...",
"mentions": [],
"mention_roles": [],
"mention_everyone": false,
"member": {
"roles": [
"..."
],
"premium_since": null,
"pending": false,
"nick": null,
"mute": false,
"joined_at": "...",
"flags": 0,
"deaf": false,
"communication_disabled_until": null,
"avatar": null
},
"id": "...",
"flags": 0,
"embeds": [],
"edited_timestamp": null,
"content": "test_message",
"components": [],
"channel_id": "...",
"author": {
"username": "...",
"public_flags": 64,
"id": "...",
"discriminator": "...",
"avatar_decoration": null,
"avatar": "..."
},
"attachments": [],
"guild_id": "..."
}
}
d
の中身には、"content"
にメッセージの内容、"member"
には送信者のサーバーデータ、"author"
には送信者のデータ、... などのいろいろなものが入っています。
ここから、on_message(message: discord.Message)
につなげていることもわかります。
リアクションを追加した時
詳細
{
"t": "MESSAGE_REACTION_ADD",
"s": 10,
"op": 0,
"d": {
"user_id": "...",
"message_id": "...",
"member": {
"user": {
"username": "...",
"public_flags": 64,
"id": "...",
"discriminator": "...",
"bot": false,
"avatar": "..."
},
"roles": [
"..."
],
"premium_since": null,
"pending": false,
"nick": null,
"mute": false,
"joined_at": "...",
"flags": 0,
"deaf": false,
"communication_disabled_until": null,
"avatar": null
},
"emoji": {
"name": "🎉",
"id": null
},
"channel_id": "...",
"guild_id": "..."
}
}
こちらにも "emoji"
には絵文字の内容が入っています。
これもon_reaction_add(reaction: discord.Reaction, user: discord.User)
につながります。
実行したコード
プログラム詳細(汚いですがすみません。)
import asyncio
import json
import threading
import time
from os import getenv
import aiohttp
from dotenv import load_dotenv
# TOKENを取得
load_dotenv(".env")
token = getenv("TOKEN")
# シーケンス番号を初期値に0に設定
# 各関数でglobal宣言をして使う。
seqense = 0
# headerを作成
headers = {
"Authorization": f"Bot {token}"
}
# 最初に送るメッセージを作成
string = {
"op": 2,
"d": {
"token": token,
"properties": {},
"intents": 3276799
}
}
def counter(ws: aiohttp.ClientWebSocketResponse, heatbeat: int, session_id: str):
global seqense
while True:
# 安全を持って1秒早く送信
time.sleep(heatbeat / 1000 - 1)
# 内容の詳細:https://discord.com/developers/docs/topics/gateway#heartbeat
asyncio.run(ws.send_str(json.dumps({"op": 1, "d": seqense})))
print("sended!")
async def main():
global seqense
# sessionを作成
async with aiohttp.ClientSession() as session:
# そのsessionからwebsocketを作成
async with session.ws_connect(
# websocketのリンク、headerの作成
url="wss://gateway.discord.gg/bot/?v=6&encoding=json",
method="GET",
headers=headers
) as ws:
# 自分の情報を送る
await ws.send_str(json.dumps(string))
# ハートビートの入った情報を受信 & dictにパース & 出力
_first_msg = await asyncio.wait_for(ws.receive(), timeout=2)
first_msg = json.loads(_first_msg.data)
print(first_msg)
# ハートビートを取り出す
heartbeat = first_msg["d"]["heartbeat_interval"]
# `READY` の情報を受信 & dictにパース & 出力
_ready_msg = await asyncio.wait_for(ws.receive(), timeout=2)
ready_msg = json.loads(_ready_msg.data)
print(ready_msg)
# セッションIDを取り出し、ハートビートとともに出力
session_id = ready_msg["d"].get("session_id")
print("heartbeat: ", heartbeat, ", session_id: ", session_id)
# スレッドを作成し、heartbeadミリ秒ごとに自分が生きていることを知らせるために情報を送る
# ハートビートごとに送る必要がある。また、その中に何回イベントが起きたかを示すシーケンス番号を入れる
th = threading.Thread(target=counter, args=(ws, heartbeat, session_id))
th.start()
# 下で使う万が一の時の時に送信する文のフォーマットを作成
# global宣言されているので常に更新されている。
_res = {
"op": 6,
"d": {
"token": token,
"session_id": session_id,
"seq": seqense
}
}
while True:
try:
# 2秒をタイムアウトとしてユーザーがメッセージを送信するなどしていべんとがおきるのを待つ。
# 2秒を過ぎるとTimeoutErrorを起こし、exceptに移動。
rcv: aiohttp.WSMessage = await asyncio.wait_for(ws.receive(), timeout=2)
# 受け取ったデータをdict型にパース
_json = json.loads(rcv.data)
# その中からシーケンス番号を取得し、グローバル宣言されたseqense変数に代入
seqense = _json.get("s", None)
# シーケンス番号がNoneのとき、最初に戻る
if seqense is None:
continue
# もし`op` が1の時、ユーザーは直ちに `RESUME`で再送信する必要がある
# 参照:https://discord.com/developers/docs/topics/gateway#resume
if _json.get("op") == 1:
await ws.send_str(json.dumps(_res))
# 最後に、イベントがあったときにシーケンス番号、受け取った内容を出力
print(seqense, rcv)
# wait_forでタイムアウトが起きた時、例外が発生し、最初に戻る。
except asyncio.TimeoutError:
pass
if __name__ == '__main__':
# 関数を非同期で実行
asyncio.run(main())
感想
調べてもよくわからないところが多かったのですが、なんとなくわかって来てこれを作った人はすごいと思います。
たくさんの方からいろいろ教えていただき、助かりました。ありがとうございます。
編集リクエストありましたら送ってもらえると嬉しいです。
参照
https://discord.com/developers/docs/topics/gateway
https://discord.com/developers/docs/topics/opcodes-and-status-codes