落地海运协同群:测服靠会话存档听群话,报价单能带上协同字段。
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 发送文本应用消息。
|
||||
|
||||
@@ -238,7 +238,7 @@ class MemoryMessageStore:
|
||||
return state == TaskState.DONE
|
||||
if state == TaskState.CLAIMED:
|
||||
saw_claim = True
|
||||
# 单测没有出站线程:一直 PENDING 就不要空等 12 秒。测服有出站线程会立刻认领。
|
||||
# 没有出站线程时(单测)不要空等;已被认领则等到发完。
|
||||
if not saw_claim and state == TaskState.PENDING and time.time() - started >= 0.4:
|
||||
return True
|
||||
if not state and time.time() - started >= 0.3:
|
||||
|
||||
@@ -53,6 +53,15 @@ def parse_plain_xml(xml_text: str, *, default_agent_id: str = "") -> InboundMess
|
||||
if chat_id:
|
||||
chat_type = "group"
|
||||
|
||||
media: dict[str, object] = {}
|
||||
if msg_type.lower() in {"file", "image", "video"}:
|
||||
media = {
|
||||
"media_id": _text(root, "MediaId") or _text(root, "MediaID"),
|
||||
"filename": _text(root, "FileName") or _text(root, "Title"),
|
||||
"file_ext": _text(root, "FileExt"),
|
||||
"content_type": "",
|
||||
}
|
||||
|
||||
return InboundMessage(
|
||||
sender_id=sender_id,
|
||||
message_id=msg_id,
|
||||
@@ -62,7 +71,7 @@ def parse_plain_xml(xml_text: str, *, default_agent_id: str = "") -> InboundMess
|
||||
chat_type=chat_type,
|
||||
agent_id=agent_id,
|
||||
create_time=create_time,
|
||||
media={},
|
||||
media=media,
|
||||
raw={
|
||||
"event": event,
|
||||
"event_key": event_key,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
微盛会话存档通道:把管家查询结果归一成 InboundMessage。
|
||||
|
||||
本包只做 HTTP 查询与解析。轮询循环在 Worker HTTP 槽(jobs/archive_poll)。
|
||||
禁止:推现网官方会话存档 SDK 游标;禁止在本包建工单、调 LLM。
|
||||
"""
|
||||
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
微盛 OpenAPI 客户端:换票 + 拉员工群消息。
|
||||
|
||||
本文件职责:带超时的 HTTPS GET;token 缓存在本前缀 Redis。
|
||||
禁止:在回调线程调用;禁止把 access_token / secret 打进日志;禁止动现网官方 seq。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
from agent.redis_coord import get_redis_runtime
|
||||
from agent.redis_coord.client import RedisClient
|
||||
from agent.redis_coord.token_cache import get_wshoto_token, set_wshoto_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TOKEN_PATH = "/openapi/access_token"
|
||||
_LIST_PATH = "/openapi/chat/message/list"
|
||||
_HTTP_TIMEOUT = 8.0
|
||||
|
||||
|
||||
class WshotoError(RuntimeError):
|
||||
"""管家接口业务失败(code!=0)。message 不得含密钥。"""
|
||||
|
||||
def __init__(self, code: int, msg: str) -> None:
|
||||
self.code = int(code)
|
||||
self.msg = str(msg or "")
|
||||
super().__init__(f"wshoto code={self.code} msg={self.msg}")
|
||||
|
||||
|
||||
def wshoto_ready(settings: Optional[Settings] = None) -> bool:
|
||||
"""有 AppId/Secret 且开关未关,才允许 Worker 去拉。"""
|
||||
cfg = settings or get_settings()
|
||||
if not bool(getattr(cfg, "wshoto_poll_enabled", True)):
|
||||
return False
|
||||
return bool((cfg.wshoto_app_id or "").strip() and (cfg.wshoto_app_secret or "").strip())
|
||||
|
||||
|
||||
def _is_token_error(code: int, msg: str) -> bool:
|
||||
text = (msg or "").lower()
|
||||
return code in {40001, 40014, 42001} or "token" in text or "过期" in (msg or "")
|
||||
|
||||
|
||||
class WshotoOpenClient:
|
||||
"""
|
||||
微盛查询客户端。
|
||||
|
||||
调用时机:Worker HTTP 槽。失败抛 WshotoError 或返回空列表由调用方决定。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Optional[Settings] = None,
|
||||
*,
|
||||
redis: Optional[RedisClient] = None,
|
||||
) -> None:
|
||||
self.settings = settings or get_settings()
|
||||
self._redis = redis
|
||||
|
||||
def _redis_client(self) -> Optional[RedisClient]:
|
||||
if self._redis is not None:
|
||||
return self._redis
|
||||
try:
|
||||
return get_redis_runtime().client
|
||||
except Exception:
|
||||
logger.exception("微盛 token 缓存:Redis 不可用,本次直打换票")
|
||||
return None
|
||||
|
||||
def _base(self) -> str:
|
||||
return (self.settings.wshoto_base_url or "https://open.wshoto.com").rstrip("/")
|
||||
|
||||
def fetch_token(self, *, force: bool = False) -> str:
|
||||
"""
|
||||
取 access_token:先读本前缀缓存,没有再换票。
|
||||
|
||||
副作用:可能写 Redis token:wshoto。不会推进任何消息游标。
|
||||
"""
|
||||
app_id = (self.settings.wshoto_app_id or "").strip()
|
||||
secret = (self.settings.wshoto_app_secret or "").strip()
|
||||
if not app_id or not secret:
|
||||
raise WshotoError(0, "missing_app_credentials")
|
||||
cache = self._redis_client()
|
||||
if cache is not None and not force:
|
||||
cached = get_wshoto_token(cache, app_id=app_id)
|
||||
if cached:
|
||||
return cached
|
||||
url = f"{self._base()}{_TOKEN_PATH}"
|
||||
try:
|
||||
resp = httpx.get(
|
||||
url,
|
||||
params={"appid": app_id, "app_secret": secret},
|
||||
timeout=_HTTP_TIMEOUT,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise WshotoError(0, f"token_http:{type(exc).__name__}") from exc
|
||||
code = int(body.get("code") if body.get("code") is not None else -1)
|
||||
if code != 0:
|
||||
raise WshotoError(code, str(body.get("msg") or "token_failed"))
|
||||
data = body.get("data") if isinstance(body.get("data"), dict) else {}
|
||||
token = str(data.get("access_token") or "").strip()
|
||||
if not token:
|
||||
raise WshotoError(code, "empty_access_token")
|
||||
expires = int(data.get("expires_in") or 7200)
|
||||
if cache is not None:
|
||||
set_wshoto_token(cache, app_id=app_id, token=token, ttl_seconds=max(60, expires - 120))
|
||||
logger.info("微盛换票成功 expires_in=%s", expires)
|
||||
return token
|
||||
|
||||
def list_group_messages(
|
||||
self,
|
||||
*,
|
||||
userid: str,
|
||||
target_id: str = "",
|
||||
last_seq: int = 0,
|
||||
page_size: int = 50,
|
||||
msg_time_start: int = 0,
|
||||
msg_time_end: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
拉指定员工的群聊消息(type=1)。
|
||||
|
||||
last_seq 只是本次查询过滤,不是官方 SDK 消费确认。
|
||||
首次不要传 last_seq=0 翻全量,应配合时间窗。
|
||||
"""
|
||||
token = self.fetch_token()
|
||||
return self._list_once(
|
||||
token=token,
|
||||
userid=userid,
|
||||
target_id=target_id,
|
||||
last_seq=last_seq,
|
||||
page_size=page_size,
|
||||
msg_time_start=msg_time_start,
|
||||
msg_time_end=msg_time_end,
|
||||
retry_token=True,
|
||||
)
|
||||
|
||||
def _list_once(
|
||||
self,
|
||||
*,
|
||||
token: str,
|
||||
userid: str,
|
||||
target_id: str,
|
||||
last_seq: int,
|
||||
page_size: int,
|
||||
msg_time_start: int,
|
||||
msg_time_end: int,
|
||||
retry_token: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
params: dict[str, Any] = {
|
||||
"access_token": token,
|
||||
"type": 1,
|
||||
"page_size": max(1, min(int(page_size or 50), 100)),
|
||||
}
|
||||
if userid:
|
||||
params["userid"] = userid
|
||||
if target_id:
|
||||
params["target_id"] = target_id
|
||||
if last_seq > 0:
|
||||
params["last_seq"] = int(last_seq)
|
||||
if msg_time_start > 0 and msg_time_end > 0:
|
||||
params["msg_time_start"] = int(msg_time_start)
|
||||
params["msg_time_end"] = int(msg_time_end)
|
||||
url = f"{self._base()}{_LIST_PATH}"
|
||||
try:
|
||||
resp = httpx.get(url, params=params, timeout=_HTTP_TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise WshotoError(0, f"list_http:{type(exc).__name__}") from exc
|
||||
code = int(body.get("code") if body.get("code") is not None else -1)
|
||||
msg = str(body.get("msg") or "")
|
||||
if code != 0:
|
||||
if retry_token and _is_token_error(code, msg):
|
||||
fresh = self.fetch_token(force=True)
|
||||
return self._list_once(
|
||||
token=fresh,
|
||||
userid=userid,
|
||||
target_id=target_id,
|
||||
last_seq=last_seq,
|
||||
page_size=page_size,
|
||||
msg_time_start=msg_time_start,
|
||||
msg_time_end=msg_time_end,
|
||||
retry_token=False,
|
||||
)
|
||||
raise WshotoError(code, msg or "list_failed")
|
||||
data = body.get("data") if isinstance(body.get("data"), dict) else {}
|
||||
records = data.get("records") if isinstance(data, dict) else None
|
||||
if not isinstance(records, list):
|
||||
return []
|
||||
return [dict(x) for x in records if isinstance(x, dict)]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
微盛群消息 → InboundMessage。
|
||||
|
||||
本文件职责:从管家 records 抽出 sender_id / chat_id / 正文 / 附件名。
|
||||
禁止:把存档席「询价机器人」当成发言人入业务;禁止再向企微换 ID。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Mapping, Optional, Sequence
|
||||
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
|
||||
# 存档席在群里的显示名;from 偶发不是 userid 时也要丢掉
|
||||
SEAT_DISPLAY_NAMES = frozenset({"询价机器人"})
|
||||
_SKIP_TYPES = frozenset({"revoke", "recall", "event", "unknown"})
|
||||
_FILE_TYPES = frozenset({"file", "image", "video", "emotion", "voice"})
|
||||
|
||||
|
||||
def _as_mapping(raw: Any) -> dict[str, Any]:
|
||||
if isinstance(raw, Mapping):
|
||||
return dict(raw)
|
||||
if isinstance(raw, str) and raw.strip().startswith("{"):
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return dict(parsed) if isinstance(parsed, Mapping) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def _create_time(msgtime: Any) -> int:
|
||||
"""微盛 msgtime 常是毫秒;InboundMessage.create_time 用秒。"""
|
||||
try:
|
||||
n = int(msgtime or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
if n > 10**12:
|
||||
return n // 1000
|
||||
return n
|
||||
|
||||
|
||||
def _filename(msg_type: str, content: str, payload: Mapping[str, Any]) -> str:
|
||||
for key in ("filename", "file_name", "FileName", "title", "name"):
|
||||
val = str(payload.get(key) or "").strip()
|
||||
if val:
|
||||
return val
|
||||
if msg_type in _FILE_TYPES:
|
||||
return str(content or "").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def archive_record_to_inbound(
|
||||
record: Mapping[str, Any],
|
||||
*,
|
||||
seat_userids: Sequence[str] = (),
|
||||
) -> Optional[InboundMessage]:
|
||||
"""
|
||||
一条管家记录变成群入站。
|
||||
|
||||
返回 None:不是群、没有发言人、发言人是存档席、或撤回类消息。
|
||||
message_id 带 wshoto: 前缀,避免和回调 MsgId 撞车。
|
||||
"""
|
||||
room_id = str(record.get("room_id") or record.get("roomid") or "").strip()
|
||||
sender_id = str(record.get("from") or record.get("from_id") or "").strip()
|
||||
if not room_id or not sender_id:
|
||||
return None
|
||||
seats = {str(x).strip() for x in seat_userids if str(x).strip()}
|
||||
if sender_id in seats or sender_id in SEAT_DISPLAY_NAMES:
|
||||
return None
|
||||
|
||||
msg_type = str(record.get("msgtype") or record.get("msg_type") or "text").strip().lower()
|
||||
if msg_type in _SKIP_TYPES:
|
||||
return None
|
||||
|
||||
payload = _as_mapping(record.get("msg_content") or record.get("msgcontent"))
|
||||
content = str(record.get("content") or payload.get("content") or "").strip()
|
||||
msgid = str(record.get("msgid") or record.get("msg_id") or "").strip()
|
||||
seq = record.get("seq")
|
||||
if not msgid:
|
||||
msgid = f"{room_id}:{seq or 0}:{sender_id}"
|
||||
|
||||
media: dict[str, Any] = {}
|
||||
inbound_type = "text"
|
||||
if msg_type in _FILE_TYPES:
|
||||
inbound_type = "file" if msg_type == "file" else msg_type
|
||||
media = {
|
||||
"filename": _filename(msg_type, content, payload),
|
||||
"media_id": str(payload.get("sdkfileid") or payload.get("sdk_file_id") or ""),
|
||||
"file_ext": str(payload.get("fileext") or payload.get("file_ext") or ""),
|
||||
"content_type": "",
|
||||
"filesize": payload.get("filesize") or payload.get("file_size") or 0,
|
||||
}
|
||||
if not content:
|
||||
content = str(media.get("filename") or "")
|
||||
|
||||
return InboundMessage(
|
||||
sender_id=sender_id,
|
||||
message_id=f"wshoto:{msgid}",
|
||||
content=content,
|
||||
msg_type=inbound_type,
|
||||
chat_id=room_id,
|
||||
chat_type="group",
|
||||
create_time=_create_time(record.get("msgtime") or record.get("msg_time")),
|
||||
media=media,
|
||||
raw={
|
||||
"source": "wshoto_archive",
|
||||
"seq": seq,
|
||||
"msgtype": msg_type,
|
||||
"direction": record.get("direction") or "",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
微盛群消息一轮:按「询价机器人」席位拉新话,写入 inbox。
|
||||
|
||||
本文件职责:换票查询、归一、入 inbox、推进测服 last_seq。
|
||||
Worker 独立线程轮询,不占 HTTP 槽、不堵回调。入队后由 inbox 线程分发。
|
||||
禁止:堵回调;禁止推现网官方 SDK 游标;禁止把存档席当发言人。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Callable, Optional, Sequence
|
||||
|
||||
from agent.channel.queue import get_message_store
|
||||
from agent.channel.wshoto.client import WshotoError, WshotoOpenClient, wshoto_ready
|
||||
from agent.channel.wshoto.parse import archive_record_to_inbound
|
||||
from agent.channel.wshoto.rooms import (
|
||||
list_watched_rooms,
|
||||
load_last_seq,
|
||||
mark_room_hot,
|
||||
save_last_seq,
|
||||
)
|
||||
from agent.config import Settings, get_settings
|
||||
from agent.redis_coord.client import RedisClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 新群还没有游标时,只回看最近几分钟,避免每轮翻三小时。
|
||||
_NEW_ROOM_LOOKBACK_SEC = 180
|
||||
|
||||
|
||||
def archive_seat_userids(settings: Optional[Settings] = None) -> list[str]:
|
||||
"""
|
||||
会话存档账号 userid。
|
||||
|
||||
测服群里的「询价机器人」就是这一席;默认读 WECOM_ARCHIVE_SEAT_USER_IDS(AI001)。
|
||||
"""
|
||||
cfg = settings or get_settings()
|
||||
raw = str(getattr(cfg, "wecom_archive_seat_user_ids", "") or "").strip()
|
||||
ids: list[str] = []
|
||||
for part in raw.replace(";", ",").split(","):
|
||||
uid = part.strip()
|
||||
if uid and uid not in ids:
|
||||
ids.append(uid)
|
||||
return ids
|
||||
|
||||
|
||||
def _record_seq(record: dict[str, Any]) -> int:
|
||||
try:
|
||||
return int(record.get("seq") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def ingest_records(
|
||||
records: Sequence[dict[str, Any]],
|
||||
*,
|
||||
seat_userids: Sequence[str],
|
||||
min_seq: int = 0,
|
||||
store: Any = None,
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
把一页管家记录写入 inbox。
|
||||
|
||||
返回:(入队成功数, 跳过数)。无 sender / 存档席 / 重复 message_id 都算跳过。
|
||||
"""
|
||||
book = store if store is not None else get_message_store()
|
||||
enqueued = 0
|
||||
skipped = 0
|
||||
for rec in records:
|
||||
if _record_seq(rec) <= min_seq:
|
||||
skipped += 1
|
||||
continue
|
||||
msg = archive_record_to_inbound(rec, seat_userids=seat_userids)
|
||||
if msg is None:
|
||||
skipped += 1
|
||||
continue
|
||||
ok, reason = book.enqueue_inbound(msg)
|
||||
if ok:
|
||||
enqueued += 1
|
||||
mark_room_hot(msg.chat_id)
|
||||
logger.info(
|
||||
"存档入队 sender=%s chat=%s type=%s reason=%s",
|
||||
msg.sender_id,
|
||||
msg.chat_id,
|
||||
msg.msg_type,
|
||||
reason,
|
||||
)
|
||||
else:
|
||||
skipped += 1
|
||||
return enqueued, skipped
|
||||
|
||||
|
||||
def _needs_target(exc: WshotoError) -> bool:
|
||||
text = (exc.msg or "").lower()
|
||||
return "target_id" in text or "targetid" in text or "房间" in (exc.msg or "")
|
||||
|
||||
|
||||
def poll_once(
|
||||
*,
|
||||
client: Optional[WshotoOpenClient] = None,
|
||||
settings: Optional[Settings] = None,
|
||||
redis: Optional[RedisClient] = None,
|
||||
store: Any = None,
|
||||
now: Optional[Callable[[], float]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
拉一页并存入 inbox。
|
||||
|
||||
已有 last_seq:按 seq 增量查。没有:只看最近若干小时,避免从 0 翻全公司历史。
|
||||
已登记协同群按房间号拉(不要带存档席 userid,应用群否则给空页)。
|
||||
还没有群时才按席位试拉。
|
||||
"""
|
||||
cfg = settings or get_settings()
|
||||
if not wshoto_ready(cfg):
|
||||
return {"ok": False, "reason": "not_configured"}
|
||||
seats = archive_seat_userids(cfg)
|
||||
if not seats:
|
||||
logger.warning("微盛轮询:未配置 WECOM_ARCHIVE_SEAT_USER_IDS,只能靠显示名丢掉询价机器人自己的话")
|
||||
api = client or WshotoOpenClient(cfg, redis=redis)
|
||||
lookback = max(60, int(getattr(cfg, "wshoto_bootstrap_lookback_sec", 10800) or 10800))
|
||||
page_size = int(getattr(cfg, "wshoto_page_size", 50) or 50)
|
||||
clock = now or time.time
|
||||
rooms = list_watched_rooms(redis=redis)
|
||||
if rooms:
|
||||
# 应用群必须带房间号、不要带存档席 userid,否则管家返回空页。
|
||||
records, mode, last = _poll_by_rooms(
|
||||
api,
|
||||
redis=redis,
|
||||
page_size=page_size,
|
||||
lookback=lookback,
|
||||
clock=clock,
|
||||
)
|
||||
else:
|
||||
seat = seats[0] if seats else ""
|
||||
last = load_last_seq(f"seat:{seat or 'default'}", redis=redis)
|
||||
time_start = 0
|
||||
time_end = 0
|
||||
if last <= 0:
|
||||
time_end = int(clock())
|
||||
time_start = time_end - lookback
|
||||
try:
|
||||
records = api.list_group_messages(
|
||||
userid=seat,
|
||||
last_seq=last,
|
||||
page_size=page_size,
|
||||
msg_time_start=time_start,
|
||||
msg_time_end=time_end,
|
||||
)
|
||||
mode = "seat"
|
||||
except WshotoError as exc:
|
||||
if not _needs_target(exc):
|
||||
logger.warning("微盛按席拉取失败 code=%s msg=%s", exc.code, exc.msg)
|
||||
return {"ok": False, "reason": "list_failed", "code": exc.code}
|
||||
records, mode, last = _poll_by_rooms(
|
||||
api,
|
||||
redis=redis,
|
||||
page_size=page_size,
|
||||
lookback=lookback,
|
||||
clock=clock,
|
||||
)
|
||||
# 按房间拉时各群自己的 last_seq 已经过滤过,这里不要再用席游标误丢。
|
||||
ingest_min = 0 if mode == "rooms" else last
|
||||
enqueued, skipped = ingest_records(
|
||||
records,
|
||||
seat_userids=seats,
|
||||
min_seq=ingest_min,
|
||||
store=store,
|
||||
)
|
||||
max_seq = last
|
||||
for rec in records:
|
||||
seq = _record_seq(rec)
|
||||
if seq > max_seq:
|
||||
max_seq = seq
|
||||
if max_seq > last and not rooms:
|
||||
save_last_seq(f"seat:{(seats[0] if seats else 'default')}", max_seq, redis=redis)
|
||||
logger.info(
|
||||
"微盛轮询 mode=%s fetched=%s enqueued=%s skipped=%s last_seq=%s→%s",
|
||||
mode,
|
||||
len(records),
|
||||
enqueued,
|
||||
skipped,
|
||||
last,
|
||||
max_seq,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"mode": mode,
|
||||
"fetched": len(records),
|
||||
"enqueued": enqueued,
|
||||
"skipped": skipped,
|
||||
"last_seq": max_seq,
|
||||
}
|
||||
|
||||
|
||||
def _poll_by_rooms(
|
||||
api: WshotoOpenClient,
|
||||
*,
|
||||
redis: Optional[RedisClient],
|
||||
page_size: int,
|
||||
lookback: int,
|
||||
clock: Callable[[], float],
|
||||
) -> tuple[list[dict[str, Any]], str, int]:
|
||||
"""只拉已登记协同群。应用群不要带存档席 userid,否则管家给空页。"""
|
||||
rooms = list_watched_rooms(redis=redis)
|
||||
if not rooms:
|
||||
logger.warning("微盛按房间拉取:还没有登记过的协同群")
|
||||
return [], "rooms", 0
|
||||
rooms = sorted(
|
||||
rooms,
|
||||
key=lambda r: (0 if load_last_seq(f"room:{r}", redis=redis) <= 0 else 1, r),
|
||||
)
|
||||
merged: list[dict[str, Any]] = []
|
||||
floor = 0
|
||||
for room in rooms:
|
||||
scope = f"room:{room}"
|
||||
last = load_last_seq(scope, redis=redis)
|
||||
time_start = 0
|
||||
time_end = 0
|
||||
if last <= 0:
|
||||
time_end = int(clock())
|
||||
time_start = time_end - min(lookback, _NEW_ROOM_LOOKBACK_SEC)
|
||||
try:
|
||||
rows = api.list_group_messages(
|
||||
userid="",
|
||||
target_id=room,
|
||||
last_seq=last,
|
||||
page_size=page_size,
|
||||
msg_time_start=time_start,
|
||||
msg_time_end=time_end,
|
||||
)
|
||||
except WshotoError as exc:
|
||||
logger.warning("微盛房间拉取失败 chat=%s code=%s msg=%s", room, exc.code, exc.msg)
|
||||
continue
|
||||
max_seq = last
|
||||
for rec in rows:
|
||||
seq = _record_seq(rec)
|
||||
if seq > max_seq:
|
||||
max_seq = seq
|
||||
if max_seq > last:
|
||||
save_last_seq(scope, max_seq, redis=redis)
|
||||
if last > floor:
|
||||
floor = last
|
||||
merged.extend(rows)
|
||||
return merged, "rooms", floor
|
||||
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
测服自己的微盛监听名单与 last_seq。
|
||||
|
||||
本文件职责:把协同群 chat_id、按席/按房间的查询游标写到 REDIS_KEY_PREFIX 下。
|
||||
禁止:写现网 ytd:prod: 前缀;禁止把游标当六态。
|
||||
线程:可被建群线程与 Worker 轮询同时调用,只做单 key 读写。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from agent.redis_coord import get_redis_runtime
|
||||
from agent.redis_coord.client import RedisClient
|
||||
from agent.redis_coord.keys import WSHOTO_LAST_SEQ, WSHOTO_ROOM_TICKET, WSHOTO_ROOMS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 建群后或刚听到群话:立刻唤醒轮询,并在热窗内更勤地扫。
|
||||
_poll_wake = threading.Event()
|
||||
_hot_until: dict[str, float] = {}
|
||||
_hot_lock = threading.Lock()
|
||||
HOT_WINDOW_SEC = 90.0
|
||||
|
||||
|
||||
def wake_wshoto_poll() -> None:
|
||||
"""打断轮询等待。建群线程可调;真正拉消息仍在 Worker 轮询线程。"""
|
||||
_poll_wake.set()
|
||||
|
||||
|
||||
def wait_wshoto_poll(timeout: float) -> bool:
|
||||
"""等下一轮或被唤醒。返回是否被踢醒。"""
|
||||
fired = _poll_wake.wait(timeout=max(0.1, float(timeout)))
|
||||
if fired:
|
||||
_poll_wake.clear()
|
||||
return fired
|
||||
|
||||
|
||||
def mark_room_hot(chat_id: str, *, ttl_sec: float = HOT_WINDOW_SEC) -> None:
|
||||
"""这群接下来几十秒更勤扫:刚建群或刚有人说话。"""
|
||||
raw = (chat_id or "").strip()
|
||||
if not raw:
|
||||
return
|
||||
until = time.time() + max(5.0, float(ttl_sec))
|
||||
with _hot_lock:
|
||||
_hot_until[raw] = until
|
||||
wake_wshoto_poll()
|
||||
|
||||
|
||||
def has_hot_room() -> bool:
|
||||
"""是否还有热窗内的协同群。"""
|
||||
now = time.time()
|
||||
with _hot_lock:
|
||||
stale = [k for k, until in _hot_until.items() if until <= now]
|
||||
for k in stale:
|
||||
_hot_until.pop(k, None)
|
||||
return any(until > now for until in _hot_until.values())
|
||||
|
||||
|
||||
def _client(redis: Optional[RedisClient] = None) -> Optional[RedisClient]:
|
||||
if redis is not None:
|
||||
return redis
|
||||
try:
|
||||
return get_redis_runtime().client
|
||||
except Exception:
|
||||
logger.exception("微盛监听名单:Redis 不可用")
|
||||
return None
|
||||
|
||||
|
||||
def watch_collab_room(
|
||||
chat_id: str,
|
||||
*,
|
||||
work_order_no: str = "",
|
||||
redis: Optional[RedisClient] = None,
|
||||
) -> None:
|
||||
"""
|
||||
建群成功后登记 chat_id,供按房间补拉。
|
||||
|
||||
可同时记下房间→工单号,主账按群查找暂时不可用时也能对上单。
|
||||
副作用:改 Redis。失败只打日志,不挡销售侧建群。
|
||||
"""
|
||||
raw = (chat_id or "").strip()
|
||||
if not raw:
|
||||
return
|
||||
client = _client(redis)
|
||||
if client is None:
|
||||
return
|
||||
key = client.key(WSHOTO_ROOMS)
|
||||
current = client.raw.get(key) or ""
|
||||
if isinstance(current, bytes):
|
||||
current = current.decode("utf-8")
|
||||
rooms = {x.strip() for x in str(current).split(",") if x.strip()}
|
||||
rooms.add(raw)
|
||||
client.raw.set(key, ",".join(sorted(rooms)))
|
||||
no = (work_order_no or "").strip()
|
||||
if no:
|
||||
client.raw.set(client.key(WSHOTO_ROOM_TICKET, raw), no)
|
||||
logger.info("存档监听已登记群数=%s", len(rooms))
|
||||
mark_room_hot(raw)
|
||||
|
||||
|
||||
def ticket_no_for_room(chat_id: str, *, redis: Optional[RedisClient] = None) -> str:
|
||||
"""用协同群 chat_id 找回工单号(测服 Redis 短索引,不是六态)。"""
|
||||
raw = (chat_id or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
client = _client(redis)
|
||||
if client is None:
|
||||
return ""
|
||||
val = client.raw.get(client.key(WSHOTO_ROOM_TICKET, raw))
|
||||
if val is None:
|
||||
return ""
|
||||
if isinstance(val, bytes):
|
||||
val = val.decode("utf-8")
|
||||
return str(val).strip()
|
||||
|
||||
|
||||
def unwatch_collab_room(chat_id: str, *, redis: Optional[RedisClient] = None) -> None:
|
||||
"""协同结束后去掉监听,避免解散群还被当成活群补拉。"""
|
||||
raw = (chat_id or "").strip()
|
||||
if not raw:
|
||||
return
|
||||
client = _client(redis)
|
||||
if client is None:
|
||||
return
|
||||
key = client.key(WSHOTO_ROOMS)
|
||||
current = client.raw.get(key) or ""
|
||||
if isinstance(current, bytes):
|
||||
current = current.decode("utf-8")
|
||||
rooms = {x.strip() for x in str(current).split(",") if x.strip() and x.strip() != raw}
|
||||
client.raw.set(key, ",".join(sorted(rooms)))
|
||||
client.raw.delete(client.key(WSHOTO_ROOM_TICKET, raw))
|
||||
|
||||
|
||||
def list_watched_rooms(*, redis: Optional[RedisClient] = None) -> list[str]:
|
||||
"""当前要按房间补拉的协同群。"""
|
||||
client = _client(redis)
|
||||
if client is None:
|
||||
return []
|
||||
current = client.raw.get(client.key(WSHOTO_ROOMS)) or ""
|
||||
if isinstance(current, bytes):
|
||||
current = current.decode("utf-8")
|
||||
return [x.strip() for x in str(current).split(",") if x.strip()]
|
||||
|
||||
|
||||
def load_last_seq(scope: str, *, redis: Optional[RedisClient] = None) -> int:
|
||||
"""
|
||||
读取本前缀下的 last_seq。
|
||||
|
||||
scope:seat:AI001 或 room:wrxxx。没有记录返回 0(调用方改走时间窗,避免从 0 翻全量历史)。
|
||||
"""
|
||||
client = _client(redis)
|
||||
if client is None:
|
||||
return 0
|
||||
val = client.raw.get(client.key(WSHOTO_LAST_SEQ, scope))
|
||||
if val is None:
|
||||
return 0
|
||||
try:
|
||||
return int(val)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def save_last_seq(scope: str, seq: int, *, redis: Optional[RedisClient] = None) -> None:
|
||||
"""推进测服自己的查询游标。seq 必须单调不减。"""
|
||||
if seq <= 0:
|
||||
return
|
||||
client = _client(redis)
|
||||
if client is None:
|
||||
return
|
||||
prev = load_last_seq(scope, redis=client)
|
||||
if seq < prev:
|
||||
return
|
||||
client.raw.set(client.key(WSHOTO_LAST_SEQ, scope), str(int(seq)))
|
||||
Reference in New Issue
Block a user