import asyncio import hashlib import logging from datetime import datetime, timezone from fastapi import APIRouter, Query, Request from fastapi.responses import JSONResponse, PlainTextResponse from yuxi.channel.extensions.weibo.config import WeiboConfig from yuxi.channel.extensions.weibo.message import extract_content, parse_weibo_message from yuxi.channel.extensions.weibo.outbound import WeiboOutbound, remove_markdown from yuxi.channel.extensions.weibo.pairing import WeiboPairing from yuxi.channel.extensions.weibo.security import WeiboSecurity from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage from yuxi.channel.plugins.registry import ChannelPluginRegistry from yuxi.channel.routing.models import PeerKind from yuxi.channel.runtime.manager import gateway logger = logging.getLogger(__name__) router = APIRouter(prefix="/webhook/weibo", tags=["weibo"]) _config = WeiboConfig() _pairing = WeiboPairing() def _get_plugin(): return ChannelPluginRegistry.get("weibo") @router.get("/callback") async def weibo_url_verify( signature: str = Query(...), timestamp: str = Query(...), nonce: str = Query(...), echostr: str = Query(...), ): account = _config.resolve_account() if not account.is_configured(): return PlainTextResponse("not configured", status_code=503) params = sorted([account.callback_token, str(timestamp), str(nonce)]) expected = hashlib.sha1("".join(params).encode("utf-8")).hexdigest() if expected == signature: return PlainTextResponse(echostr) logger.warning("Weibo URL verification failed: signature mismatch") return PlainTextResponse("signature verification failed", status_code=403) @router.post("/callback") async def weibo_message_callback(request: Request): account = _config.resolve_account() if not account.is_configured(): return JSONResponse({"result": False, "msg": "not configured"}, status_code=503) body = await request.json() try: raw_msg = parse_weibo_message(body, account.app_key) except Exception: logger.exception("Weibo JSON parsing failed") return JSONResponse({"result": False, "msg": "parse error"}, status_code=400) if str(raw_msg.sender_id) == str(raw_msg.receiver_id): return JSONResponse({"result": True, "msg": "ok"}) if raw_msg.msg_type == "event": return await _handle_event(raw_msg, account) plugin = _get_plugin() if plugin is None: logger.warning("Weibo plugin not registered") return JSONResponse({"result": False, "msg": "plugin not ready"}, status_code=503) if raw_msg.msg_id and plugin.is_duplicate(raw_msg.msg_id): return JSONResponse({"result": True, "msg": "ok"}) gw = plugin.gateway if gw: gw.record_inbound(raw_msg.sender_id) content = extract_content(raw_msg) if not content: return JSONResponse({"result": True, "msg": "ok"}) if account.remove_markdown: content = remove_markdown(content) security = WeiboSecurity(account) policy = security.resolve_dm_policy() if policy == "disabled": return JSONResponse({"result": True, "msg": "ok"}) if policy == "pairing": if not security.check_allowlist(raw_msg.sender_id): if content.strip().startswith("配对 "): code_input = content.strip()[3:].strip() if _pairing.verify(raw_msg.sender_id, code_input): security.add_to_allowlist(raw_msg.sender_id) await _send_weibo_text(raw_msg.sender_id, "配对成功!现在可以开始对话了。") else: await _send_weibo_text(raw_msg.sender_id, "配对码无效或已过期,请重新发送消息获取配对码。") else: code = _pairing.generate_code(raw_msg.sender_id) if code: await _send_weibo_text( raw_msg.sender_id, f"首次对话需要验证身份,请输入以下配对码:\n\n配对 {code}\n\n(配对码有效期 10 分钟)", ) else: await _send_weibo_text(raw_msg.sender_id, "配对请求过于频繁,请稍后再试。") return JSONResponse({"result": True, "msg": "ok"}) if policy == "allowlist": if not security.check_allowlist(raw_msg.sender_id): return JSONResponse({"result": True, "msg": "ok"}) processor = gateway._processor if processor is None: logger.warning("Message processor not available") return JSONResponse({"result": False, "msg": "processor not ready"}, status_code=503) unified = UnifiedMessage( msg_id=raw_msg.msg_id, channel_type="weibo", account_id="default", content=content, message_type=MessageType.TEXT, sender=PeerInfo( id=raw_msg.sender_id, kind=PeerKind.DIRECT, display_name=raw_msg.sender_id, ), timestamp=datetime.fromtimestamp(raw_msg.create_time, tz=timezone.utc) if raw_msg.create_time else None, raw_payload=raw_msg.raw_json, body_for_agent=content, metadata={ "SenderId": raw_msg.sender_id, "ReceiverId": raw_msg.receiver_id, "MsgType": raw_msg.msg_type, "SubType": raw_msg.sub_type, "EventKey": raw_msg.event_key, }, ) asyncio.create_task( _dispatch_to_agent(processor, unified), name=f"weibo-dispatch-{raw_msg.sender_id}", ) return JSONResponse({"result": True, "msg": "ok"}) async def _handle_event(raw_msg, account) -> JSONResponse: if raw_msg.sub_type == "follow": welcome = account.subscribe_msg or "" if welcome: await _send_weibo_text(raw_msg.sender_id, welcome) logger.info("Sent welcome message to %s", raw_msg.sender_id) guide = getattr(account, "subscribe_guide", "") if guide: await _send_weibo_text(raw_msg.sender_id, guide) logger.info("Sent subscribe guide to %s", raw_msg.sender_id) elif raw_msg.sub_type == "subscribe": logger.info("User %s subscribed (sent DY)", raw_msg.sender_id) await _send_weibo_text( raw_msg.sender_id, "订阅成功!现在你可以接收服务消息了。如需退订,请发送 TD。", ) elif raw_msg.sub_type == "unsubscribe": logger.info("User %s unsubscribed (sent TD)", raw_msg.sender_id) elif raw_msg.sub_type == "unfollow": logger.info("User %s unfollowed, cleaning up state", raw_msg.sender_id) plugin = _get_plugin() if plugin: plugin._dedup.reset() security = WeiboSecurity(account) security.remove_from_allowlist(raw_msg.sender_id) elif raw_msg.sub_type == "click": event_key = raw_msg.event_key or "" logger.info("Menu click: key=%s, user=%s", event_key, raw_msg.sender_id) processor = gateway._processor if processor and event_key: unified = UnifiedMessage( msg_id=raw_msg.msg_id, channel_type="weibo", account_id="default", content=f"[菜单点击: {event_key}]", message_type=MessageType.TEXT, sender=PeerInfo( id=raw_msg.sender_id, kind=PeerKind.DIRECT, display_name=raw_msg.sender_id, ), timestamp=datetime.fromtimestamp(raw_msg.create_time, tz=timezone.utc) if raw_msg.create_time else None, raw_payload=raw_msg.raw_json, body_for_agent=event_key, metadata={ "SenderId": raw_msg.sender_id, "ReceiverId": raw_msg.receiver_id, "MsgType": "event", "SubType": "click", "EventKey": event_key, }, ) asyncio.create_task( _dispatch_to_agent(processor, unified), name=f"weibo-menu-{raw_msg.sender_id}", ) elif raw_msg.sub_type in ("scan", "scan_follow"): event_key = raw_msg.event_key or "" ticket = raw_msg.raw_json.get("ticket", "") logger.info( "Scan event: sub_type=%s, key=%s, ticket=%s, user=%s", raw_msg.sub_type, event_key, ticket, raw_msg.sender_id, ) processor = gateway._processor if processor: scan_label = "扫码事件" if raw_msg.sub_type == "scan" else "扫码关注" unified = UnifiedMessage( msg_id=raw_msg.msg_id, channel_type="weibo", account_id="default", content=f"[{scan_label}] key={event_key}" + (f" ticket={ticket}" if ticket else ""), message_type=MessageType.TEXT, sender=PeerInfo( id=raw_msg.sender_id, kind=PeerKind.DIRECT, display_name=raw_msg.sender_id, ), timestamp=datetime.fromtimestamp(raw_msg.create_time, tz=timezone.utc) if raw_msg.create_time else None, raw_payload=raw_msg.raw_json, body_for_agent=event_key, metadata={ "SenderId": raw_msg.sender_id, "ReceiverId": raw_msg.receiver_id, "MsgType": "event", "SubType": raw_msg.sub_type, "EventKey": event_key, "Ticket": ticket, }, ) asyncio.create_task( _dispatch_to_agent(processor, unified), name=f"weibo-scan-{raw_msg.sender_id}", ) elif raw_msg.sub_type == "masssend": status = raw_msg.raw_json.get("status", "") msg_id = raw_msg.raw_json.get("msg_id", "") logger.info("Broadcast result: status=%s, msg_id=%s", status, msg_id) return JSONResponse({"result": True, "msg": "ok"}) async def _dispatch_to_agent(processor, msg: UnifiedMessage) -> None: try: await asyncio.wait_for(processor.process(msg), timeout=120.0) except asyncio.TimeoutError: logger.error("Agent response timeout for weibo user %s", msg.sender.id) except Exception: logger.exception("Failed to process Weibo message for user %s", msg.sender.id) async def _send_weibo_text(to_user: str, content: str) -> None: plugin = _get_plugin() if plugin is None or plugin.gateway is None: logger.warning("No Weibo gateway available") return outbound = WeiboOutbound(plugin.gateway) try: await outbound.send_text(to_user, content) except Exception: logger.exception("Failed to send Weibo text to %s", to_user)