Files
inquiry_robot/inquiry-agent/agent/channel/outbox/sender.py
T

411 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
企微主动发消息客户端(应用消息)。
本文件职责:取 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 create_group(self, *, name: str, userids: list[str]) -> dict[str, Any]:
"""
创建企微应用群(appchat/create),群名=工单号。
成员须含销售、产品、会话存档账号(询价机器人);应用自己会进群。失败回 ok=False,不抛给回调。
"""
users = [str(x).strip() for x in userids if str(x).strip()]
if not name or len(users) < 2:
return {"ok": False, "error": "name_or_users", "chat_id": ""}
try:
token = self._get_token()
except Exception as exc: # noqa: BLE001
logger.exception("建群取 token 失败")
return {"ok": False, "error": str(exc), "chat_id": ""}
url = f"{self._api_base}/cgi-bin/appchat/create"
body = {"name": name, "userlist": users}
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("企微建群网络失败")
return {"ok": False, "error": str(exc), "chat_id": ""}
errcode = int(data.get("errcode") or 0)
chat_id = str(data.get("chatid") or data.get("chat_id") or "")
if errcode != 0 or not chat_id:
logger.warning("企微建群失败 errcode=%s", errcode)
return {
"ok": False,
"error": str(data.get("errmsg") or f"errcode_{errcode}"),
"chat_id": "",
}
logger.info("企微群已创建 name=%s chat=%s", name, chat_id)
return {"ok": True, "chat_id": chat_id, "error": ""}
def add_group_members(self, *, chat_id: str, userids: list[str]) -> dict[str, Any]:
"""
往已有应用群补人(appchat/update add_user_list)。
用于已建群补拉会话存档账号。已在群里的人企微会忽略。不抛给回调。
"""
users = [str(x).strip() for x in userids if str(x).strip()]
if not chat_id or not users:
return {"ok": False, "error": "chat_or_users"}
try:
token = self._get_token()
except Exception as exc: # noqa: BLE001
logger.exception("补拉群成员取 token 失败")
return {"ok": False, "error": str(exc)}
url = f"{self._api_base}/cgi-bin/appchat/update"
body = {"chatid": chat_id, "add_user_list": users}
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("企微补拉群成员网络失败")
return {"ok": False, "error": str(exc)}
errcode = int(data.get("errcode") or 0)
if errcode != 0:
logger.warning("企微补拉群成员失败 errcode=%s", errcode)
return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")}
logger.info("企微群已补人 chat=%s", chat_id)
return {"ok": True, "error": ""}
def send_group(self, *, chat_id: str, content: str) -> dict[str, Any]:
"""
向应用群发文本。必须走 appchat/send,不能当私聊 message/send。
失败回 ok=False,由调用方决定是否重试;不抛给回调线程。
"""
if not chat_id or not content:
return {"ok": False, "error": "chat_or_content"}
if not self._corp_id or not self._secret:
return {"ok": False, "error": "WECOM_CORP_ID/SECRET 未配置"}
try:
token = self._get_token()
except Exception as exc: # noqa: BLE001
logger.exception("群消息取 token 失败")
return {"ok": False, "error": str(exc)}
url = f"{self._api_base}/cgi-bin/appchat/send"
body = {"chatid": chat_id, "msgtype": "text", "text": {"content": content}}
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("企微群消息网络失败")
return {"ok": False, "error": str(exc)}
errcode = int(data.get("errcode") or 0)
if errcode != 0:
logger.warning("企微群消息失败 errcode=%s", errcode)
return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")}
return {"ok": True, "error": ""}
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_access_token(self) -> str:
"""
取出站/JS-SDK 用的 access_token。
只给同进程 H5 签名用;禁止写进页面或日志。
"""
return self._get_token()
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 过期类时也停)
}
)