""" 企微主动发消息客户端(应用消息)。 本文件职责:取 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_outbound( self, *, touser: str, content: str, payload: dict[str, Any] | None = None, ) -> WeComSendResult: """ 按 outbox 内容发应用消息:有模板卡先发卡,失败再回退文本。 副作用:出站 HTTP 调企微;失败返回 ok=False,由 outbox 重试。 """ extra = dict(payload or {}) if extra.get("msgtype") == "file": return self.send_file( touser=touser, filename=str(extra.get("filename") or "quote.xlsx"), filepath=str(extra.get("filepath") or ""), file_b64=str(extra.get("file_b64") or extra.get("fileBase64") or ""), ) if extra.get("msgtype") == "update_template_card": return self.update_template_card( touser=touser, response_code=str(extra.get("response_code") or ""), replace_name=str((extra.get("button") or {}).get("replace_name") or ""), ) if extra.get("msgtype") == "template_card" and extra.get("template_card"): card_result = self._post_message( { "touser": touser, "msgtype": "template_card", "agentid": self._agent_id, "template_card": extra["template_card"], "enable_id_trans": 0, }, preview="[template_card]", ) if card_result.ok: return card_result logger.warning( "模板卡发送失败,回退文本 err=%s:%s", card_result.errcode, card_result.errmsg, ) return self.send_text(touser=touser, content=content) def update_template_card( self, *, touser: str, response_code: str, replace_name: str, ) -> WeComSendResult: """ 按回调 ResponseCode 更新原模板卡按钮文案,保持置灰不可再点。 失败不回落成新文本气泡,避免销售看到重复「已提交」。 """ if not touser or not response_code or not replace_name: return WeComSendResult(ok=True, errcode=0, errmsg="skip_card_update") try: token = self._get_token() except Exception as exc: # noqa: BLE001 logger.exception("更新模板卡取 token 失败") return WeComSendResult(ok=True, errcode=-1, errmsg=str(exc)) url = f"{self._api_base}/cgi-bin/message/update_template_card" body = { "userids": [touser], "agentid": self._agent_id, "response_code": response_code, "enable_id_trans": 0, "button": {"replace_name": replace_name}, } 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("企微 update_template_card 网络失败") return WeComSendResult(ok=True, errcode=-1, errmsg=str(exc)) errcode = int(data.get("errcode") or 0) errmsg = str(data.get("errmsg") or "") if errcode != 0: logger.warning("更新模板卡失败 errcode=%s errmsg=%s", errcode, errmsg) return WeComSendResult(ok=True, errcode=errcode, errmsg=errmsg, raw=data) logger.info("企微模板卡已置灰 touser=%s", touser) return WeComSendResult(ok=True, errcode=0, errmsg=errmsg, raw=data) def send_file( self, *, touser: str, filename: str, filepath: str = "", file_b64: str = "", ) -> WeComSendResult: """ 先上传临时素材再发文件消息。 字节来自本机路径或 Base64;失败回 ok=False 由 outbox 重试。 禁止在回调线程调用;本方法只给出站消费线程用。 """ if not touser: return WeComSendResult(ok=False, errcode=-1, errmsg="touser 为空") raw = b"" if filepath: try: from pathlib import Path raw = Path(filepath).read_bytes() except OSError as exc: return WeComSendResult(ok=False, errcode=-1, errmsg=f"read_file:{exc}") elif file_b64: import base64 try: raw = base64.b64decode(file_b64) except Exception as exc: # noqa: BLE001 return WeComSendResult(ok=False, errcode=-1, errmsg=f"b64:{exc}") if not raw: return WeComSendResult(ok=False, errcode=-1, errmsg="file_empty") media_id, up = self._upload_media(filename=filename or "quote.xlsx", content=raw) if not media_id: return up return self._post_message( { "touser": touser, "msgtype": "file", "agentid": self._agent_id, "file": {"media_id": media_id}, }, preview=f"[file]{filename}", ) def _upload_media(self, *, filename: str, content: bytes) -> tuple[str, WeComSendResult]: """cgi-bin/media/upload;密钥不写日志。""" if not self._corp_id or not self._secret: return "", WeComSendResult(ok=False, errcode=-1, errmsg="WECOM_CORP_ID/SECRET 未配置") try: token = self._get_token() except Exception as exc: # noqa: BLE001 logger.exception("上传素材取 token 失败") return "", WeComSendResult(ok=False, errcode=-1, errmsg=str(exc)) url = f"{self._api_base}/cgi-bin/media/upload" try: with httpx.Client(timeout=max(self._timeout, 30.0)) as client: resp = client.post( url, params={"access_token": token, "type": "file"}, files={"media": (filename, content, "application/octet-stream")}, ) data = resp.json() except Exception as exc: # noqa: BLE001 logger.exception("企微 media/upload 网络失败") return "", WeComSendResult(ok=False, errcode=-1, errmsg=str(exc)) errcode = int(data.get("errcode") or 0) media_id = str(data.get("media_id") or "") if errcode != 0 or not media_id: logger.warning("企微上传文件失败 errcode=%s", errcode) return "", WeComSendResult( ok=False, errcode=errcode, errmsg=str(data.get("errmsg") or "upload_fail"), raw=data ) return media_id, WeComSendResult(ok=True, errcode=0, raw=data) def send_text(self, *, touser: str, content: str) -> WeComSendResult: """ 向指定企微 userid 发送文本应用消息。 参数:touser 必须是入站 sender_id;content 测试环境应由上层加 [test] 前缀。 副作用:出站 HTTP 调企微;失败返回 ok=False,不抛给回调线程(由 outbox 重试)。 """ if not touser or not content: return WeComSendResult(ok=False, errcode=-1, errmsg="touser/content 为空") return self._post_message( { "touser": touser, "msgtype": "text", "agentid": self._agent_id, "text": {"content": content}, "safe": 0, }, preview=content, ) def _post_message(self, body: dict[str, Any], *, preview: str) -> WeComSendResult: """真正打企微 message/send;密钥不写日志。""" if not self._corp_id or not self._secret: return WeComSendResult(ok=False, errcode=-1, errmsg="WECOM_CORP_ID/SECRET 未配置") 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" 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: 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) shown = preview if len(preview) <= 200 else (preview[:200] + "…") logger.info("企微发送成功 touser=%s reply=%s", body.get("touser"), shown) 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 过期类时也停) } )