落地海运协同群:测服靠会话存档听群话,报价单能带上协同字段。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 发送文本应用消息。
|
||||
|
||||
Reference in New Issue
Block a user