ForcePilot/backend/server/routers/webhook_router.py
Kris 915ae2f5cc refactor(channel): 重构渠道管理器使用方式,新增微信webhook和渠道注册注销接口
1. 将全局channel_manager改为通过get_channel_manager()获取单例
2. 新增微信webhook处理接口
3. 在dashboard反馈接口中新增channel字段返回
4. 新增渠道注册、注销API接口
5. 优化部分代码格式和异常处理逻辑
6. 注册Nostr profile api路由
2026-05-13 16:51:23 +08:00

229 lines
9.0 KiB
Python

from __future__ import annotations
import asyncio
from fastapi import APIRouter, Request, status
from fastapi.responses import JSONResponse, Response
from yuxi.channels.manager import get_channel_manager
from yuxi.channels.registry import BUILTIN_ADAPTERS
from yuxi.utils.logging_config import logger
webhook = APIRouter(prefix="/webhook", tags=["webhook"])
@webhook.post("/feishu")
async def feishu_webhook(request: Request):
adapter = get_channel_manager()._adapters.get("feishu")
if not adapter:
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content={"code": 503, "data": None, "message": "Feishu channel is not running"},
)
body = await request.body()
headers = dict(request.headers)
source_ip = request.client.host if request.client else "default"
try:
from yuxi.channels.adapters.feishu.webhook_server import handle_feishu_webhook
result = await handle_feishu_webhook(body, headers, adapter, source_ip)
code = result.get("code", 0)
if code == 0:
response_content = {"code": 0, "data": None, "message": "ok"}
if "challenge" in result:
response_content["challenge"] = result["challenge"]
return JSONResponse(content=response_content)
if code == 413:
return JSONResponse(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
content={"code": 413, "message": result.get("message", "")},
)
if code == 403:
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"code": 403, "message": result.get("message", "")},
)
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"code": 400, "message": result.get("message", "")},
)
except Exception as e:
logger.error(f"[FeishuWebhook] Handler error: {e}")
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"code": 500, "message": str(e)},
)
@webhook.api_route("/wechat", methods=["GET", "POST"])
async def wechat_webhook(request: Request):
adapter = get_channel_manager()._adapters.get("wechat")
if not adapter:
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content={"code": 503, "data": None, "message": "WeChat channel is not running"},
)
if request.method == "GET":
return await _handle_wechat_url_verification(adapter, request)
body = await request.body()
headers = dict(request.headers)
source_ip = request.client.host if request.client else "unknown"
max_body_bytes = getattr(adapter, "_MAX_WEBHOOK_BODY_BYTES", 256 * 1024)
if len(body) > max_body_bytes:
logger.warning(f"[WeChatWebhook] Body too large: {len(body)} > {max_body_bytes}")
return JSONResponse(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
content={"code": 413, "message": "Request body too large"},
)
try:
if not await adapter.verify_webhook_signature(headers, body):
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"code": 403, "data": None, "message": "Invalid webhook signature"},
)
except NotImplementedError:
pass
try:
parsed_dict = adapter._parse_webhook_body(body)
passive_reply = await adapter.try_passive_reply(parsed_dict)
message = adapter.normalize_inbound(parsed_dict)
except Exception as e:
logger.error(f"[WeChatWebhook] Failed to parse message: {e}")
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"code": 400, "data": None, "message": f"Failed to parse message: {e}"},
)
asyncio.create_task(adapter._handle_message(message))
if passive_reply is not None:
return Response(content=passive_reply, media_type="application/xml")
return JSONResponse(content={"code": 0, "data": None, "message": "ok"})
async def _handle_wechat_url_verification(adapter, request: Request):
from yuxi.channels.adapters.wechat.mp.crypto import verify_url_echostr
from yuxi.channels.adapters.wechat.wecom.crypto import verify_url_signature
params = dict(request.query_params)
signature = params.get("signature", params.get("msg_signature", ""))
timestamp = params.get("timestamp", "")
nonce = params.get("nonce", "")
echostr = params.get("echostr", "")
token = adapter.config.get("token", "")
if not token or not echostr:
logger.warning("[WeChatWebhook] URL verification missing token or echostr")
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"code": 400, "message": "Missing token or echostr"},
)
mode = getattr(adapter, "_mode", "personal")
if mode == "wecom":
ok, result = verify_url_signature(token, timestamp, nonce, echostr, signature)
else:
ok, result = verify_url_echostr(token, timestamp, nonce, echostr, signature)
if not ok:
logger.warning("[WeChatWebhook] URL verification signature mismatch")
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"code": 403, "message": "Signature verification failed"},
)
logger.info(f"[WeChatWebhook] URL verification succeeded for mode={mode}")
return JSONResponse(content=result)
@webhook.post("/{channel_type}")
async def webhook_dispatch(channel_type: str, request: Request):
adapter = get_channel_manager()._adapters.get(channel_type)
adapter_cls = BUILTIN_ADAPTERS.get(channel_type)
if not adapter:
if adapter_cls and adapter_cls.webhook_path:
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content={"code": 503, "data": None, "message": f"Channel '{channel_type}' is not running"},
)
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={"code": 404, "data": None, "message": f"Webhook channel '{channel_type}' not found"},
)
body = await request.body()
headers = dict(request.headers)
source_ip = request.client.host if request.client else "unknown"
adapter._last_webhook_headers = headers
adapter._last_source_ip = source_ip
max_body_bytes = getattr(adapter, "_MAX_WEBHOOK_BODY_BYTES", 256 * 1024)
if len(body) > max_body_bytes:
logger.warning(f"Webhook body too large for {channel_type}: {len(body)} > {max_body_bytes}")
return JSONResponse(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
content={"code": 413, "message": "Request body too large"},
)
try:
if not await adapter.verify_webhook_signature(headers, body):
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"code": 403, "data": None, "message": "Invalid webhook signature"},
)
except NotImplementedError:
pass
import json
try:
body_data = json.loads(body)
except json.JSONDecodeError as e:
logger.error(f"Webhook JSON decode failed for {channel_type}: {e}")
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"code": 400, "data": None, "message": f"Invalid JSON payload: {e}"},
)
try:
if hasattr(adapter, "handle_webhook"):
handle_timeout = getattr(adapter, "_WEBHOOK_HANDLE_TIMEOUT_SECONDS", 30)
message = await asyncio.wait_for(adapter.handle_webhook(body_data), timeout=handle_timeout)
if message is None or isinstance(message, int):
if isinstance(message, int):
return JSONResponse(
status_code=message,
content={"code": message, "message": "Webhook rejected"},
)
return JSONResponse(content={"code": 0, "data": None, "message": "ok"})
asyncio.create_task(adapter._handle_message(message))
return JSONResponse(content={"code": 0, "data": None, "message": "ok"})
else:
message = adapter.normalize_inbound(body_data)
except TimeoutError:
logger.error(f"Webhook processing timed out for {channel_type}")
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content={"code": 503, "message": "Webhook processing timed out"},
)
except Exception as e:
logger.error(f"Failed to parse webhook for {channel_type}: {e}")
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"code": 400, "data": None, "message": f"Failed to parse webhook payload: {e}"},
)
asyncio.create_task(adapter._handle_message(message))
return JSONResponse(content={"code": 0, "data": None, "message": "ok"})