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

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 发送文本应用消息。
+1 -1
View File
@@ -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:
+10 -1
View File
@@ -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)]
+113
View File
@@ -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 "",
},
)
+246
View File
@@ -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
+177
View File
@@ -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)))