import asyncio import hashlib import hmac import json import logging from datetime import datetime, UTC from fastapi import APIRouter, Request from fastapi.responses import JSONResponse from yuxi.channel.extensions.xiaohongshu.config import XiaohongshuConfig from yuxi.channel.extensions.xiaohongshu.dedupe import MessageDeduplicator from yuxi.channel.extensions.xiaohongshu.security import XiaohongshuSecurity from yuxi.channel.extensions.xiaohongshu.pairing import XiaohongshuPairing from yuxi.channel.extensions.xiaohongshu.outbound import XiaohongshuOutbound, set_send_context, get_window_tracker from yuxi.channel.runtime.manager import gateway from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage from yuxi.channel.routing.models import PeerKind logger = logging.getLogger(__name__) router = APIRouter(prefix="/webhook/xiaohongshu", tags=["xiaohongshu"]) _config = XiaohongshuConfig() _deduplicator = MessageDeduplicator() _pairing = XiaohongshuPairing() _window_tracker = get_window_tracker() _outbound: XiaohongshuOutbound | None = None def set_outbound(outbound: XiaohongshuOutbound | None) -> None: global _outbound _outbound = outbound def verify_signature(app_secret: str, request_path: str, all_params: dict[str, str], sign: str) -> bool: all_params = {k: v for k, v in all_params.items() if k != "sign"} sorted_keys = sorted(all_params.keys()) sorted_parts = [f"{k}={all_params[k]}" for k in sorted_keys] base_string = request_path + "&".join(sorted_parts) + app_secret computed = hashlib.md5(base_string.encode()).hexdigest() return hmac.compare_digest(computed, sign) @router.post("/callback") async def xiaohongshu_webhook_receive(request: Request): body_bytes = await request.body() body_str = body_bytes.decode("utf-8") timestamp = request.headers.get("timestamp", "") app_key = request.headers.get("app-key", "") sign = request.headers.get("sign", "") all_params = {"timestamp": timestamp, "app-key": app_key} for k, v in request.query_params.items(): all_params[k] = v account = _config.resolve_account() if not verify_signature(account.app_secret, request.url.path, all_params, sign): logger.warning("Xiaohongshu webhook: invalid signature") return JSONResponse({"code": 401, "msg": "signature_invalid"}, status_code=401) try: event = json.loads(body_str) except json.JSONDecodeError: logger.warning("Xiaohongshu webhook: invalid JSON body") return JSONResponse({"code": 400, "msg": "invalid_json"}, status_code=400) if event.get("test") is True: return JSONResponse({"code": 0, "msg": "ok"}, status_code=200) msg_tag = event.get("msgTag", "") if msg_tag == "im_enter_chat": await _handle_enter_chat(event, account) return JSONResponse({"code": 0, "msg": "ok"}, status_code=200) if msg_tag != "im_message_receive": return JSONResponse({"code": 0, "msg": "ok"}, status_code=200) msg_id = event.get("msgId", "") or event.get("log_id", "") if msg_id and _deduplicator.is_duplicate(msg_id): return JSONResponse({"code": 0, "msg": "ok"}, status_code=200) content_data = event.get("content", {}) msg_type = content_data.get("msg_type", "") if msg_type == "text": content = content_data.get("text", "") elif msg_type == "image": content = "[图片]" elif msg_type == "video": content = "[视频]" elif msg_type == "voice": content = "[语音]" else: content = f"[不支持的消息类型: {msg_type}]" if not content: return JSONResponse({"code": 0, "msg": "ok"}, status_code=200) from_user_id = event.get("from_user_id", "") user_info = event.get("user_info", {}) or {} nick_name = user_info.get("nick_name", from_user_id) avatar = user_info.get("avatar", "") _window_tracker.record(from_user_id) security = XiaohongshuSecurity(account) conversation_id = content_data.get("conversation_id", "") server_message_id = content_data.get("server_message_id", "") if conversation_id or server_message_id: set_send_context(from_user_id, conversation_id, server_message_id) policy = security.resolve_dm_policy() if policy == "disabled": return JSONResponse({"code": 0, "msg": "ok"}, status_code=200) if policy == "pairing": if not security.check_allowlist(from_user_id): handled = await _handle_pairing(from_user_id, content) if handled: return JSONResponse({"code": 0, "msg": "ok"}, status_code=200) return JSONResponse({"code": 0, "msg": "ok"}, status_code=200) if policy == "allowlist": if not security.check_allowlist(from_user_id): return JSONResponse({"code": 0, "msg": "ok"}, status_code=200) unified = UnifiedMessage( msg_id=msg_id, channel_type="xiaohongshu", account_id="default", content=content, message_type=MessageType.TEXT if msg_type == "text" else MessageType.IMAGE, sender=PeerInfo( id=from_user_id, kind=PeerKind.DIRECT, display_name=nick_name, ), timestamp=datetime.fromtimestamp( content_data.get("create_time", 0) / 1000, tz=UTC ), raw_payload=event, body_for_agent=content, metadata={ "conversation_id": conversation_id, "message_type": msg_type, "open_id": from_user_id, "server_message_id": server_message_id, "avatar": avatar, "nick_name": nick_name, }, ) asyncio.create_task( _dispatch_to_agent(unified), name=f"xiaohongshu-dispatch-{from_user_id}", ) return JSONResponse({"code": 0, "msg": "ok"}, status_code=200) async def _dispatch_to_agent(msg: UnifiedMessage) -> None: processor = gateway._processor if processor is None: logger.warning("Message processor not available, cannot dispatch Xiaohongshu message") return try: await asyncio.wait_for(processor.process(msg), timeout=120.0) except TimeoutError: logger.error("Agent response timeout for xiaohongshu user %s", msg.sender.id) except Exception: logger.exception("Failed to process Xiaohongshu message for user %s", msg.sender.id) async def _handle_enter_chat(event: dict, account) -> None: from_user_id = event.get("from_user_id", "") logger.info("Xiaohongshu user %s entered chat session", from_user_id) security = XiaohongshuSecurity(account) policy = security.resolve_dm_policy() if policy == "disabled": return if not _window_tracker.can_enter_dm_reply(from_user_id): logger.info("Xiaohongshu enter-chat rate limited for user %s", from_user_id) return welcome_text = account.welcome_text or "你好!有什么可以帮助你的?" await _send_xiaohongshu_text(from_user_id, welcome_text) _window_tracker.record_enter_dm_reply(from_user_id) async def _handle_pairing(from_user_id: str, content: str) -> bool: if content.strip().startswith("配对 "): code_input = content.strip()[3:].strip() if _pairing.verify(from_user_id, code_input): security = XiaohongshuSecurity(_config.resolve_account()) security.add_to_allowlist(from_user_id) await _send_xiaohongshu_text(from_user_id, "配对成功!现在可以开始对话了。") return True else: await _send_xiaohongshu_text(from_user_id, "配对码无效或已过期,请重新发送消息获取配对码。") return True else: code = _pairing.generate_code(from_user_id) if code: await _send_xiaohongshu_text( from_user_id, f"首次对话需要验证身份,请输入以下配对码:\n\n配对 {code}\n\n(配对码有效期 10 分钟)", ) else: await _send_xiaohongshu_text(from_user_id, "配对请求过于频繁,请稍后再试。") return True return False async def _send_xiaohongshu_text(to_user_id: str, content: str) -> None: ob = _outbound if ob is None: from yuxi.channel.extensions.xiaohongshu.gateway import _get_gateway gw = _get_gateway() if gw is None: logger.warning("No Xiaohongshu gateway available for sending text") return ob = XiaohongshuOutbound(gw) try: await ob.send_text(to_user_id, content) except Exception: logger.exception("Failed to send Xiaohongshu text to %s", to_user_id)