import asyncio import logging from yuxi.channel.extensions.wechat_kf.types import KFOutboundResult logger = logging.getLogger(__name__) SEND_MSG_URL = "/cgi-bin/kf/send_msg" SEND_MSG_ON_EVENT_URL = "/cgi-bin/kf/send_msg_on_event" RECALL_URL = "/cgi-bin/kf/recall_msg" MAX_TEXT_BYTES = 2048 def split_utf8_safe(text: str, max_bytes: int = MAX_TEXT_BYTES) -> list[str]: chunks = [] current = "" for char in text: candidate = current + char if len(candidate.encode("utf-8")) > max_bytes: if current: chunks.append(current) current = char else: chunks.append(char) else: current = candidate if current: chunks.append(current) return chunks class WeChatKFOutbound: def __init__(self, gateway=None): self._gateway = gateway async def send_text(self, external_user_id: str, open_kfid: str, content: str) -> KFOutboundResult: session_manager = self._gateway._session_manager if self._gateway else None session = session_manager.get_session(open_kfid, external_user_id) if session_manager else None if session and session.msg_count_in_round >= 5: return KFOutboundResult(success=False, error="本轮消息数已达上限(5条)", errcode=45047) chunks = split_utf8_safe(content) last_result = None for i, chunk in enumerate(chunks): remaining = 5 - (session.msg_count_in_round if session else 0) - i if remaining <= 0: break last_result = await self._send_msg(external_user_id, open_kfid, "text", {"text": {"content": chunk}}) if session: session.msg_count_in_round += 1 if session and session_manager: session_manager.update_session(session) return last_result or KFOutboundResult(success=True) async def send_image(self, external_user_id: str, open_kfid: str, media_id: str) -> KFOutboundResult: return await self._send_msg( external_user_id, open_kfid, "image", {"image": {"media_id": media_id}}, ) async def send_voice(self, external_user_id: str, open_kfid: str, media_id: str) -> KFOutboundResult: return await self._send_msg( external_user_id, open_kfid, "voice", {"voice": {"media_id": media_id}}, ) async def send_video(self, external_user_id: str, open_kfid: str, media: dict) -> KFOutboundResult: return await self._send_msg(external_user_id, open_kfid, "video", {"video": media}) async def send_file(self, external_user_id: str, open_kfid: str, media_id: str) -> KFOutboundResult: return await self._send_msg( external_user_id, open_kfid, "file", {"file": {"media_id": media_id}}, ) async def send_link( self, external_user_id: str, open_kfid: str, title: str, desc: str, url: str, pic_url: str = "", ) -> KFOutboundResult: payload = { "link": { "title": title, "desc": desc, "url": url, "pic_url": pic_url, } } return await self._send_msg(external_user_id, open_kfid, "link", payload) async def send_miniprogram( self, external_user_id: str, open_kfid: str, title: str, appid: str, pagepath: str, thumb_media_id: str, ) -> KFOutboundResult: payload = { "miniprogram": { "title": title, "appid": appid, "pagepath": pagepath, "thumb_media_id": thumb_media_id, } } return await self._send_msg(external_user_id, open_kfid, "miniprogram", payload) async def send_menu( self, external_user_id: str, open_kfid: str, head_content: str, menu_list: list[dict], tail_content: str = "", ) -> KFOutboundResult: payload = { "msgmenu": { "head_content": head_content, "list": menu_list, "tail_content": tail_content, } } return await self._send_msg(external_user_id, open_kfid, "msgmenu", payload) async def send_location( self, external_user_id: str, open_kfid: str, latitude: float, longitude: float, name: str, address: str, ) -> KFOutboundResult: payload = { "location": { "latitude": str(latitude), "longitude": str(longitude), "name": name, "address": address, } } return await self._send_msg(external_user_id, open_kfid, "location", payload) async def send_business_card(self, external_user_id: str, open_kfid: str, userid: str) -> KFOutboundResult: payload = {"business_card": {"userid": userid}} return await self._send_msg(external_user_id, open_kfid, "business_card", payload) async def send_welcome(self, open_kfid: str, welcome_code: str, content: str) -> KFOutboundResult: payload = { "code": welcome_code, "msgtype": "text", "text": {"content": content}, } return await self._send_event_msg(open_kfid, payload) async def send_session_end_msg(self, open_kfid: str, msg_code: str, content: str) -> KFOutboundResult: payload = { "code": msg_code, "msgtype": "text", "text": {"content": content}, } return await self._send_event_msg(open_kfid, payload) async def recall(self, open_kfid: str, external_user_id: str, msgid: str) -> KFOutboundResult: token = await self._gateway.get_access_token() resp = await self._gateway._http.post( RECALL_URL, params={"access_token": token}, json={ "open_kfid": open_kfid, "external_user_id": external_user_id, "msgid": msgid, }, ) data = resp.json() if data.get("errcode") == 0: return KFOutboundResult(success=True) return KFOutboundResult(success=False, error=data.get("errmsg", ""), errcode=data.get("errcode", 0)) async def _send_msg(self, touser: str, open_kfid: str, msgtype: str, content: dict) -> KFOutboundResult: payload = {"touser": touser, "open_kfid": open_kfid, "msgtype": msgtype} payload.update(content) for attempt in range(3): try: token = await self._gateway.get_access_token() resp = await self._gateway._http.post( SEND_MSG_URL, params={"access_token": token}, json=payload, ) data = resp.json() errcode = data.get("errcode", 0) if errcode == 0: return KFOutboundResult(success=True, msg_id=data.get("msgid", "")) if errcode == 42001: await self._gateway._refresh_token() continue if errcode == 45047: return KFOutboundResult(success=False, error="下行条数超限", errcode=45047) return KFOutboundResult( success=False, error=data.get("errmsg", ""), errcode=errcode, ) except Exception as e: if attempt < 2: await asyncio.sleep(2**attempt) else: return KFOutboundResult(success=False, error=str(e)) return KFOutboundResult(success=False, error="重试耗尽") async def _send_event_msg(self, open_kfid: str, payload: dict) -> KFOutboundResult: token = await self._gateway.get_access_token() resp = await self._gateway._http.post( SEND_MSG_ON_EVENT_URL, params={"access_token": token}, json=payload, ) data = resp.json() if data.get("errcode") == 0: return KFOutboundResult(success=True, msg_id=data.get("msgid", "")) return KFOutboundResult(success=False, error=data.get("errmsg", ""), errcode=data.get("errcode", 0))