import asyncio import logging import time import httpx from yuxi.channel.extensions.wechat_mp.outbound import remove_markdown, split_utf8, split_utf8_safe # noqa: F401 logger = logging.getLogger(__name__) CUSTOM_SEND_URL = "https://api.weixin.qq.com/cgi-bin/message/custom/send" TYPING_URL = "https://api.weixin.qq.com/cgi-bin/message/custom/business/typing" MAX_UTF8_LEN = 2048 class MiniProgramOutbound: def __init__(self, gateway=None): self._gateway = gateway self._http: httpx.AsyncClient | None = None async def send_text( self, target_id: str, content: str, *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> None: if not content: return texts = split_utf8(content) for i, text in enumerate(texts): payload = { "touser": target_id, "msgtype": "text", "text": {"content": text}, } await self._send_custom(payload) if i < len(texts) - 1: await asyncio.sleep(0.5) async def send_media( self, target_id: str, media_url: str, media_type: str, reply_to_id: str | None = None, thread_id: str | None = None, ) -> None: payload = { "touser": target_id, "msgtype": media_type, media_type: {"media_id": media_url}, } await self._send_custom(payload) async def send_image(self, to_user: str, media_id: str) -> bool: return await self._send_media(to_user, "image", media_id) async def send_voice(self, to_user: str, media_id: str) -> bool: return await self._send_media(to_user, "voice", media_id) async def send_miniprogram_page( self, target_id: str, title: str, pagepath: str, thumb_media_id: str, *, reply_to_id: str | None = None, thread_id: str | None = None, ) -> bool: payload = { "touser": target_id, "msgtype": "miniprogrampage", "miniprogrampage": { "title": title, "pagepath": pagepath, "thumb_media_id": thumb_media_id, }, } return await self._send_custom(payload) async def set_typing(self, target_id: str, command: str = "Typing") -> bool: payload = {"touser": target_id, "command": command} token = self._gateway.access_token if self._gateway else None if not token: logger.error("No valid access_token for typing") return False url = f"{TYPING_URL}?access_token={token}" if self._http is None: self._http = httpx.AsyncClient(timeout=15.0) try: resp = await self._http.post(url, json=payload) data = resp.json() return data.get("errcode") == 0 except Exception: logger.exception("MiniProgram set_typing failed") return False async def cancel_typing(self, target_id: str) -> bool: return await self.set_typing(target_id, "CancelTyping") async def send_link( self, target_id: str, title: str, description: str, url: str, thumb_url: str = "", *, reply_to_id: str | None = None, thread_id: str | None = None, ) -> bool: payload = { "touser": target_id, "msgtype": "link", "link": { "title": title, "description": description, "url": url, "thumb_url": thumb_url, }, } return await self._send_custom(payload) async def send_video( self, target_id: str, media_id: str, thumb_media_id: str = "", title: str = "", description: str = "", *, reply_to_id: str | None = None, thread_id: str | None = None, ) -> bool: payload: dict = { "touser": target_id, "msgtype": "video", "video": { "media_id": media_id, }, } if thumb_media_id: payload["video"]["thumb_media_id"] = thumb_media_id if title: payload["video"]["title"] = title if description: payload["video"]["description"] = description return await self._send_custom(payload) async def _send_media(self, to_user: str, media_type: str, media_id: str) -> bool: payload = { "touser": to_user, "msgtype": media_type, media_type: {"media_id": media_id}, } return await self._send_custom(payload) async def _send_custom(self, payload: dict) -> bool: token = self._gateway.access_token if self._gateway else None if not token: logger.error("No valid access_token for custom send") return False url = f"{CUSTOM_SEND_URL}?access_token={token}" if self._http is None: self._http = httpx.AsyncClient(timeout=15.0) for attempt in range(3): try: resp = await self._http.post(url, json=payload) data = resp.json() errcode = data.get("errcode", -1) if errcode == 0: return True if errcode == 45015: logger.warning("48h window expired for %s", payload.get("touser")) return False if errcode == 40001: logger.warning("Token expired, will retry") else: logger.warning("Custom send failed: errcode=%s errmsg=%s", errcode, data.get("errmsg")) except Exception as e: logger.warning("Custom send attempt %s failed: %s", attempt + 1, e) await asyncio.sleep(attempt + 1) return False async def send_stream(self, target_id: str, text_generator, chunk_size: int = 200): buffer = "" async for chunk in text_generator: buffer += chunk if len(buffer) >= chunk_size: await self.send_text(target_id, buffer) buffer = "" await asyncio.sleep(0.5) if buffer: await self.send_text(target_id, buffer) @staticmethod def build_passive_text_reply(to_user: str, from_user: str, content: str) -> str: return ( '' f'' f'' f'{int(time.time())}' '' f'' '' ) @staticmethod def build_passive_image_reply(to_user: str, from_user: str, media_id: str) -> str: return ( '' f'' f'' f'{int(time.time())}' '' '' f'' '' '' ) @staticmethod def build_passive_voice_reply(to_user: str, from_user: str, media_id: str) -> str: return ( '' f'' f'' f'{int(time.time())}' '' '' f'' '' '' ) async def close(self): if self._http: await self._http.aclose() self._http = None