1. 将全局channel_manager改为通过get_channel_manager()获取单例 2. 新增微信webhook处理接口 3. 在dashboard反馈接口中新增channel字段返回 4. 新增渠道注册、注销API接口 5. 优化部分代码格式和异常处理逻辑 6. 注册Nostr profile api路由
229 lines
9.0 KiB
Python
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"})
|