Files
inquiry_robot/inquiry-agent/tests/test_wshoto_archive.py
T

473 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
微盛会话存档:解析、跳过存档席、测服游标、入 inbox。
不连微盛外网、不连现网 Redis。
"""
from __future__ import annotations
import os
import sys
import unittest
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
os.environ.setdefault("YTD_ENV", "test")
os.environ.setdefault("REDIS_BACKEND", "memory")
os.environ.setdefault("CHECKPOINT_BACKEND", "memory")
os.environ.setdefault("MESSAGE_STORE_BACKEND", "memory")
os.environ.setdefault("LLM_DATA_USAGE_CONFIRMED", "false")
os.environ.setdefault("LLM_ALLOW_NETWORK", "false")
os.environ.setdefault("LEDGER_BACKEND", "memory")
from agent.channel.queue import MemoryMessageStore
from agent.channel.wshoto.client import WshotoError, wshoto_ready
from agent.channel.wshoto.parse import archive_record_to_inbound
from agent.channel.wshoto.rooms import (
list_watched_rooms,
load_last_seq,
save_last_seq,
unwatch_collab_room,
watch_collab_room,
)
from agent.channel.wshoto.poll import ingest_records, poll_once
from agent.redis_coord.client import MemoryRedis, RedisClient
def _redis() -> RedisClient:
return RedisClient(raw=MemoryRedis(), key_prefix="inquiry_robot:", backend="memory")
class _Cfg:
wshoto_poll_enabled = True
wshoto_app_id = "app"
wshoto_app_secret = "secret"
wecom_archive_seat_user_ids = "AI001"
wshoto_bootstrap_lookback_sec = 3600
wshoto_page_size = 50
class _FakeApi:
def __init__(self, records: list[dict], *, error: WshotoError | None = None) -> None:
self.records = records
self.error = error
self.calls: list[dict] = []
def list_group_messages(self, **kwargs):
self.calls.append(kwargs)
if self.error is not None:
raise self.error
last = int(kwargs.get("last_seq") or 0)
return [r for r in self.records if int(r.get("seq") or 0) > last]
class WshotoParseTests(unittest.TestCase):
def test_member_text_becomes_group_inbound(self) -> None:
msg = archive_record_to_inbound(
{
"msgid": "m1",
"seq": 10,
"from": "WuJiLin",
"room_id": "wr_room_1",
"msgtype": "text",
"content": "运费 1200",
"msgtime": 1_700_000_000_000,
},
seat_userids=["AI001"],
)
self.assertIsNotNone(msg)
assert msg is not None
self.assertEqual(msg.sender_id, "WuJiLin")
self.assertEqual(msg.chat_id, "wr_room_1")
self.assertEqual(msg.chat_type, "group")
self.assertEqual(msg.content, "运费 1200")
self.assertEqual(msg.message_id, "wshoto:m1")
self.assertEqual(msg.create_time, 1_700_000_000)
def test_official_text_content_beats_short_preview(self) -> None:
"""管家 content 常是摘要或空,正文在企微官方 text.content。必须取更长的那份。"""
msg = archive_record_to_inbound(
{
"msgid": "m1b",
"seq": 11,
"from": "LuoXiaoHua",
"room_id": "wr_room_1",
"msgtype": "text",
"content": "码头操作费",
"msg_content": {
"text": {"content": "码头操作费100CNY\n报关费改为300CNY"}
},
},
seat_userids=["AI001"],
)
self.assertIsNotNone(msg)
assert msg is not None
self.assertEqual(msg.content, "码头操作费100CNY\n报关费改为300CNY")
def test_skip_archive_seat_and_display_name(self) -> None:
self.assertIsNone(
archive_record_to_inbound(
{
"msgid": "m2",
"from": "AI001",
"room_id": "wr_room_1",
"msgtype": "text",
"content": "群简介",
},
seat_userids=["AI001"],
)
)
self.assertIsNone(
archive_record_to_inbound(
{
"msgid": "m3",
"from": "询价机器人",
"room_id": "wr_room_1",
"msgtype": "text",
"content": "系统话术",
},
seat_userids=["AI001"],
)
)
def test_file_keeps_filename(self) -> None:
msg = archive_record_to_inbound(
{
"msgid": "m4",
"from": "LuoXiaoHua",
"room_id": "wr_room_1",
"msgtype": "file",
"content": "",
"msg_content": {"filename": "报价.xlsx", "sdkfileid": "sdk-1"},
},
seat_userids=["AI001"],
)
self.assertIsNotNone(msg)
assert msg is not None
self.assertEqual(msg.msg_type, "file")
self.assertEqual(msg.media.get("filename"), "报价.xlsx")
self.assertEqual(msg.media.get("media_id"), "sdk-1")
def test_skip_revoke_and_missing_room(self) -> None:
self.assertIsNone(
archive_record_to_inbound(
{"msgid": "m5", "from": "WuJiLin", "room_id": "", "msgtype": "text", "content": "x"},
seat_userids=["AI001"],
)
)
self.assertIsNone(
archive_record_to_inbound(
{
"msgid": "m6",
"from": "WuJiLin",
"room_id": "wr_room_1",
"msgtype": "revoke",
"content": "x",
},
seat_userids=["AI001"],
)
)
class WshotoPollTests(unittest.TestCase):
def test_ready_needs_keys(self) -> None:
cfg = _Cfg()
self.assertTrue(wshoto_ready(cfg))
cfg.wshoto_app_secret = ""
self.assertFalse(wshoto_ready(cfg))
def test_watch_rooms_and_cursor(self) -> None:
redis = _redis()
watch_collab_room("wr_a", redis=redis)
watch_collab_room("wr_b", redis=redis)
self.assertEqual(list_watched_rooms(redis=redis), ["wr_a", "wr_b"])
unwatch_collab_room("wr_a", redis=redis)
self.assertEqual(list_watched_rooms(redis=redis), ["wr_b"])
save_last_seq("seat:AI001", 88, redis=redis)
self.assertEqual(load_last_seq("seat:AI001", redis=redis), 88)
watch_collab_room("wr_c", work_order_no="WO1", redis=redis)
from agent.channel.wshoto.rooms import ticket_no_for_room
self.assertEqual(ticket_no_for_room("wr_c", redis=redis), "WO1")
def test_watch_room_enters_hot_window(self) -> None:
from agent.channel.wshoto.rooms import has_hot_room, wait_wshoto_poll
redis = _redis()
watch_collab_room("wr_hot", redis=redis)
self.assertTrue(has_hot_room())
self.assertTrue(wait_wshoto_poll(0.2))
def test_new_room_lookback_is_capped(self) -> None:
store = MemoryMessageStore()
redis = _redis()
watch_collab_room("wr_room_1", redis=redis)
api = _FakeApi(
[
{
"msgid": "fresh",
"seq": 7,
"from": "WuJiLin",
"room_id": "wr_room_1",
"msgtype": "text",
"content": "随时可提",
}
]
)
out = poll_once(
client=api,
settings=_Cfg(),
redis=redis,
store=store,
now=lambda: 1_800_000_000,
)
self.assertTrue(out["ok"])
call = api.calls[0]
self.assertEqual(call.get("msg_time_end"), 1_800_000_000)
# 新群回看上限见 poll._NEW_ROOM_LOOKBACK_SEC(3600),须盖过存档延迟。
self.assertEqual(call.get("msg_time_start"), 1_800_000_000 - 3600)
def test_poll_enqueues_member_skips_seat_and_advances_seq(self) -> None:
store = MemoryMessageStore()
redis = _redis()
api = _FakeApi(
[
{
"msgid": "old",
"seq": 10,
"from": "WuJiLin",
"room_id": "wr_room_1",
"msgtype": "text",
"content": "旧消息",
},
{
"msgid": "seat",
"seq": 11,
"from": "AI001",
"room_id": "wr_room_1",
"msgtype": "text",
"content": "机器人自己说的",
},
{
"msgid": "new",
"seq": 12,
"from": "WuJiLin",
"room_id": "wr_room_1",
"msgtype": "text",
"content": "运费 1500",
},
]
)
save_last_seq("seat:AI001", 10, redis=redis)
out = poll_once(client=api, settings=_Cfg(), redis=redis, store=store)
self.assertTrue(out["ok"])
self.assertEqual(out["enqueued"], 1)
self.assertEqual(out["last_seq"], 12)
self.assertEqual(load_last_seq("seat:AI001", redis=redis), 12)
item = store.claim_inbound()
self.assertIsNotNone(item)
assert item is not None
self.assertEqual(item.message.content, "运费 1500")
self.assertEqual(item.message.chat_type, "group")
def test_first_poll_uses_time_window_not_seq_zero(self) -> None:
store = MemoryMessageStore()
redis = _redis()
api = _FakeApi(
[
{
"msgid": "recent",
"seq": 5,
"from": "WuJiLin",
"room_id": "wr_room_1",
"msgtype": "text",
"content": "刚说的",
}
]
)
out = poll_once(
client=api,
settings=_Cfg(),
redis=redis,
store=store,
now=lambda: 1_800_000_000,
)
self.assertTrue(out["ok"])
self.assertEqual(len(api.calls), 1)
call = api.calls[0]
self.assertEqual(call.get("last_seq"), 0)
self.assertGreater(call.get("msg_time_start") or 0, 0)
self.assertEqual(call.get("msg_time_end"), 1_800_000_000)
self.assertEqual(out["enqueued"], 1)
def test_ingest_dedupes_message_id(self) -> None:
store = MemoryMessageStore()
rec = {
"msgid": "same",
"seq": 3,
"from": "WuJiLin",
"room_id": "wr_room_1",
"msgtype": "text",
"content": "重复",
}
a, _ = ingest_records([rec], seat_userids=["AI001"], store=store)
b, skipped = ingest_records([rec], seat_userids=["AI001"], store=store)
self.assertEqual(a, 1)
self.assertEqual(b, 0)
self.assertEqual(skipped, 1)
def test_room_seq_gap_backfill_keeps_dissolve(self) -> None:
"""
现网事故:增量只先到「撤回」高 seq,游标一步跳到 9899,
中间的「解散群」再也拉不回来。跳号时必须时间窗补洞再推进游标。
"""
store = MemoryMessageStore()
redis = _redis()
room = "wr_gap_dissolve"
watch_collab_room(room, redis=redis)
save_last_seq(f"room:{room}", 9892, redis=redis)
all_rows = [
{
"msgid": "m9897",
"seq": 9897,
"from": "ShiChangChang",
"room_id": room,
"msgtype": "text",
"content": "随便说一句",
"msgtime": 1_792_546_051_000,
},
{
"msgid": "m9898",
"seq": 9898,
"from": "ShiChangChang",
"room_id": room,
"msgtype": "text",
"content": "解散群",
"msgtime": 1_792_546_055_000,
},
{
"msgid": "m9899",
"seq": 9899,
"from": "ShiChangChang",
"room_id": room,
"msgtype": "revoke",
"content": "[撤回消息]",
"msgtime": 1_792_546_059_000,
},
]
class _GapApi:
def __init__(self) -> None:
self.calls: list[dict] = []
def list_group_messages(self, **kwargs):
self.calls.append(dict(kwargs))
last = int(kwargs.get("last_seq") or 0)
time_start = int(kwargs.get("msg_time_start") or 0)
# 模拟管家增量晚到:先只吐撤回;时间窗才能看到中间「解散群」。
if time_start > 0:
return [r for r in all_rows if int(r["seq"]) > last]
if last > 0:
return [r for r in all_rows if int(r["seq"]) == 9899 and int(r["seq"]) > last]
return list(all_rows)
cfg = _Cfg()
cfg.wshoto_room_gap_ms = 0
out = poll_once(
client=_GapApi(),
settings=cfg,
redis=redis,
store=store,
now=lambda: 1_792_546_100,
)
self.assertTrue(out["ok"])
self.assertGreaterEqual(out["enqueued"], 1)
self.assertEqual(load_last_seq(f"room:{room}", redis=redis), 9899)
claimed = []
while True:
item = store.claim_inbound()
if item is None:
break
claimed.append(item.message.content)
self.assertIn("解散群", claimed)
def test_watched_rooms_preferred_without_seat_userid(self) -> None:
store = MemoryMessageStore()
redis = _redis()
watch_collab_room("wr_room_1", redis=redis)
api = _FakeApi(
[
{
"msgid": "room-msg",
"seq": 21,
"from": "LuoXiaoHua",
"room_id": "wr_room_1",
"msgtype": "text",
"content": "20GP 1",
}
]
)
out = poll_once(client=api, settings=_Cfg(), redis=redis, store=store)
self.assertTrue(out["ok"])
self.assertEqual(out["mode"], "rooms")
self.assertEqual(out["enqueued"], 1)
self.assertEqual(api.calls[0].get("userid"), "")
self.assertEqual(api.calls[0].get("target_id"), "wr_room_1")
def test_hot_room_polled_before_cold_and_rate_limit_stops(self) -> None:
"""热群优先;遇 10061 本轮停;游标只推进已扫冷群,不跳过未扫到的。"""
from agent.channel.wshoto import rooms as rooms_mod
from agent.channel.wshoto.rooms import (
load_poll_cursor,
mark_room_hot,
plan_rooms_for_poll,
)
redis = _redis()
for i in range(6):
watch_collab_room(f"wr_cold_{i}", redis=redis)
watch_collab_room("wr_hot_now", redis=redis)
# watch 会标热;单测只留目标热群,模拟「大量冷群 + 一个刚说话的群」。
with rooms_mod._hot_lock:
rooms_mod._hot_until.clear()
mark_room_hot("wr_hot_now")
planned = plan_rooms_for_poll(
list_watched_rooms(redis=redis),
max_cold=2,
redis=redis,
)
self.assertEqual(planned[0], "wr_hot_now")
self.assertLessEqual(len(planned), 3)
# 规划阶段不得推进游标。
self.assertEqual(load_poll_cursor(redis=redis), 0)
class _RateApi:
def __init__(self) -> None:
self.calls: list[dict] = []
def list_group_messages(self, **kwargs):
self.calls.append(kwargs)
if len(self.calls) >= 2:
raise WshotoError(10061, "请求频率过高")
return []
store = MemoryMessageStore()
api = _RateApi()
cfg = _Cfg()
cfg.wshoto_rooms_per_poll = 2
cfg.wshoto_room_gap_ms = 0
cfg.wshoto_poll_budget_sec = 30
out = poll_once(client=api, settings=cfg, redis=redis, store=store)
self.assertTrue(out["ok"])
self.assertEqual(api.calls[0].get("target_id"), "wr_hot_now")
self.assertLessEqual(len(api.calls), 2)
# 热群不算冷游标;限频停在第 1 个冷群之前或之上时,冷游标推进应 < 计划冷群数。
self.assertLessEqual(load_poll_cursor(redis=redis), 1)
if __name__ == "__main__":
unittest.main()