""" 企微主动发消息客户端(应用消息)。 本文件职责:取 access_token、发送文本;密钥来自环境变量。 禁止:在发送成功时冒充业务成功;禁止按 userid 伪造业务分支。 出站唯一入口应经 outbox 消费调用本客户端。 """ from __future__ import annotations import logging import threading import time from dataclasses import dataclass from typing import Any, Optional import httpx logger = logging.getLogger(__name__) @dataclass class WeComSendResult: """企微发送结果(通道层)。""" ok: bool errcode: int = 0 errmsg: str = "" raw: dict[str, Any] | None = None class WeComAppClient: """ 询价应用(默认 AgentId 1000010)主动消息客户端。 线程安全:token 缓存带锁;HTTP 带超时,禁止无限阻塞。 """ def __init__( self, *, corp_id: str, secret: str, agent_id: int, api_base: str = "https://qyapi.weixin.qq.com", timeout_sec: float = 10.0, ) -> None: self._corp_id = corp_id self._secret = secret self._agent_id = agent_id self._api_base = api_base.rstrip("/") self._timeout = timeout_sec self._token: Optional[str] = None self._token_expire_at: float = 0.0 self._lock = threading.Lock() def send_text(self, *, touser: str, content: str) -> WeComSendResult: """ 向指定企微 userid 发送文本应用消息。 参数:touser 必须是入站 sender_id;content 测试环境应由上层加 [test] 前缀。 副作用:出站 HTTP 调企微;失败返回 ok=False,不抛给回调线程(由 outbox 重试)。 """ if not self._corp_id or not self._secret: return WeComSendResult(ok=False, errcode=-1, errmsg="WECOM_CORP_ID/SECRET 未配置") if not touser or not content: return WeComSendResult(ok=False, errcode=-1, errmsg="touser/content 为空") try: token = self._get_token() except Exception as exc: # noqa: BLE001 logger.exception("获取 access_token 失败") return WeComSendResult(ok=False, errcode=-1, errmsg=str(exc)) url = f"{self._api_base}/cgi-bin/message/send" body = { "touser": touser, "msgtype": "text", "agentid": self._agent_id, "text": {"content": content}, "safe": 0, } try: with httpx.Client(timeout=self._timeout) as client: resp = client.post(url, params={"access_token": token}, json=body) data = resp.json() except Exception as exc: # noqa: BLE001 logger.exception("企微 message/send 网络失败") return WeComSendResult(ok=False, errcode=-1, errmsg=str(exc)) errcode = int(data.get("errcode") or 0) errmsg = str(data.get("errmsg") or "") if errcode != 0: # token 失效则清空缓存,下次重试 if errcode in (40014, 42001, 42007): with self._lock: self._token = None self._token_expire_at = 0.0 logger.warning("企微发送失败 errcode=%s errmsg=%s", errcode, errmsg) return WeComSendResult(ok=False, errcode=errcode, errmsg=errmsg, raw=data) preview = content if len(content) <= 200 else (content[:200] + "…") logger.info("企微发送成功 touser=%s reply=%s", touser, preview) return WeComSendResult(ok=True, errcode=0, errmsg=errmsg, raw=data) def _get_token(self) -> str: with self._lock: now = time.time() if self._token and now < self._token_expire_at - 60: return self._token # 注意:勿把 corpsecret 打进日志;httpx 默认 INFO 会打印完整 URL url = f"{self._api_base}/cgi-bin/gettoken" with httpx.Client(timeout=self._timeout) as client: resp = client.get( url, params={"corpid": self._corp_id, "corpsecret": self._secret}, ) data = resp.json() errcode = int(data.get("errcode") or 0) if errcode != 0 or not data.get("access_token"): raise RuntimeError(f"gettoken 失败 errcode={errcode}") token = str(data["access_token"]) expires_in = int(data.get("expires_in") or 7200) with self._lock: self._token = token self._token_expire_at = time.time() + expires_in return token # 企微侧明确不可重试的错误(IP 白名单、非法 userid 等) PERMANENT_SEND_ERRORS = frozenset( { 60020, # not allow to access from your ip 60111, # invalid user 81013, # user not in app visible range 40003, # invalid openid/userid 40013, # invalid corpid 40001, # invalid credential(非 token 过期类时也停) } )