1
3

More than 1 year has passed since last update.

discordのイベント検知の方法

Posted at

動機

自分がよく使っているdiscordのBOTがどう動いているのかが気になった。

仕組み

自分なりに解釈したものが混ざっています。
図が下手なので言葉で。

送受信はwebsocketを使います。

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の時にこちらが送る必要のあるデータ

この送信直後に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_readyon_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_CREATEon_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

1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3