import asyncio import logging import time from datetime import UTC, datetime import httpx from yuxi.channel.extensions.wechat_kf.dedupe import MessageDeduplicator from yuxi.channel.extensions.wechat_kf.config import WeChatKFConfig from yuxi.channel.extensions.wechat_kf.outbound import WeChatKFOutbound from yuxi.channel.extensions.wechat_kf.session import KFSessionManager from yuxi.channel.extensions.wechat_kf.sync import sync_messages from yuxi.channel.extensions.wechat_kf.types import InboundKFMessage, KFAccount, SyncCursor 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__) WEIXIN_API_BASE = "https://qyapi.weixin.qq.com" SERVICE_STATE_GET_URL = "/cgi-bin/kf/service_state/get" SERVICE_STATE_TRANS_URL = "/cgi-bin/kf/service_state/trans" _gateway_instance: "WeChatKFGateway | None" = None def _set_gateway(gw: "WeChatKFGateway") -> None: global _gateway_instance _gateway_instance = gw def _get_gateway() -> "WeChatKFGateway | None": return _gateway_instance class WeChatKFGateway: def __init__(self): self._account: KFAccount | None = None self._http: httpx.AsyncClient | None = None self._access_token: str | None = None self._token_expires_at: float = 0 self._token_lock = asyncio.Lock() self._cancel_event = asyncio.Event() self._sync_tasks: list[asyncio.Task] = [] self._cursors: dict[str, SyncCursor] = {} self._session_manager: KFSessionManager | None = None self._deduplicator = MessageDeduplicator() self._running = False async def start(self, ctx) -> object: account = self._resolve_account(ctx) if not account.is_configured(): logger.warning("WeChat KF account not configured, skipping start") return {"running": False, "reason": "not-configured"} self._account = account self._cancel_event.clear() self._http = httpx.AsyncClient( base_url=WEIXIN_API_BASE, timeout=httpx.Timeout(30.0), ) self._session_manager = KFSessionManager(account) try: await self._refresh_token() except Exception: logger.exception("WeChat KF failed to get access_token") await self.stop(ctx) raise kf_ids = account.kf_account_ids if account.kf_account_ids else ["default"] for kf_id in kf_ids: task = asyncio.create_task(self._sync_loop(kf_id)) self._sync_tasks.append(task) self._running = True logger.info( "WeChat KF gateway started for account %s, kf_ids=%s", account.account_id, kf_ids, ) return {"status": "started", "kf_accounts": kf_ids} async def stop(self, ctx) -> None: self._running = False self._cancel_event.set() for task in self._sync_tasks: if not task.done(): task.cancel() try: await task except asyncio.CancelledError: pass self._sync_tasks.clear() if self._http: await self._http.aclose() self._http = None logger.info("WeChat KF gateway stopped") @property def access_token(self) -> str | None: if self._access_token and time.time() < self._token_expires_at: return self._access_token return None async def get_access_token(self) -> str: if self._access_token and time.time() < self._token_expires_at - 300: return self._access_token async with self._token_lock: if self._access_token and time.time() < self._token_expires_at - 300: return self._access_token await self._refresh_token() if not self._access_token: raise RuntimeError("无法获取微信客服 access_token") return self._access_token async def _refresh_token(self): if not self._http or not self._account: return resp = await self._http.get( "/cgi-bin/gettoken", params={ "corpid": self._account.corp_id, "corpsecret": self._account.corp_secret, }, ) data = resp.json() if data.get("errcode") != 0: raise RuntimeError(f"获取 access_token 失败: {data}") self._access_token = data["access_token"] self._token_expires_at = time.time() + data.get("expires_in", 7200) logger.info("WeChat KF access_token refreshed, expires_in=%s", data.get("expires_in")) async def _sync_loop(self, kf_id: str): while not self._cancel_event.is_set(): try: cursor = self._cursors.get(kf_id) cursor_str = cursor.next_cursor if cursor else "" result = await sync_messages( http=self._http, access_token=await self.get_access_token(), open_kfid=kf_id, cursor=cursor_str, limit=self._account.sync_limit if self._account else 1000, ) if result and result.get("errcode") == 0: self._cursors[kf_id] = SyncCursor( open_kfid=kf_id, next_cursor=result.get("next_cursor", ""), updated_at=time.time(), has_more=bool(result.get("has_more", 0)), ) msg_list = result.get("msg_list", []) for msg in msg_list: await self._handle_message(msg) except asyncio.CancelledError: break except Exception: logger.exception("WeChat KF sync_loop error for kf_id=%s", kf_id) try: interval = self._account.sync_interval if self._account else 1.0 await asyncio.wait_for(self._cancel_event.wait(), timeout=interval) break except TimeoutError: pass async def _handle_message(self, raw_msg: dict): msg_id = raw_msg.get("msgid", "") if not msg_id or self._deduplicator.is_duplicate(msg_id): return inbound = self._extract_inbound(raw_msg) event_type = inbound.event_type open_kfid = inbound.open_kfid external_user_id = inbound.external_user_id if self._session_manager: if event_type == "enter_session": welcome_code = inbound.event_data.get("welcome_code", "") session = self._session_manager.create_session( open_kfid, external_user_id, raw_msg.get("session_id", "") ) session.welcome_code = welcome_code if welcome_code and not session.welcome_sent: await self._trigger_welcome(open_kfid, external_user_id, welcome_code) session.welcome_sent = True elif event_type == "session_status_change": change_type = inbound.event_data.get("change_type", "") msg_code = inbound.event_data.get("msg_code", "") if change_type == "end" and msg_code: await self._trigger_session_end(open_kfid, external_user_id, msg_code) self._session_manager.close_session(open_kfid, external_user_id) elif event_type == "session_quit": self._session_manager.close_session(open_kfid, external_user_id) elif event_type == "msg_send_fail": fail_msgid = inbound.event_data.get("fail_msgid", "") fail_type = inbound.event_data.get("fail_type", "") logger.error( "WeChat KF msg send failed: msgid=%s, fail_type=%s, user=%s", fail_msgid, fail_type, external_user_id, ) inbound.fail_msgid = fail_msgid inbound.fail_type = fail_type await self._dispatch_system_event(inbound) elif event_type == "user_recall_msg": recall_msgid = inbound.event_data.get("recall_msgid", "") logger.info( "User recalled msg: recall_msgid=%s, user=%s", recall_msgid, external_user_id, ) inbound.recall_msgid = recall_msgid elif event_type == "servicer_status_change": servicer_userid = inbound.event_data.get("servicer_userid", "") status = inbound.event_data.get("status", "") logger.info( "Servicer status changed: userid=%s, status=%s", servicer_userid, status, ) elif event_type == "servicer_recall_msg": recall_msgid = inbound.event_data.get("recall_msgid", "") logger.info("Servicer recalled msg: recall_msgid=%s", recall_msgid) else: self._session_manager.mark_user_message(open_kfid, external_user_id) if event_type in ("msg_send_fail", "servicer_status_change", "servicer_recall_msg"): return try: await self._dispatch_to_agent(inbound) except Exception: logger.exception("WeChat KF dispatch failed for msg_id=%s", msg_id) def _extract_inbound(self, raw_msg: dict): msg_type = raw_msg.get("msgtype", "") extractors = { "image": lambda m: ( m.get("image", {}).get("media_id", ""), m.get("image", {}).get("pic_url", ""), ), "voice": lambda m: (m.get("voice", {}).get("media_id", ""), ""), "video": lambda m: (m.get("video", {}).get("media_id", ""), ""), "file": lambda m: (m.get("file", {}).get("media_id", ""), ""), "link": lambda m: ("", m.get("link", {}).get("pic_url", "")), } extractor = extractors.get(msg_type) media_id, media_url = extractor(raw_msg) if extractor else ("", "") return InboundKFMessage( msg_id=raw_msg.get("msgid", ""), msg_type=msg_type, open_kfid=raw_msg.get("open_kfid", ""), external_user_id=raw_msg.get("external_user_id", ""), send_time=raw_msg.get("send_time", 0), origin=raw_msg.get("origin", 0), content=raw_msg.get("text", {}).get("content", ""), media_id=media_id, media_url=media_url, event_type=raw_msg.get("event", {}).get("event_type", "") if msg_type == "event" else "", event_data=raw_msg.get("event", {}), raw_xml=raw_msg.get("_raw_xml", ""), ) async def _trigger_welcome(self, open_kfid: str, external_user_id: str, welcome_code: str): outbound = WeChatKFOutbound(self) try: result = await outbound.send_welcome( open_kfid, welcome_code, "您好,我是您的智能客服助手,请问有什么可以帮您?" ) if result.success: logger.info("Welcome sent to %s", external_user_id) else: logger.warning("Failed to send welcome: %s", result.error) except Exception: logger.exception("Error sending welcome to %s", external_user_id) async def _trigger_session_end(self, open_kfid: str, external_user_id: str, msg_code: str): outbound = WeChatKFOutbound(self) try: result = await outbound.send_session_end_msg( open_kfid, msg_code, "会话已结束,如有其他问题欢迎再次咨询,祝您生活愉快!" ) if result.success: logger.info("Session end msg sent to %s", external_user_id) else: logger.warning("Failed to send session end msg: %s", result.error) except Exception: logger.exception("Error sending session end msg to %s", external_user_id) async def _dispatch_to_agent(self, inbound): processor = gateway._processor if processor is None: logger.warning("Message processor not available for WeChat KF dispatch") return msg_type = MessageType.TEXT content = inbound.content if inbound.msg_type == "image": msg_type = MessageType.IMAGE content = content or f"[图片: {inbound.media_url or inbound.media_id}]" elif inbound.msg_type == "voice": msg_type = MessageType.VOICE elif inbound.msg_type == "video": content = content or f"[视频: {inbound.media_id}]" elif inbound.msg_type == "file": content = content or f"[文件: {inbound.media_id}]" elif inbound.msg_type == "location": loc = inbound.event_data if inbound.event_data else {} content = ( f"[位置] 纬度:{loc.get('latitude')} 经度:{loc.get('longitude')}" f" 名称:{loc.get('name', '')} 地址:{loc.get('address', '')}" ) elif inbound.msg_type == "link": link_data = inbound.event_data if inbound.event_data else {} content = f"[链接] {link_data.get('title', '')}: {link_data.get('url', '')}" elif inbound.msg_type == "business_card": card_data = inbound.event_data if inbound.event_data else {} content = f"[名片] userid={card_data.get('userid', '')}" elif inbound.msg_type == "miniprogram": mp_data = inbound.event_data if inbound.event_data else {} content = ( f"[小程序] {mp_data.get('title', '')}" f" appid={mp_data.get('appid', '')}" f" pagepath={mp_data.get('pagepath', '')}" ) elif inbound.msg_type == "msgmenu": menu_data = inbound.event_data if inbound.event_data else {} content = f"[菜单选择] id={menu_data.get('id', '')} content={menu_data.get('content', '')}" elif inbound.msg_type == "channels_shop_product": product = inbound.event_data if inbound.event_data else {} content = f"[视频号商品] {product.get('title', '')}" elif inbound.msg_type == "channels_shop_order": order = inbound.event_data if inbound.event_data else {} content = f"[视频号订单] {order.get('order_id', '')}" elif inbound.msg_type == "merged_msg": merged = inbound.event_data if inbound.event_data else {} content = f"[聊天记录] {merged.get('title', '')}" elif inbound.msg_type == "channels": channels_data = inbound.event_data if inbound.event_data else {} content = f"[视频号] {channels_data.get('nickname', '')}" elif inbound.msg_type == "note": note_data = inbound.event_data if inbound.event_data else {} content = f"[笔记] {note_data.get('title', '')}" elif inbound.msg_type == "meeting": content = "[会议消息]" elif inbound.msg_type == "calendar": content = "[日程消息]" unified = UnifiedMessage( msg_id=inbound.msg_id, channel_type="wechat-kf", account_id="default", content=content, message_type=msg_type, sender=PeerInfo( id=inbound.external_user_id, kind=PeerKind.DIRECT, display_name=inbound.external_user_id, ), timestamp=(datetime.fromtimestamp(inbound.send_time, tz=UTC) if inbound.send_time else None), raw_payload=inbound.raw_xml or inbound.event_data, body_for_agent=content, metadata={ "OpenKfId": inbound.open_kfid, "ExternalUserId": inbound.external_user_id, "MsgType": inbound.msg_type, "EventType": inbound.event_type, }, ) asyncio.create_task( self._process_message(processor, unified), name=f"kf-dispatch-{inbound.external_user_id}", ) async def _dispatch_system_event(self, inbound): logger.info("Dispatching system event: %s", inbound.event_type) async def _process_message(self, processor, msg) -> None: try: await asyncio.wait_for(processor.process(msg), timeout=120.0) except TimeoutError: logger.error("Agent response timeout for KF user %s", msg.sender.id) except Exception: logger.exception("Failed to process KF message for user %s", msg.sender.id) async def get_service_state(self, open_kfid: str, external_user_id: str) -> dict: token = await self.get_access_token() resp = await self._http.post( SERVICE_STATE_GET_URL, params={"access_token": token}, json={"open_kfid": open_kfid, "external_user_id": external_user_id}, ) return resp.json() async def transfer_to_servicer( self, open_kfid: str, external_user_id: str, servicer_userid: str | None = None, ) -> dict: payload = { "open_kfid": open_kfid, "external_user_id": external_user_id, "service_state": 3 if servicer_userid else 2, } if servicer_userid: payload["servicer_userid"] = servicer_userid token = await self.get_access_token() resp = await self._http.post( SERVICE_STATE_TRANS_URL, params={"access_token": token}, json=payload, ) result = resp.json() if result.get("errcode") == 0 and self._session_manager: self._session_manager.close_session(open_kfid, external_user_id) return result async def add_account(self, open_kfid: str): task = asyncio.create_task(self._sync_loop(open_kfid)) self._sync_tasks.append(task) logger.info("WeChat KF sync loop added for %s", open_kfid) @staticmethod def _resolve_account(ctx): if isinstance(ctx, dict): account_id = ctx.get("account_id", "default") else: account_id = "default" config = WeChatKFConfig() return config.resolve_account(account_id)