11import asyncio
2+ import json
23import logging
34import time
5+ from binascii import Error as BinasciiError
46from typing import cast
57
68import quart
79from botpy import BotAPI , BotHttp , BotWebSocket , Client , ConnectionSession , Token
10+ from cryptography .exceptions import InvalidSignature
811from cryptography .hazmat .primitives .asymmetric import ed25519
912
1013from astrbot .api import logger
1316for handler in logging .root .handlers [:]:
1417 logging .root .removeHandler (handler )
1518
19+ _SIGNATURE_HEADER = "X-Signature-Ed25519"
20+ _SIGNATURE_TIMESTAMP_HEADER = "X-Signature-Timestamp"
21+ _ED25519_SEED_SIZE = 32
22+ _ED25519_SIGNATURE_SIZE = 64
23+
24+
25+ def _build_ed25519_seed (secret : str ) -> bytes :
26+ if not secret :
27+ raise ValueError ("QQ official bot secret is empty." )
28+
29+ seed = secret .encode ("utf-8" )
30+ while len (seed ) < _ED25519_SEED_SIZE :
31+ seed *= 2
32+ return seed [:_ED25519_SEED_SIZE ]
33+
34+
35+ def _sign_qq_webhook_payload (secret : str , timestamp : str , payload : bytes ) -> str :
36+ seed = _build_ed25519_seed (secret )
37+ private_key = ed25519 .Ed25519PrivateKey .from_private_bytes (seed )
38+ return private_key .sign (timestamp .encode ("utf-8" ) + payload ).hex ()
39+
40+
41+ def _verify_qq_webhook_signature (
42+ secret : str ,
43+ timestamp : str | None ,
44+ signature : str | None ,
45+ body : bytes ,
46+ ) -> bool :
47+ if not timestamp or not signature :
48+ return False
49+
50+ try :
51+ signature_buffer = bytes .fromhex (signature )
52+ except (BinasciiError , ValueError ):
53+ return False
54+
55+ if (
56+ len (signature_buffer ) != _ED25519_SIGNATURE_SIZE
57+ or signature_buffer [63 ] & 224 != 0
58+ ):
59+ return False
60+
61+ try :
62+ seed = _build_ed25519_seed (secret )
63+ private_key = ed25519 .Ed25519PrivateKey .from_private_bytes (seed )
64+ public_key = private_key .public_key ()
65+ public_key .verify (signature_buffer , timestamp .encode ("utf-8" ) + body )
66+ except (InvalidSignature , ValueError ):
67+ return False
68+ return True
69+
1670
1771class QQOfficialWebhook :
1872 def __init__ (
@@ -27,7 +81,12 @@ def __init__(
2781 if isinstance (self .port , str ):
2882 self .port = int (self .port )
2983
30- self .http : BotHttp = BotHttp (timeout = 300 , is_sandbox = self .is_sandbox )
84+ self .http : BotHttp = BotHttp (
85+ timeout = 300 ,
86+ is_sandbox = self .is_sandbox ,
87+ app_id = self .appid ,
88+ secret = self .secret ,
89+ )
3190 self .api : BotAPI = BotAPI (http = self .http )
3291 self .token = Token (self .appid , self .secret )
3392
@@ -40,6 +99,7 @@ def __init__(
4099 self .client = botpy_client
41100 self .event_queue = event_queue
42101 self .shutdown_event = asyncio .Event ()
102+ self ._connection : ConnectionSession | None = None
43103
44104 # Cache for extra fields extracted from raw webhook payloads, keyed by message id
45105 self ._extra_data_cache : dict [str , dict ] = {}
@@ -55,6 +115,13 @@ async def initialize(self) -> None:
55115 # 直接注入到 botpy 的 Client,移花接木!
56116 self .client .api = self .api
57117 self .client .http = self .http
118+ self ._setup_connection ()
119+
120+ def _setup_connection (self ) -> None :
121+ if self ._connection is not None :
122+ return
123+ self .client .api = self .api
124+ self .client .http = self .http
58125
59126 async def bot_connect () -> None :
60127 pass
@@ -105,7 +172,24 @@ async def handle_callback(self, request) -> dict:
105172 Returns:
106173 响应数据
107174 """
108- msg : dict = await request .json
175+ body = await request .get_data ()
176+ if not _verify_qq_webhook_signature (
177+ self .secret ,
178+ request .headers .get (_SIGNATURE_TIMESTAMP_HEADER ),
179+ request .headers .get (_SIGNATURE_HEADER ),
180+ body ,
181+ ):
182+ logger .warning ("qq_official_webhook signature verification failed." )
183+ return {"error" : "Invalid signature" }, 401
184+
185+ try :
186+ msg = json .loads (body .decode ("utf-8" ))
187+ except json .JSONDecodeError :
188+ logger .warning ("qq_official_webhook callback body is not valid JSON." )
189+ return {"error" : "Invalid JSON" }, 400
190+ if not isinstance (msg , dict ):
191+ return {"error" : "Invalid JSON" }, 400
192+
109193 logger .debug (f"收到 qq_official_webhook 回调: { msg } " )
110194
111195 event = msg .get ("t" )
@@ -136,6 +220,13 @@ async def handle_callback(self, request) -> dict:
136220
137221 if event and opcode == BotWebSocket .WS_DISPATCH_EVENT :
138222 event = msg ["t" ].lower ()
223+ if self ._connection is None :
224+ logger .warning (
225+ "qq_official_webhook botpy connection is not initialized; "
226+ "creating parser connection lazily." ,
227+ )
228+ self ._setup_connection ()
229+
139230 # Extract extra fields from raw payload before botpy parses and discards them
140231 if data :
141232 msg_id = data .get ("id" )
0 commit comments