fix: preserve webhook callback responses

Preserve raw webhook callback responses and fix QQ webhook validation handling.
This commit is contained in:
Weilong Liao
2026-07-03 00:25:09 +08:00
committed by GitHub
parent ea19be1d06
commit 1e3b12acc9
9 changed files with 120 additions and 47 deletions

View File

@@ -176,15 +176,6 @@ class QQOfficialWebhook:
响应数据
"""
body = await request.get_data()
if not _verify_qq_webhook_signature(
self.secret,
request.headers.get(_SIGNATURE_TIMESTAMP_HEADER),
request.headers.get(_SIGNATURE_HEADER),
body,
):
logger.warning("qq_official_webhook signature verification failed.")
return {"error": "Invalid signature"}, 401
try:
msg = json.loads(body.decode("utf-8"))
except json.JSONDecodeError:
@@ -205,6 +196,15 @@ class QQOfficialWebhook:
logger.debug(f"webhook validation response: {signed}")
return signed
if not _verify_qq_webhook_signature(
self.secret,
request.headers.get(_SIGNATURE_TIMESTAMP_HEADER),
request.headers.get(_SIGNATURE_HEADER),
body,
):
logger.warning("qq_official_webhook signature verification failed.")
return {"error": "Invalid signature"}, 401
event_id = msg.get("id")
if event_id:
now = time.monotonic()

View File

@@ -272,15 +272,13 @@ class WecomPlatformAdapter(Platform):
) -> None:
# 企业微信客服不支持主动发送
if hasattr(self.client, "kf_message"):
logger.warning("企业微信客服模式不支持 send_by_session 主动发送。")
await super().send_by_session(session, message_chain)
return
raise Exception("企业微信客服模式不支持 send_by_session 主动发送。")
if not self.agent_id:
logger.warning(
await super().send_by_session(session, message_chain)
raise Exception(
f"send_by_session 失败:无法为会话 {session.session_id} 推断 agent_id。",
)
await super().send_by_session(session, message_chain)
return
message_obj = AstrBotMessage()
message_obj.self_id = self.agent_id
@@ -303,7 +301,7 @@ class WecomPlatformAdapter(Platform):
"wecom 适配器",
id=self.config.get("id", "wecom"),
support_streaming_message=False,
support_proactive_message=False,
support_proactive_message=True,
)
@override

View File

@@ -121,7 +121,7 @@ class WecomAIBotAdapter(Platform):
name="wecom_ai_bot",
description="企业微信智能机器人适配器,支持 HTTP 回调和长连接模式",
id=self.config.get("id", "wecom_ai_bot"),
support_proactive_message=bool(self.msg_push_webhook_url),
support_proactive_message=True,
)
self.api_client: WecomAIBotAPIClient | None = None
@@ -568,21 +568,18 @@ class WecomAIBotAdapter(Platform):
) -> None:
"""通过消息推送 webhook 发送消息。"""
if not self.webhook_client:
logger.warning(
"主动消息发送失败: 未配置企业微信消息推送 Webhook URL请前往配置添加。session_id=%s",
session.session_id,
raise RuntimeError(
"主动消息发送失败: 未配置企业微信消息推送 Webhook URL请前往配置添加。"
"详见文档: https://docs.astrbot.app/platform/wecom_ai_bot.html#%E9%85%8D%E7%BD%AE-astrbot。"
f"session_id={session.session_id}"
)
await super().send_by_session(session, message_chain)
return
try:
await self.webhook_client.send_message_chain(message_chain)
except Exception as e:
logger.error(
"企业微信消息推送失败(session=%s): %s",
session.session_id,
e,
)
raise RuntimeError(
f"企业微信消息推送失败: session_id={session.session_id}, error={e}"
) from e
await super().send_by_session(session, message_chain)
def run(self) -> Awaitable[Any]:

View File

@@ -395,6 +395,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
message_chain: MessageChain,
) -> None:
await super().send_by_session(session, message_chain)
raise Exception("微信公众号不支持发送主动消息")
@override
def meta(self) -> PlatformMetadata:
@@ -403,7 +404,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
"微信公众平台 适配器",
id=self.config.get("id", "weixin_official_account"),
support_streaming_message=False,
support_proactive_message=False,
support_proactive_message=True,
)
@override

View File

@@ -33,7 +33,15 @@ class WebhookRequest:
raise
def _response_from_result(result: Any):
def webhook_response_from_result(result: Any):
"""Convert adapter callback results into raw webhook HTTP responses.
Args:
result: Adapter callback return value.
Returns:
A FastAPI-compatible raw response value.
"""
if isinstance(result, Response):
return result
@@ -55,6 +63,9 @@ def _response_from_result(result: Any):
if isinstance(result, dict | list):
return JSONResponse(result)
if isinstance(result, str | bytes):
return Response(content=result)
return result
@@ -77,7 +88,7 @@ class FastAPIWebhookServer:
result = view_func()
if inspect.isawaitable(result):
result = await result
return _response_from_result(result)
return webhook_response_from_result(result)
self.app.add_api_route(
path,

View File

@@ -5,6 +5,7 @@ from typing import Any
from fastapi import APIRouter, Depends, Request
from fastapi.responses import Response
from astrbot.core.platform.webhook_server import webhook_response_from_result
from astrbot.dashboard.asgi_runtime import DashboardRequest
from astrbot.dashboard.async_utils import run_maybe_async
from astrbot.dashboard.responses import ApiError, ok
@@ -58,6 +59,23 @@ async def _run(operation):
_raise_platform_error(exc)
async def _run_webhook(operation):
"""Run a platform webhook callback and preserve the platform response.
Args:
operation: Callback operation returning a platform-specific response.
Returns:
Raw FastAPI response compatible with third-party webhook protocols.
"""
try:
result = await run_maybe_async(operation)
except PlatformServiceError as exc:
return webhook_response_from_result(({"error": str(exc)}, exc.status_code))
return webhook_response_from_result(result)
@router.post("/bot-types/{bot_type}/registration")
async def register_bot_type(
bot_type: str,
@@ -76,7 +94,7 @@ async def verify_platform_webhook(
request: Request,
service: PlatformService = Depends(get_service),
):
return await _run(
return await _run_webhook(
lambda: service.handle_webhook_callback(webhook_uuid, DashboardRequest(request))
)
@@ -87,7 +105,7 @@ async def receive_platform_webhook(
request: Request,
service: PlatformService = Depends(get_service),
):
return await _run(
return await _run_webhook(
lambda: service.handle_webhook_callback(webhook_uuid, DashboardRequest(request))
)
@@ -98,7 +116,7 @@ async def dashboard_platform_webhook(
request: Request,
service: PlatformService = Depends(get_service),
):
return await _run(
return await _run_webhook(
lambda: service.handle_webhook_callback(webhook_uuid, DashboardRequest(request))
)

View File

@@ -389,10 +389,15 @@ class FakePlatform:
return True
async def webhook_callback(self, request_obj):
payload = await request_obj.get_json(silent=True)
if payload.get("response_mode") == "plain":
return "success"
if payload.get("response_mode") == "tuple":
return "accepted", 202, {"Content-Type": "text/plain"}
return {
"webhook_uuid": self.config["webhook_uuid"],
"method": request_obj.method,
"payload": await request_obj.get_json(silent=True),
"payload": payload,
}
async def send_by_session(self, session, message_chain) -> None:
@@ -3294,10 +3299,35 @@ async def test_v1_platform_webhook_is_public_route(
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["data"] == {
assert response.json() == {
"webhook_uuid": "demo-hook",
"method": "POST",
"payload": {"challenge": "ping"},
}
@pytest.mark.asyncio
async def test_v1_platform_webhook_preserves_plain_response(
asgi_client: httpx.AsyncClient,
):
response = await asgi_client.post(
"/api/v1/webhooks/platforms/demo-hook",
json={"response_mode": "plain"},
)
assert response.status_code == 200
assert response.text == "success"
@pytest.mark.asyncio
async def test_v1_platform_webhook_preserves_tuple_response(
asgi_client: httpx.AsyncClient,
):
response = await asgi_client.post(
"/api/v1/webhooks/platforms/demo-hook",
json={"response_mode": "tuple"},
)
assert response.status_code == 202
assert response.headers["content-type"] == "text/plain"
assert response.text == "accepted"

View File

@@ -63,7 +63,7 @@ async def test_qq_webhook_callback_rejects_missing_signature():
@pytest.mark.asyncio
async def test_qq_webhook_callback_accepts_signed_validation():
async def test_qq_webhook_callback_accepts_unsigned_validation():
secret = "test-secret"
event_ts = "1710000000"
plain_token = "plain-token"
@@ -71,19 +71,10 @@ async def test_qq_webhook_callback_accepts_signed_validation():
{"op": 13, "d": {"event_ts": event_ts, "plain_token": plain_token}},
separators=(",", ":"),
).encode("utf-8")
signature = _sign_qq_webhook_payload(secret, event_ts, body)
webhook = object.__new__(QQOfficialWebhook)
webhook.secret = secret
result = await webhook.handle_callback(
FakeRequest(
body,
{
_SIGNATURE_TIMESTAMP_HEADER: event_ts,
_SIGNATURE_HEADER: signature,
},
)
)
result = await webhook.handle_callback(FakeRequest(body))
assert result == {
"plain_token": plain_token,

View File

@@ -0,0 +1,27 @@
from fastapi.responses import JSONResponse, Response
from astrbot.core.platform.webhook_server import webhook_response_from_result
def test_webhook_response_preserves_plain_string():
response = webhook_response_from_result("success")
assert isinstance(response, Response)
assert response.body == b"success"
def test_webhook_response_preserves_tuple_headers():
response = webhook_response_from_result(
("accepted", 202, {"Content-Type": "text/plain"})
)
assert isinstance(response, Response)
assert response.status_code == 202
assert response.media_type == "text/plain"
assert response.body == b"accepted"
def test_webhook_response_keeps_json_for_dict():
response = webhook_response_from_result({"ok": True})
assert isinstance(response, JSONResponse)