import asyncio import logging import time import httpx from yuxi.channel.extensions.wechat_mp.format import ( MAX_UTF8_LEN, remove_markdown, split_utf8, split_utf8_safe, ) logger = logging.getLogger(__name__) CUSTOM_SEND_URL = "https://api.weixin.qq.com/cgi-bin/message/custom/send" class WeChatMPOutbound: 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, extra: dict | None = None, ) -> None: payload = {"touser": target_id, "msgtype": media_type} field = (extra or {}).copy() if media_type in ("image", "voice"): field.setdefault("media_id", media_url) elif media_type == "video": field.setdefault("media_id", media_url) field.setdefault("thumb_media_id", field.get("thumb_media_id", "")) field.setdefault("title", field.get("title", "")) field.setdefault("description", field.get("description", "")) elif media_type == "music": field.setdefault("thumb_media_id", media_url) else: field.setdefault("media_id", media_url) payload[media_type] = field 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_video( self, to_user: str, media_id: str, thumb_media_id: str = "", title: str = "", description: str = "", ) -> bool: return await self._send_media( to_user, "video", media_id, extra={ "thumb_media_id": thumb_media_id, "title": title, "description": description, }, ) async def send_music( self, to_user: str, title: str, description: str, music_url: str, hq_music_url: str, thumb_media_id: str, ) -> bool: payload = { "touser": to_user, "msgtype": "music", "music": { "title": title, "description": description, "musicurl": music_url, "hqmusicurl": hq_music_url, "thumb_media_id": thumb_media_id, }, } return await self._send_custom(payload) async def send_news(self, to_user: str, articles: list[dict]) -> bool: payload = { "touser": to_user, "msgtype": "news", "news": {"articles": articles[:8]}, } return await self._send_custom(payload) async def send_menu(self, to_user: str, buttons: list[dict]) -> bool: payload = { "touser": to_user, "msgtype": "msgmenu", "msgmenu": {"head_content": "", "list": buttons[:10], "tail_content": ""}, } return await self._send_custom(payload) async def send_miniprogram_page( self, to_user: str, title: str, appid: str, pagepath: str, thumb_media_id: str, ) -> bool: payload = { "touser": to_user, "msgtype": "miniprogrampage", "miniprogrampage": { "title": title, "appid": appid, "pagepath": pagepath, "thumb_media_id": thumb_media_id, }, } return await self._send_custom(payload) async def _send_media(self, to_user: str, media_type: str, media_id: str, extra: dict | None = None) -> bool: payload = { "touser": to_user, "msgtype": media_type, media_type: {"media_id": media_id}, } if extra: payload[media_type].update(extra) 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'' '' '' ) @staticmethod def build_passive_video_reply( to_user: str, from_user: str, media_id: str, title: str = "", description: str = "" ) -> str: return ( '' f'' f'' f'{int(time.time())}' '' '' '' ) @staticmethod def build_passive_music_reply( to_user: str, from_user: str, title: str, description: str, music_url: str, hq_music_url: str, thumb_media_id: str, ) -> str: return ( '' f'' f'' f'{int(time.time())}' '' '' f'<![CDATA[{title}]]>' f'' f'' f'' f'' '' '' ) @staticmethod def build_passive_news_reply(to_user: str, from_user: str, articles: list[dict]) -> str: articles_xml = "" for article in articles[:8]: articles_xml += ( "" f'<![CDATA[{article.get("title", "")}]]>' f'' f'' f'' "" ) return ( '' f'' f'' f'{int(time.time())}' '' f'{len(articles[:8])}' f'{articles_xml}' '' ) async def close(self): if self._http: await self._http.aclose() self._http = None