import asyncio import logging import httpx logger = logging.getLogger(__name__) MESSAGE_SEND_URL = "https://qyapi.weixin.qq.com/cgi-bin/message/send" MAX_UTF8_LEN_BYTES = 2048 def split_utf8(text: str, max_bytes: int = MAX_UTF8_LEN_BYTES) -> list[str]: encoded = text.encode("utf-8") if len(encoded) <= max_bytes: return [text] result: list[str] = [] remaining = encoded while remaining: chunk = remaining[:max_bytes] for cut in range(len(chunk), 0, -1): try: result.append(chunk[:cut].decode("utf-8")) break except UnicodeDecodeError: continue remaining = remaining[cut:] return result class WeComOutbound: def __init__(self, gateway=None): self._gateway = gateway self._http: httpx.AsyncClient | None = None @property def agent_id(self) -> int: account = self._gateway.account if self._gateway else None return getattr(account, "agent_id", 0) 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, toparty: str | None = None, totag: str | None = None, safe: int = 0, enable_duplicate_check: int = 0, duplicate_check_interval: int = 1800, ) -> None: if not content or not target_id: return texts = split_utf8(content) for i, text in enumerate(texts): payload: dict = { "msgtype": "text", "agentid": self.agent_id, "text": {"content": text}, "safe": safe, } if toparty: payload["toparty"] = toparty else: payload["touser"] = target_id if totag: payload["totag"] = totag if enable_duplicate_check: payload["enable_duplicate_check"] = enable_duplicate_check payload["duplicate_check_interval"] = duplicate_check_interval await self._send_message(payload) if i < len(texts) - 1: await asyncio.sleep(0.3) async def send_markdown( self, target_id: str, content: str, *, toparty: str | None = None, totag: str | None = None, safe: int = 0, enable_duplicate_check: int = 0, duplicate_check_interval: int = 1800, ) -> None: if not content or not target_id: return md_chunks = split_utf8(content, max_bytes=4096) for i, chunk in enumerate(md_chunks): payload: dict = { "msgtype": "markdown", "agentid": self.agent_id, "markdown": {"content": chunk}, "safe": safe, } if toparty: payload["toparty"] = toparty else: payload["touser"] = target_id if totag: payload["totag"] = totag if enable_duplicate_check: payload["enable_duplicate_check"] = enable_duplicate_check payload["duplicate_check_interval"] = duplicate_check_interval await self._send_message(payload) if i < len(md_chunks) - 1: await asyncio.sleep(0.3) async def send_media( self, target_id: str, media_id: str, media_type: str, reply_to_id: str | None = None, thread_id: str | None = None, title: str | None = None, description: str | None = None, ) -> None: media_field_map = { "image": "image", "voice": "voice", "video": "video", "file": "file", } field = media_field_map.get(media_type, media_type) payload = { "touser": target_id, "msgtype": media_type, "agentid": self.agent_id, field: {"media_id": media_id}, } if media_type == "video": payload[field]["title"] = title or "" payload[field]["description"] = description or "" await self._send_message(payload) async def send_textcard( self, target_id: str, title: str, description: str, url: str, btn_text: str = "查看详情", ) -> None: payload = { "touser": target_id, "msgtype": "textcard", "agentid": self.agent_id, "textcard": { "title": title, "description": description, "url": url, "btntxt": btn_text, }, } await self._send_message(payload) async def _send_message(self, payload: dict) -> bool: token = self._gateway.access_token if self._gateway else None if not token: logger.error("No valid access_token for wecom message send") return False url = f"{MESSAGE_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 in (40001, 40014, 42001): logger.warning("Token expired/invalid (%s), will retry", errcode) else: logger.warning( "WeCom send failed: errcode=%s errmsg=%s payload_type=%s", errcode, data.get("errmsg"), payload.get("msgtype"), ) if errcode in (40003, 60011, 84061): return False except Exception as e: logger.warning("WeCom send attempt %s failed: %s", attempt + 1, e) await asyncio.sleep(min(attempt + 1, 3)) return False async def send_template_card(self, target_id: str, template_card: dict) -> bool: payload = { "touser": target_id, "msgtype": "template_card", "agentid": self.agent_id, "template_card": template_card, } return await self._send_message(payload) async def send_news(self, target_id: str, articles: list[dict]) -> None: if not articles or not target_id: return if len(articles) > 8: articles = articles[:8] payload = { "touser": target_id, "msgtype": "news", "agentid": self.agent_id, "news": {"articles": articles}, } await self._send_message(payload) async def send_mpnews(self, target_id: str, articles: list[dict]) -> None: if not articles or not target_id: return if len(articles) > 8: articles = articles[:8] payload = { "touser": target_id, "msgtype": "mpnews", "agentid": self.agent_id, "mpnews": {"articles": articles}, } await self._send_message(payload) async def send_miniprogram_notice( self, target_id: str, appid: str, title: str, description: str = "", page: str = "", emphasis_first_item: bool = False, content_item: list[dict] | None = None, ) -> None: notice: dict = { "appid": appid, "title": title, "description": description, "emphasis_first_item": emphasis_first_item, } if page: notice["page"] = page if content_item: notice["content_item"] = content_item payload = { "touser": target_id, "msgtype": "miniprogram_notice", "agentid": self.agent_id, "miniprogram_notice": notice, } await self._send_message(payload) async def send_taskcard( self, target_id: str, task_id: str, title: str, description: str = "", url: str = "", buttons: list[dict] | None = None, ) -> None: card: dict = { "title": title, "description": description, "url": url, "task_id": task_id, } if buttons: card["btn"] = buttons payload = { "touser": target_id, "msgtype": "interactive_taskcard", "agentid": self.agent_id, "interactive_taskcard": card, } await self._send_message(payload) async def recall_message(self, msg_id: str) -> bool: token = self._gateway.access_token if self._gateway else None if not token: return False url = f"https://qyapi.weixin.qq.com/cgi-bin/message/recall?access_token={token}" if self._http is None: self._http = httpx.AsyncClient(timeout=15.0) try: resp = await self._http.post(url, json={"msgid": msg_id}) data = resp.json() return data.get("errcode") == 0 except Exception: logger.exception("WeCom recall_message failed") return False async def close(self): if self._http: await self._http.aclose() self._http = None