落地海运协同群:测服靠会话存档听群话,报价单能带上协同字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-15 18:12:45 +08:00
co-authored by Cursor
parent 235ecd8c83
commit 12219cacb1
39 changed files with 4436 additions and 256 deletions
+161 -3
View File
@@ -68,12 +68,20 @@ class WeComAppClient:
"""
extra = dict(payload or {})
if extra.get("msgtype") == "file":
return self.send_file(
file_result = 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 ""),
)
# 成交卡绑在同一条出站:文件 HTTP 成功后再发卡。
# 企微文件气泡要等素材落地,卡片是即时的;同一毫秒连发,销售会先看到成交跟进。
if file_result.ok:
gap = float(getattr(self, "_followup_gap_sec", 1.6) or 0)
if gap > 0:
time.sleep(gap)
self._send_followup_card(touser=touser, extra=extra)
return file_result
if extra.get("msgtype") == "update_template_card":
return self.update_template_card(
touser=touser,
@@ -142,6 +150,36 @@ class WeComAppClient:
logger.info("企微模板卡已置灰 touser=%s", touser)
return WeComSendResult(ok=True, errcode=0, errmsg=errmsg, raw=data)
def _send_followup_card(self, *, touser: str, extra: dict[str, Any]) -> None:
"""
报价文件发出后再发跟进卡。失败不回滚文件、不触发整条重试。
跟进卡单独入队会被另一条出站线程抢走,销售就会先看到成交跟进。
"""
card = extra.get("followup_card")
if not isinstance(card, dict) or not card:
nested = extra.get("followup_payload")
if isinstance(nested, dict):
card = nested.get("template_card")
if not isinstance(card, dict) or not card:
return
result = self._post_message(
{
"touser": touser,
"msgtype": "template_card",
"agentid": self._agent_id,
"template_card": card,
"enable_id_trans": 0,
},
preview="[followup_card]",
)
if not result.ok:
logger.warning(
"报价文件已发出,跟进卡失败 err=%s:%s",
result.errcode,
result.errmsg,
)
def send_file(
self,
*,
@@ -283,12 +321,21 @@ class WeComAppClient:
logger.info("企微群已补人 chat=%s", chat_id)
return {"ok": True, "error": ""}
def send_group(self, *, chat_id: str, content: str) -> dict[str, Any]:
def send_group(
self,
*,
chat_id: str,
content: str,
mention_userids: list[str] | None = None,
) -> dict[str, Any]:
"""
向应用群发文本。必须走 appchat/send,不能当私聊 message/send。
mention_userids 只给调用方记账,不传给企微。
正文开头已经写了 @姓名;再传企微点人名单,结尾会再点一次。
失败回 ok=False,由调用方决定是否重试;不抛给回调线程。
"""
_ = mention_userids
if not chat_id or not content:
return {"ok": False, "error": "chat_or_content"}
if not self._corp_id or not self._secret:
@@ -299,7 +346,7 @@ class WeComAppClient:
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}}
body: dict[str, Any] = {"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)
@@ -313,6 +360,117 @@ class WeComAppClient:
return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")}
return {"ok": True, "error": ""}
def send_group_file(
self,
*,
chat_id: str,
filename: str,
filepath: str = "",
file_b64: str = "",
) -> dict[str, Any]:
"""
向应用群发文件:先上传临时素材,再 appchat/send。
与私聊 send_file 相同素材接口,但消息必须走群,不能 message/send。
失败回 ok=False,不抛给回调线程。
"""
if not chat_id:
return {"ok": False, "error": "chat_id"}
raw = b""
if filepath:
try:
from pathlib import Path
raw = Path(filepath).read_bytes()
except OSError as exc:
return {"ok": False, "error": f"read_file:{exc}"}
elif file_b64:
import base64
try:
raw = base64.b64decode(file_b64)
except Exception as exc: # noqa: BLE001
return {"ok": False, "error": f"b64:{exc}"}
if not raw:
return {"ok": False, "error": "file_empty"}
media_id, up = self._upload_media(filename=filename or "quote.xlsx", content=raw)
if not media_id:
return {"ok": False, "error": up.errmsg or "upload_fail"}
return self._post_appchat(
{
"chatid": chat_id,
"msgtype": "file",
"file": {"media_id": media_id},
}
)
def send_group_card(self, *, chat_id: str, template_card: dict[str, Any]) -> dict[str, Any]:
"""
向应用群发卡。企微 appchat 对 template_card 支持不稳定,失败由调用方已发过文本兜底。
"""
if not chat_id or not template_card:
return {"ok": False, "error": "chat_or_card"}
return self._post_appchat(
{
"chatid": chat_id,
"msgtype": "template_card",
"template_card": template_card,
}
)
def dismiss_group(self, *, chat_id: str, userids: list[str]) -> dict[str, Any]:
"""
解散应用群:官方无 dismiss,改为 appchat/update 移出成员。
不抛给回调。移完人不改工单六态。
"""
users = [str(x).strip() for x in (userids or []) 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, "del_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 _post_appchat(self, body: dict[str, Any]) -> dict[str, Any]:
"""appchat/send 公共发送。失败不抛。"""
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"
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("企微 appchat/send 网络失败")
return {"ok": False, "error": str(exc)}
errcode = int(data.get("errcode") or 0)
if errcode != 0:
logger.warning("企微 appchat/send 失败 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 发送文本应用消息。