mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 09:40:30 +08:00
fix: preserve webhook callback responses
Preserve raw webhook callback responses and fix QQ webhook validation handling.
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
27
tests/test_webhook_server_response.py
Normal file
27
tests/test_webhook_server_response.py
Normal 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)
|
||||
Reference in New Issue
Block a user