Skip to content

Commit 05c137e

Browse files
committed
fix: qq official webhook mode can not restart normally
1 parent 1a04998 commit 05c137e

2 files changed

Lines changed: 217 additions & 2 deletions

File tree

astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import asyncio
2+
import json
23
import logging
34
import time
5+
from binascii import Error as BinasciiError
46
from typing import cast
57

68
import quart
79
from botpy import BotAPI, BotHttp, BotWebSocket, Client, ConnectionSession, Token
10+
from cryptography.exceptions import InvalidSignature
811
from cryptography.hazmat.primitives.asymmetric import ed25519
912

1013
from astrbot.api import logger
@@ -13,6 +16,57 @@
1316
for 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

1771
class 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")
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import asyncio
2+
import json
3+
4+
import pytest
5+
6+
from astrbot.core.platform.sources.qqofficial_webhook.qo_webhook_server import (
7+
_SIGNATURE_HEADER,
8+
_SIGNATURE_TIMESTAMP_HEADER,
9+
QQOfficialWebhook,
10+
_sign_qq_webhook_payload,
11+
_verify_qq_webhook_signature,
12+
)
13+
14+
15+
class FakeRequest:
16+
def __init__(self, body: bytes, headers: dict[str, str] | None = None) -> None:
17+
self._body = body
18+
self.headers = headers or {}
19+
20+
async def get_data(self) -> bytes:
21+
return self._body
22+
23+
24+
class FakeBotpyClient:
25+
api = None
26+
http = None
27+
28+
def ws_dispatch(self, *_args, **_kwargs) -> None:
29+
return None
30+
31+
32+
def test_qq_webhook_signature_verification_accepts_valid_signature():
33+
secret = "test-secret"
34+
timestamp = "1710000000"
35+
body = b'{"op":12,"d":0}'
36+
signature = _sign_qq_webhook_payload(secret, timestamp, body)
37+
38+
assert _verify_qq_webhook_signature(secret, timestamp, signature, body)
39+
40+
41+
def test_qq_webhook_signature_verification_rejects_tampered_body():
42+
secret = "test-secret"
43+
timestamp = "1710000000"
44+
body = b'{"op":12,"d":0}'
45+
signature = _sign_qq_webhook_payload(secret, timestamp, body)
46+
47+
assert not _verify_qq_webhook_signature(
48+
secret,
49+
timestamp,
50+
signature,
51+
b'{"op":12,"d":1}',
52+
)
53+
54+
55+
@pytest.mark.asyncio
56+
async def test_qq_webhook_callback_rejects_missing_signature():
57+
webhook = object.__new__(QQOfficialWebhook)
58+
webhook.secret = "test-secret"
59+
60+
result = await webhook.handle_callback(FakeRequest(b'{"op":12,"d":0}'))
61+
62+
assert result == ({"error": "Invalid signature"}, 401)
63+
64+
65+
@pytest.mark.asyncio
66+
async def test_qq_webhook_callback_accepts_signed_validation():
67+
secret = "test-secret"
68+
event_ts = "1710000000"
69+
plain_token = "plain-token"
70+
body = json.dumps(
71+
{"op": 13, "d": {"event_ts": event_ts, "plain_token": plain_token}},
72+
separators=(",", ":"),
73+
).encode("utf-8")
74+
signature = _sign_qq_webhook_payload(secret, event_ts, body)
75+
webhook = object.__new__(QQOfficialWebhook)
76+
webhook.secret = secret
77+
78+
result = await webhook.handle_callback(
79+
FakeRequest(
80+
body,
81+
{
82+
_SIGNATURE_TIMESTAMP_HEADER: event_ts,
83+
_SIGNATURE_HEADER: signature,
84+
},
85+
)
86+
)
87+
88+
assert result == {
89+
"plain_token": plain_token,
90+
"signature": _sign_qq_webhook_payload(secret, event_ts, plain_token.encode()),
91+
}
92+
93+
94+
@pytest.mark.asyncio
95+
async def test_qq_webhook_callback_lazily_creates_botpy_connection():
96+
secret = "test-secret"
97+
timestamp = "1710000000"
98+
body = json.dumps(
99+
{"op": 0, "t": "UNKNOWN_EVENT", "id": "event-id", "d": {"id": "message-id"}},
100+
separators=(",", ":"),
101+
).encode("utf-8")
102+
signature = _sign_qq_webhook_payload(secret, timestamp, body)
103+
webhook = QQOfficialWebhook(
104+
{"appid": "123", "secret": secret},
105+
asyncio.Queue(),
106+
FakeBotpyClient(),
107+
)
108+
109+
result = await webhook.handle_callback(
110+
FakeRequest(
111+
body,
112+
{
113+
_SIGNATURE_TIMESTAMP_HEADER: timestamp,
114+
_SIGNATURE_HEADER: signature,
115+
},
116+
)
117+
)
118+
119+
assert result == {"opcode": 12}
120+
assert webhook._connection is not None
121+
assert webhook.http._token is not None
122+
assert webhook.http._token.app_id == "123"
123+
assert webhook.client.api is webhook.api
124+
assert webhook.client.http is webhook.http

0 commit comments

Comments
 (0)