1. webhook路由新增记录请求源IP与请求头 2. 修复多个适配器单元测试:更新配置断言、补全async测试装饰器、重构测试调用逻辑 3. 新增Nostr适配器测试通用fixture 4. 更新NextcloudTalk签名相关测试与函数命名
143 lines
5.6 KiB
Python
143 lines
5.6 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
from fastapi import APIRouter, Request, status
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from yuxi.channels.manager import 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 = 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.post("/{channel_type}")
|
|
async def webhook_dispatch(channel_type: str, request: Request):
|
|
adapter = 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 asyncio.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"})
|