import asyncio import hashlib import hmac import json import logging from datetime import UTC, datetime from fastapi import APIRouter, Request from fastapi.responses import JSONResponse, PlainTextResponse from yuxi.channel.extensions.douyin.outbound import DouyinOutbound, clean_for_douyin from yuxi.channel.extensions.douyin.security import DouyinSecurity from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage from yuxi.channel.routing.models import PeerKind from yuxi.channel.runtime.manager import gateway logger = logging.getLogger(__name__) router = APIRouter(prefix="/webhook/douyin", tags=["douyin"]) _plugin = None def set_plugin(plugin) -> None: global _plugin _plugin = plugin def clear_plugin() -> None: global _plugin _plugin = None def verify_signature(client_secret: str, body_bytes: bytes, signature: str) -> bool: data = client_secret.encode() + body_bytes computed = hashlib.sha1(data).hexdigest() return hmac.compare_digest(computed, signature) @router.post("/callback") async def douyin_webhook_receive(request: Request): body_bytes = await request.body() body_str = body_bytes.decode("utf-8") signature = request.headers.get("X-Douyin-Signature", "") plugin = _plugin if not plugin: logger.error("Douyin plugin not initialized, rejecting webhook") return JSONResponse({"status": "plugin_not_ready"}, status_code=503) config_adapter = plugin._config_adapter account = config_adapter.resolve_account() if not verify_signature(account.client_secret, body_bytes, signature): logger.warning("Douyin webhook: invalid X-Douyin-Signature") return JSONResponse({"status": "signature_invalid"}, status_code=403) try: event = json.loads(body_str) except json.JSONDecodeError: logger.warning("Douyin webhook: invalid JSON body") return PlainTextResponse("", status_code=400) if event.get("event") == "verify_webhook": challenge = event.get("content", {}).get("challenge") if challenge: return JSONResponse({"echostr": challenge}) return PlainTextResponse("", status_code=400) event_type = event.get("event", "") if event_type == "im_send_message_failed": logger.error("Douyin message send failed: %s", json.dumps(event, ensure_ascii=False)) return PlainTextResponse("success") if event_type == "im_send_msg": logger.info( "Douyin message sent callback: msg_id=%s, to_user=%s", event.get("content", {}).get("server_message_id", ""), event.get("to_user_id", ""), ) return PlainTextResponse("success") if event_type == "im_recall_msg": logger.info( "Douyin message recalled: msg_id=%s, from_user=%s", event.get("content", {}).get("server_message_id", ""), event.get("from_user_id", ""), ) return PlainTextResponse("success") if event_type == "im_msg_read": logger.info( "Douyin message read: msg_id=%s, from_user=%s", event.get("content", {}).get("server_message_id", ""), event.get("from_user_id", ""), ) return PlainTextResponse("success") if event_type not in ("im_receive_msg", "im_enter_direct_msg"): return PlainTextResponse("success") msg_id = request.headers.get("Msg-Id", event.get("log_id", "")) if msg_id and plugin._deduplicator.is_duplicate(msg_id): return PlainTextResponse("success") content_data = event.get("content", {}) msg_type = content_data.get("message_type", "") if msg_type == "text": content = content_data.get("text", "") elif msg_type in ("image", "user_local_image"): content = "[图片]" elif msg_type in ("video", "user_local_video"): content = "[视频]" elif msg_type == "emoji": emoji_info = content_data.get("emoji", {}) emoji_text = emoji_info.get("text", "") or emoji_info.get("resource_url", "") or "[表情]" content = f"[表情: {emoji_text}]" elif msg_type == "retain_consult_card": content = "[留资卡片]" else: content = f"[不支持的消息类型: {msg_type}]" if not content: return PlainTextResponse("success") from_user_id = event.get("from_user_id", "") user_infos = event.get("user_infos", []) nick_name = from_user_id avatar = "" if user_infos: user_info = user_infos[0] nick_name = user_info.get("nick_name", from_user_id) avatar = user_info.get("avatar", "") outbound = plugin._outbound if outbound: outbound.window_tracker.record(from_user_id) if event_type == "im_enter_direct_msg": await _handle_enter_direct_msg(event, account, plugin) return PlainTextResponse("success") conversation_short_id = content_data.get("conversation_short_id", "") server_message_id = content_data.get("server_message_id", "") if (conversation_short_id or server_message_id) and outbound: outbound.set_send_context(from_user_id, conversation_short_id, server_message_id) security = plugin._security if security is None: security = DouyinSecurity(account) if plugin._remove_markdown: agent_content = clean_for_douyin(content) else: agent_content = content policy = security.resolve_dm_policy() if policy == "disabled": return PlainTextResponse("success") if policy == "pairing": if not security.check_allowlist(from_user_id): handled = await _handle_pairing(from_user_id, agent_content, plugin) if handled: return PlainTextResponse("success") return PlainTextResponse("success") if policy == "allowlist": if not security.check_allowlist(from_user_id): return PlainTextResponse("success") unified = UnifiedMessage( msg_id=msg_id, channel_type="douyin", 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=agent_content, metadata={ "conversation_short_id": conversation_short_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"douyin-dispatch-{from_user_id}", ) return PlainTextResponse("success") async def _dispatch_to_agent(msg: UnifiedMessage) -> None: processor = gateway._processor if processor is None: logger.warning("Message processor not available, cannot dispatch Douyin message") return try: await asyncio.wait_for(processor.process(msg), timeout=120.0) except TimeoutError: logger.error("Agent response timeout for douyin user %s", msg.sender.id) except Exception: logger.exception("Failed to process Douyin message for user %s", msg.sender.id) async def _handle_enter_direct_msg(event: dict, account, plugin) -> None: from_user_id = event.get("from_user_id", "") logger.info("Douyin user %s entered direct message session", from_user_id) security = plugin._security if security is None: security = DouyinSecurity(account) policy = security.resolve_dm_policy() if policy == "disabled": return outbound = plugin._outbound if outbound and not outbound.window_tracker.can_enter_dm_reply(from_user_id): logger.info("Douyin enter-dm rate limited for user %s", from_user_id) return welcome_text = account.welcome_text or "你好!有什么可以帮助你的?" await _send_douyin_text(from_user_id, welcome_text, plugin) if outbound: outbound.window_tracker.record_enter_dm_reply(from_user_id) async def _handle_pairing(from_user_id: str, content: str, plugin) -> bool: if content.strip().startswith("配对 "): code_input = content.strip()[3:].strip() if plugin._pairing.verify(from_user_id, code_input): security = plugin._security if security: security.add_to_allowlist(from_user_id) await _send_douyin_text(from_user_id, "配对成功!现在可以开始对话了。", plugin) return True else: await _send_douyin_text(from_user_id, "配对码无效或已过期,请重新发送消息获取配对码。", plugin) return True else: code = plugin._pairing.generate_code(from_user_id) if code: await _send_douyin_text( from_user_id, f"首次对话需要验证身份,请输入以下配对码:\n\n配对 {code}\n\n(配对码有效期 10 分钟)", plugin, ) else: await _send_douyin_text(from_user_id, "配对请求过于频繁,请稍后再试。", plugin) return True return False async def _send_douyin_text(to_user_id: str, content: str, plugin) -> None: ob = plugin._outbound if ob is None: gw = plugin._gateway if gw is None: logger.warning("No Douyin gateway available for sending text") return ob = DouyinOutbound(gw) try: await ob.send_text(to_user_id, content) except Exception: logger.exception("Failed to send Douyin text to %s", to_user_id)