机器人显示名与站点标题可配;多段联运列表投影;协同群监听对账;工单附件落存;模板与工单后台补强
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -22,12 +22,14 @@ _TTL_SEC = 3 * 60
|
||||
|
||||
|
||||
def _norm_text(text: str) -> str:
|
||||
body = (text or "").replace("@询价小助手", "").replace("询价小助手", "")
|
||||
from agent.policy.bot_name import strip_bot_mention
|
||||
|
||||
body = strip_bot_mention(text or "")
|
||||
return "".join(body.split())
|
||||
|
||||
|
||||
def text_fingerprint(chat_id: str, sender_id: str, text: str) -> str:
|
||||
"""同一群同一人同一句(去掉 @询价小助手)。"""
|
||||
"""同一群同一人同一句(去掉 @机器人显示名,含旧名)。"""
|
||||
return f"{(chat_id or '').strip()}|{(sender_id or '').strip()}|{_norm_text(text)}"
|
||||
|
||||
|
||||
@@ -56,6 +58,19 @@ def _redis_mark(fp: str) -> None:
|
||||
logger.exception("空运群入站去重写 Redis 失败")
|
||||
|
||||
|
||||
def _redis_claim(fp: str) -> Optional[bool]:
|
||||
"""
|
||||
Redis SET NX 抢占。True=抢到;False=已被占;None=Redis 不可用。
|
||||
"""
|
||||
try:
|
||||
client = get_redis_runtime().client
|
||||
ok = client.raw.set(client.key(AIR_INBOUND_TEXT, fp), "1", nx=True, ex=_TTL_SEC)
|
||||
return bool(ok)
|
||||
except Exception:
|
||||
logger.exception("空运群入站去重抢占 Redis 失败")
|
||||
return None
|
||||
|
||||
|
||||
def _file_fresh(fp: str) -> bool:
|
||||
path = _file_path(fp)
|
||||
try:
|
||||
@@ -74,6 +89,27 @@ def _file_mark(fp: str) -> None:
|
||||
logger.exception("空运群入站去重写文件失败")
|
||||
|
||||
|
||||
def _file_claim(fp: str) -> bool:
|
||||
"""文件独占创建;已存在且未过期则失败。"""
|
||||
path = _file_path(fp)
|
||||
try:
|
||||
if path.exists():
|
||||
if (time.time() - path.stat().st_mtime) < _TTL_SEC:
|
||||
return False
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
with path.open("x", encoding="ascii") as fd:
|
||||
fd.write("1")
|
||||
return True
|
||||
except FileExistsError:
|
||||
return False
|
||||
except OSError:
|
||||
logger.exception("空运群入站去重文件抢占失败")
|
||||
return True
|
||||
|
||||
|
||||
def already_handled_air_text(*, chat_id: str, sender_id: str, text: str) -> bool:
|
||||
"""这句是否刚被 BOT 办过。给存档补听用。"""
|
||||
fp = text_fingerprint(chat_id, sender_id, text)
|
||||
@@ -91,6 +127,26 @@ def mark_air_text(*, chat_id: str, sender_id: str, text: str) -> None:
|
||||
_file_mark(fp)
|
||||
|
||||
|
||||
def try_claim_air_text(*, chat_id: str, sender_id: str, text: str) -> bool:
|
||||
"""
|
||||
同一群同一人同一句只办一遍(含 BOT 桥重复推送)。
|
||||
|
||||
返回 True=本路应处理;False=已有一路在办/办过,直接跳过。
|
||||
运行证据:多段报价同一句回了两份空运单。
|
||||
"""
|
||||
fp = text_fingerprint(chat_id, sender_id, text)
|
||||
if not _norm_text(text):
|
||||
return True
|
||||
redis_ok = _redis_claim(fp)
|
||||
if redis_ok is True:
|
||||
_file_mark(fp)
|
||||
return True
|
||||
if redis_ok is False:
|
||||
return False
|
||||
# Redis 不可用时退回文件抢占。
|
||||
return _file_claim(fp)
|
||||
|
||||
|
||||
def is_archive_source(raw: Optional[dict[str, Any]]) -> bool:
|
||||
src = str((raw or {}).get("source") or "").strip().lower()
|
||||
return src in {"wshoto_archive", "wshoto", "archive"}
|
||||
@@ -116,13 +172,12 @@ def should_skip_air_text(
|
||||
raw: Optional[dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
存档补听到的 @ 句,若 BOT 已经回过就跳过。
|
||||
同一句去重:BOT 桥重复推、存档补听都跳过。
|
||||
|
||||
BOT 自己再 @ 同一工单号仍要再发一版摘要,不在这里拦。
|
||||
用 try_claim:第一路处理,后续同文案在 TTL 内不再回。
|
||||
"""
|
||||
if not is_archive_source(raw):
|
||||
return False
|
||||
if already_handled_air_text(chat_id=chat_id, sender_id=sender_id, text=text):
|
||||
logger.info("空运群存档补听跳过,BOT 已办 chat=%s", chat_id)
|
||||
_ = raw
|
||||
if not try_claim_air_text(chat_id=chat_id, sender_id=sender_id, text=text):
|
||||
logger.info("空运群入站去重跳过 chat=%s", chat_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
卡片按钮集中防抖:同一张卡同一按钮在 TTL 内只处理一次。
|
||||
|
||||
本文件职责:按 task_id/工单号 + 按钮动作抢占;HTTP/Worker 共用 Redis,
|
||||
单测无 Redis 时退回进程内字典。短协调,不当六态、不当 checkpoint。
|
||||
禁止:用 ResponseCode 当业务键(企微每次点击换新码);禁止全局锁所有工单。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from typing import Optional
|
||||
|
||||
from agent.redis_coord.keys import IDEMPOTENCY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 连点窗口:够挡住企微连点/重试,又不挡稍后改点「成交/未成交」
|
||||
_TTL_SEC = 20
|
||||
_SCOPE = "card-btn"
|
||||
_LOCAL_MAX = 2000
|
||||
|
||||
_local_lock = threading.Lock()
|
||||
_local_seen: OrderedDict[str, float] = OrderedDict()
|
||||
|
||||
|
||||
def debounce_key(
|
||||
*,
|
||||
action: str,
|
||||
task_id: str = "",
|
||||
work_order_no: str = "",
|
||||
sender_id: str = "",
|
||||
event_key: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
拼防抖业务键。优先 task_id(同卡唯一),否则工单号,再退回 event_key/sender。
|
||||
|
||||
不含 ResponseCode:每次点击码不同,不能当去重依据。
|
||||
"""
|
||||
act = (action or "").strip()
|
||||
tid = (task_id or "").strip()
|
||||
if tid and act:
|
||||
return f"{tid}|{act}"
|
||||
wo = (work_order_no or "").strip()
|
||||
if wo and act:
|
||||
return f"{wo}|{act}|{(sender_id or '').strip()}"
|
||||
ek = (event_key or "").strip()
|
||||
if ek:
|
||||
return f"{ek}|{(sender_id or '').strip()}"
|
||||
return f"{(sender_id or '').strip()}|{act}"
|
||||
|
||||
|
||||
def try_acquire(key: str, *, ttl_seconds: int = _TTL_SEC) -> bool:
|
||||
"""
|
||||
尝试占用卡片按钮防抖键。
|
||||
|
||||
返回 True=首次(可继续业务);False=TTL 内重复(应丢弃副作用与 ack)。
|
||||
Redis 不可用时用进程内字典,保证单测与本机复现脚本也能挡住连点。
|
||||
"""
|
||||
token = (key or "").strip()
|
||||
if not token:
|
||||
return True
|
||||
ttl = max(1, int(ttl_seconds or _TTL_SEC))
|
||||
redis_hit = _try_redis(token, ttl)
|
||||
if redis_hit is not None:
|
||||
return redis_hit
|
||||
return _try_local(token, ttl)
|
||||
|
||||
|
||||
def _try_redis(key: str, ttl: int) -> Optional[bool]:
|
||||
try:
|
||||
from agent.redis_coord.runtime import get_redis_runtime
|
||||
|
||||
client = get_redis_runtime().client
|
||||
full = client.key(IDEMPOTENCY, _SCOPE, key)
|
||||
ok = client.raw.set(full, "1", nx=True, ex=ttl)
|
||||
acquired = bool(ok)
|
||||
if not acquired:
|
||||
logger.info("卡片按钮防抖命中重复 key=%s", key)
|
||||
return acquired
|
||||
except Exception:
|
||||
logger.debug("卡片按钮防抖 Redis 不可用,改用进程内", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _try_local(key: str, ttl: int) -> bool:
|
||||
now = time.time()
|
||||
with _local_lock:
|
||||
stale = [k for k, ts in _local_seen.items() if now - ts >= ttl]
|
||||
for k in stale:
|
||||
_local_seen.pop(k, None)
|
||||
prev = _local_seen.get(key)
|
||||
if prev is not None and now - prev < ttl:
|
||||
logger.info("卡片按钮防抖(本地)命中重复 key=%s", key)
|
||||
return False
|
||||
_local_seen[key] = now
|
||||
while len(_local_seen) > _LOCAL_MAX:
|
||||
_local_seen.popitem(last=False)
|
||||
return True
|
||||
Reference in New Issue
Block a user