from __future__ import annotations import asyncio import json import logging from typing import TYPE_CHECKING from fastapi import APIRouter, Request from fastapi.responses import Response from yuxi.channel.extensions.jd.crypto import decrypt_jd_message from yuxi.channel.extensions.jd.format import remove_markdown from yuxi.channel.extensions.jd.outbound import JDOutbound from yuxi.channel.extensions.jd.security import JDSecurity from yuxi.channel.extensions.jd.signature import verify_webhook_signature from yuxi.channel.extensions.jd.types import InboundJDMessage if TYPE_CHECKING: from yuxi.channel.extensions.jd import JDPlugin logger = logging.getLogger(__name__) router = APIRouter(prefix="/webhook/jd", tags=["jd"]) _plugin: JDPlugin | None = None def set_plugin(plugin: JDPlugin) -> None: global _plugin _plugin = plugin def get_plugin() -> JDPlugin | None: return _plugin @router.post("/callback") async def jd_message_callback(request: Request): plugin = _plugin if plugin is None: logger.warning("JD plugin not initialized, rejecting webhook") return Response(content='{"code":-1}', media_type="application/json", status_code=503) gw = plugin._gateway account = gw.account if gw else None raw_body = await request.body() if account and account.app_secret: signature = request.headers.get("X-JD-Sign", "") timestamp = request.headers.get("X-JD-Timestamp", "") sign_algorithm = account.webhook_sign_algorithm or "hmac-sha256" if not verify_webhook_signature(raw_body, signature, timestamp, account.app_secret, algorithm=sign_algorithm): logger.warning("JD webhook signature verification failed (algorithm=%s)", sign_algorithm) return Response(content='{"code":-1}', media_type="application/json", status_code=403) try: data = json.loads(raw_body) except json.JSONDecodeError as e: logger.error("JD webhook JSON parse error: %s", e) return Response(content='{"code":-1}', media_type="application/json", status_code=400) if account and account.app_secret and data.get("encrypt"): decrypted = decrypt_jd_message(data.get("encrypt_jd_param_json", ""), account.app_secret) if decrypted: data = decrypted else: logger.warning("JD webhook AES decrypt failed") return Response(content='{"code":-1}', media_type="application/json", status_code=400) messages = data.get("messages", []) if not messages: event_type = data.get("type") if event_type in ("system", "event"): logger.info("JD webhook event: type=%s", event_type) if event_type == "system": await _handle_system_event(data) return Response(content='{"code":0}', media_type="application/json", status_code=200) deduplicator = plugin._deduplicator pairing = plugin._pairing monitor = plugin._monitor for msg_data in messages: msg_id = msg_data.get("msg_id", "") if not msg_id or deduplicator.is_duplicate(msg_id): continue from_type = msg_data.get("from_type", "1") if from_type == "2": continue content = msg_data.get("content", "") from_id = msg_data.get("from_id", "") chat_id = msg_data.get("chat_id", "") if not content or not account: continue if gw and chat_id: gw.cache_chat_id(from_id, chat_id) security = plugin._security if security is None: security = JDSecurity(account) policy = security.resolve_dm_policy() if policy == "disabled": continue if policy == "pairing": if not security.check_allowlist(from_id): if content.strip().startswith("配对 "): code_input = content.strip()[3:].strip() if pairing.verify(from_id, code_input): security.add_to_allowlist(from_id) await _send_jd_text(plugin, from_id, "配对成功!现在可以开始对话了。", chat_id=chat_id) else: await _send_jd_text( plugin, from_id, "配对码无效或已过期,请重新发送消息获取配对码。", chat_id=chat_id ) else: code = pairing.generate_code(from_id) if code: await _send_jd_text( plugin, from_id, f"首次对话需要验证身份,请输入以下配对码:\n\n配对 {code}\n\n(配对码有效期 10 分钟)", chat_id=chat_id, ) else: await _send_jd_text(plugin, from_id, "配对请求过于频繁,请稍后再试。", chat_id=chat_id) continue if policy == "allowlist": if not security.check_allowlist(from_id): continue content = remove_markdown(content) msg = InboundJDMessage( msg_id=msg_id, msg_type=msg_data.get("msg_type", 1), content=content, from_id=from_id, to_id=msg_data.get("to_id", ""), chat_id=chat_id, msg_time=msg_data.get("msg_time", ""), from_type=msg_data.get("from_type", "1"), to_type=msg_data.get("to_type", "2"), extend=msg_data.get("extend", "{}"), raw=msg_data, ) unified = monitor.convert_to_unified(msg) from yuxi.channel.runtime.manager import gateway processor = getattr(gateway, "_processor", None) if gateway else None if processor is None: logger.warning("Message processor not available, cannot dispatch JD message") continue asyncio.create_task( _dispatch_to_agent(processor, unified), name=f"jd-dispatch-{from_id}", ) return Response(content='{"code":0}', media_type="application/json", status_code=200) async def _handle_system_event(data: dict): event_subtype = data.get("subtype", "") chat_id = data.get("chat_id", "") if event_subtype == "session_close": logger.info("JD session closed: chat_id=%s", chat_id) elif event_subtype == "user_enter": logger.info("JD user entered: chat_id=%s", chat_id) else: logger.info("JD system event: subtype=%s chat_id=%s", event_subtype, chat_id) async def _dispatch_to_agent(processor, msg) -> None: try: await asyncio.wait_for(processor.process(msg), timeout=120.0) except TimeoutError: logger.error("Agent response timeout for JD user %s", msg.sender.id) except Exception: logger.exception("Failed to process JD message for user %s", msg.sender.id) async def _send_jd_text(plugin, to_user: str, content: str, *, chat_id: str | None = None) -> None: gw = plugin._gateway if gw is None: logger.warning("No JD gateway available for sending text") return outbound = JDOutbound(gw) try: await outbound.send_text(to_user, content, chat_id=chat_id) except Exception: logger.exception("Failed to send JD text to %s", to_user) finally: await outbound.close()