diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py index 38ad45b56..277c85336 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py @@ -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() diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py index 2cee8c695..8a0ff59a0 100644 --- a/astrbot/core/platform/sources/wecom/wecom_adapter.py +++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py @@ -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 diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py index 2a1628054..1c0ee96c0 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py @@ -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]: diff --git a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py index 0fa73889e..0a7e90a0b 100644 --- a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py +++ b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py @@ -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 diff --git a/astrbot/core/platform/webhook_server.py b/astrbot/core/platform/webhook_server.py index 57fe52dfc..dbbdcb676 100644 --- a/astrbot/core/platform/webhook_server.py +++ b/astrbot/core/platform/webhook_server.py @@ -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, diff --git a/astrbot/dashboard/api/platform.py b/astrbot/dashboard/api/platform.py index 3ba03d745..a7ff2482b 100644 --- a/astrbot/dashboard/api/platform.py +++ b/astrbot/dashboard/api/platform.py @@ -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)) ) diff --git a/tests/test_fastapi_v1_dashboard.py b/tests/test_fastapi_v1_dashboard.py index 3436ae22f..13528c8ae 100644 --- a/tests/test_fastapi_v1_dashboard.py +++ b/tests/test_fastapi_v1_dashboard.py @@ -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" diff --git a/tests/test_qqofficial_webhook_signature.py b/tests/test_qqofficial_webhook_signature.py index 796bd8967..ad504bd67 100644 --- a/tests/test_qqofficial_webhook_signature.py +++ b/tests/test_qqofficial_webhook_signature.py @@ -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, diff --git a/tests/test_webhook_server_response.py b/tests/test_webhook_server_response.py new file mode 100644 index 000000000..036d83234 --- /dev/null +++ b/tests/test_webhook_server_response.py @@ -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)