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

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
+52 -18
View File
@@ -296,29 +296,22 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertEqual(phase, "wait_deal")
self.assertIn("Excel", "\n".join(self.replies))
self.assertIn("咨询成交状态", "\n".join(self.replies))
deal_cards = [
x["template_card"]
for x in self.extras
if x.get("msgtype") == "template_card"
and str((x.get("template_card") or {}).get("task_id") or "").startswith("deal-")
]
self.assertTrue(deal_cards)
deal_rows = {r["keyname"]: r["value"] for r in deal_cards[-1]["horizontal_content_list"]}
file_extras = [x for x in self.extras if x.get("msgtype") == "file"]
self.assertTrue(file_extras)
follow = file_extras[-1].get("followup_card") or {}
self.assertTrue(str(follow.get("task_id") or "").startswith("deal-"))
deal_rows = {r["keyname"]: r["value"] for r in follow.get("horizontal_content_list") or []}
self.assertIn("总销售价合计", deal_rows)
self.assertNotIn("历史报价", deal_rows)
self.assertNotIn("工单状态", deal_rows)
self.assertNotIn("来源", deal_rows)
file_idx = next(
i for i, x in enumerate(self.extras) if x.get("msgtype") == "file"
)
deal_idx = next(
i
for i, x in enumerate(self.extras)
separate_deal = [
x
for x in self.extras
if x.get("msgtype") == "template_card"
and str((x.get("template_card") or {}).get("task_id") or "").startswith("deal-")
)
self.assertLess(file_idx, deal_idx)
]
self.assertEqual(separate_deal, [])
phase = self.flow.on_button(
sender_id="u1",
action=copy.BTN_DEAL,
@@ -1268,7 +1261,7 @@ class AirTextInquiryTests(unittest.TestCase):
source="test",
)
self.assertEqual(replay, "wait_deal")
self.assertIn("咨询成交状态", "\n".join(again))
self.assertNotIn("咨询成交状态", "\n".join(again))
self.assertEqual(http.session_of("u-pdf-x").phase, "wait_deal")
def test_deal_click_works_while_http_still_wait_file(self) -> None:
@@ -1315,3 +1308,44 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertEqual(phase, "done")
self.assertEqual(flow.session_of("u-pdf-d").phase, "done")
self.assertTrue(any(x.get("msgtype") == "update_template_card" for x in extras))
class FileThenDealOrderTests(unittest.TestCase):
def test_send_outbound_sends_file_before_followup_card(self) -> None:
"""同一条出站必须先传文件再发成交卡。"""
from agent.channel.outbox.sender import WeComAppClient, WeComSendResult
order: list[str] = []
class _Client(WeComAppClient):
def send_file(self, **kwargs): # type: ignore[override]
order.append("file")
return WeComSendResult(ok=True)
def _post_message(self, body, preview=""): # type: ignore[override]
order.append(str(preview or body.get("msgtype")))
return WeComSendResult(ok=True)
client = _Client(corp_id="c", secret="s", agent_id=1)
client._followup_gap_sec = 0
client.send_outbound(
touser="u1",
content="file+deal",
payload={
"msgtype": "file",
"filename": "WO1.xlsx",
"file_b64": "QQ==",
"followup_card": {"task_id": "deal-WO1"},
},
)
self.assertEqual(order, ["file", "[followup_card]"])
def test_wait_outbound_stays_until_claimed_item_finishes(self) -> None:
"""已被认领的出站必须等到发完,不能中途当成功。"""
store = MemoryMessageStore()
ok, oid = store.enqueue_outbound(touser="u1", content="报价文件")
self.assertTrue(ok)
claimed = store.claim_outbound()
self.assertIsNotNone(claimed)
done = store.wait_outbound_done(oid, timeout_sec=0.45)
self.assertFalse(done)
+1 -1
View File
@@ -215,7 +215,7 @@ class ContinueTicketTests(unittest.TestCase):
sender_id="u6", work_order_no=wo, reply=reply
)
self.assertEqual(phase, "wait_deal")
self.assertTrue(any("成交" in x or copy.BTN_DEAL in x for x in replies))
self.assertFalse(any("成交" in x or copy.BTN_DEAL in x for x in replies))
def test_resume_missing_wo_asks(self) -> None:
flow = AirTextInquiryFlow(MemoryLedger())
+448 -11
View File
@@ -62,8 +62,29 @@ class FakeGroupClient:
self.created.append({"name": "", "userids": list(userids), "chat_id": chat_id, "add": True})
return {"ok": True}
def send_group(self, *, chat_id: str, content: str) -> dict:
self.sent.append({"chat_id": chat_id, "content": content})
def send_group(self, *, chat_id: str, content: str, mention_userids: list | None = None) -> dict:
self.sent.append(
{
"chat_id": chat_id,
"content": content,
"kind": "text",
"mention_userids": list(mention_userids or []),
}
)
return {"ok": True}
def send_group_file(self, *, chat_id: str, filename: str, filepath: str = "", file_b64: str = "") -> dict:
self.sent.append(
{"chat_id": chat_id, "filename": filename, "kind": "file", "file_b64": file_b64}
)
return {"ok": True}
def send_group_card(self, *, chat_id: str, template_card: dict) -> dict:
self.sent.append({"chat_id": chat_id, "kind": "card", "template_card": template_card})
return {"ok": True}
def dismiss_group(self, *, chat_id: str, userids: list) -> dict:
self.sent.append({"chat_id": chat_id, "kind": "dismiss", "userids": list(userids)})
return {"ok": True}
@@ -213,21 +234,40 @@ class SeaTmsCardTests(unittest.TestCase):
],
}
text = copy.sea_quote_detail_text(
work_order_no="WO202609150029", quote=long_quote
work_order_no="WO202609150030", quote=long_quote
)
self.assertIn("海运费:USD 100.00", text)
self.assertIn("报关费:CNY 250.00", text)
self.assertIn("总销售价合计:CNY 2287.65", text)
self.assertEqual(
text,
"\n".join(
[
"TMS标准报价",
"WO202609150030价格明细",
"海运费:USD 100.00",
"燃油附加费:USD 15.00",
"操作费:CNY 680.00",
"文件费:CNY 450.00",
"封条费(SEAI):CNY 50.00",
"设备费:CNY 50.00",
"报关费:CNY 250.00",
"总销售价合计:CNY 2287.65",
]
),
)
self.assertNotIn("时效", text)
self.assertNotIn("报价有效期", text)
card_text = copy.quote_area_safe_text(long_quote)
self.assertIn("总销售价合计:CNY 2287.65", card_text)
self.assertNotIn("海运费:USD 100.00\n燃油附加费", card_text)
self.assertLessEqual(len(card_text), 80)
payload = copy.sea_tms_hit_wecom_payload(
work_order_no="WO202609150029", quote=long_quote
work_order_no="WO202609150030", quote=long_quote
)
self.assertEqual(
payload["template_card"]["quote_area"]["quote_text"], card_text
)
sub = payload["template_card"]["sub_title_text"]
self.assertIn("时效:1天", sub)
self.assertIn("报价有效期:2026-09-30", sub)
def test_sea_hit_card_has_price_and_two_buttons(self) -> None:
phase = self.flow.on_text(
@@ -393,9 +433,12 @@ class SeaSkipAndPullTests(unittest.TestCase):
self.assertNotIn("AI001", self.groups.created[0]["userids"])
self.assertTrue(self.groups.sent)
group_text = self.groups.sent[0]["content"]
self.assertTrue(any(x.get("kind") == "file" for x in self.groups.sent))
self.assertIn(wo, group_text)
self.assertIn("起运港", group_text)
self.assertIn("贸易条款", group_text)
self.assertIn("商品海关编码", group_text)
self.assertNotIn("HS编码", group_text)
self.assertIn("TMS", group_text)
sess = self.flow.session_of("s1")
self.assertNotIn(copy.BTN_PULL_COLLAB, sess.allowed)
@@ -405,6 +448,40 @@ class SeaSkipAndPullTests(unittest.TestCase):
)
self.assertEqual(again, "card_discard")
def test_pull_sends_sheet_when_client_not_injected(self) -> None:
"""测服正式路径不注入 group_client,拉群仍要发出填好的模板。"""
from unittest.mock import patch
live = FakeGroupClient()
self.ledger.set_sea_staff(
[
{
"name": "张产品",
"wecomId": "prod1",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
}
]
)
with patch("agent.policy.sea_text_flow._default_group_client", return_value=live):
flow = SeaTextInquiryFlow(self.ledger, group_client=None)
flow.on_text(
sender_id="s9",
text="海运有价",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
phase = flow.on_button(
sender_id="s9",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
self.assertEqual(phase, "collab_group")
self.assertTrue(any(x.get("kind") == "text" for x in live.sent))
self.assertTrue(any(x.get("kind") == "file" for x in live.sent))
def test_pull_group_includes_archive_bot(self) -> None:
self.ledger.upsert_staff(
{
@@ -509,12 +586,27 @@ class SeaSkipAndPullTests(unittest.TestCase):
reply=self._reply,
)
self.assertTrue(self.groups.sent)
self.assertIn("暂无匹配报价", self.groups.sent[0]["content"])
group_text = self.groups.sent[0]["content"]
self.assertIn("TMS 暂无匹配报价", group_text)
self.assertNotIn("未查到合适报价", group_text)
self.assertIn("\n\n协同补充字段:\n", group_text)
self.assertIn("\n\nTMS报价:\n", group_text)
class SeaGroupCollabFieldTests(unittest.TestCase):
def setUp(self) -> None:
self.ledger = MemoryLedger()
self.ledger.set_quote_templates(
[
{
"templateId": "mem-default-sea",
"name": "海运默认",
"bizType": "海运",
"keywords": [],
"isDefault": True,
}
]
)
self.groups = FakeGroupClient()
self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups)
self.replies: list[str] = []
@@ -602,8 +694,10 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "group_ignore")
self.assertFalse(ticket.quote_version > 1 and "1200" in str(ticket.quote))
self.assertEqual(phase, "product_quote_sent")
self.assertEqual(str(ticket.quote.get("total") or ""), "1200")
deal_texts = [x["content"] for x in self.groups.sent if x.get("kind") == "text" and "成交" in (x.get("content") or "")]
self.assertTrue(deal_texts)
class SeaContinueTests(unittest.TestCase):
@@ -761,6 +855,17 @@ class SeaContinueTests(unittest.TestCase):
card_meta={"event_key": f"adopt_excel:{wo}", "response_code": "rc-xl"},
)
self.assertEqual(self.flow.session_of("c3").phase, "wait_deal")
file_extras = [x for x in self.extras if x.get("msgtype") == "file"]
self.assertTrue(file_extras)
follow = file_extras[-1].get("followup_card") or {}
self.assertTrue(str(follow.get("task_id") or "").startswith("deal-"))
separate_deal = [
x
for x in self.extras
if x.get("msgtype") == "template_card"
and str((x.get("template_card") or {}).get("task_id") or "").startswith("deal-")
]
self.assertEqual(separate_deal, [])
empty = SeaTextInquiryFlow(self.ledger)
store = MemoryMessageStore()
msg = InboundMessage(
@@ -777,11 +882,37 @@ class SeaContinueTests(unittest.TestCase):
phase = handle_continue_thread(msg, thread_id=wo, store=store)
self.assertEqual(phase, "wait_deal")
joined = "\n".join(item.content for item in store._outbox.values())
self.assertTrue(any(x in joined for x in (copy.BTN_DEAL, "成交")))
self.assertFalse(any(x in joined for x in (copy.BTN_DEAL, "成交")))
self.assertNotIn(copy.BTN_SKIP_COLLAB, joined)
class SeaGroupCopyTests(unittest.TestCase):
def test_group_brief_uses_customs_code_and_section_breaks(self) -> None:
text = copy.sea_group_brief(
work_order_no="WO202609150023",
facts={
"起运港": "纽约港",
"目的港": "巴生西港",
"品名": "机器设备",
"货量": "15",
"整柜或拼柜": "整柜",
"箱型箱量": "40HQ*1",
"报价日期": "2026-09-15",
},
quote=None,
collab_facts={},
sales_name="吴季林",
product_names=["罗小华"],
has_price=False,
)
self.assertIn("@吴季林 @罗小华", text)
self.assertIn("询价字段:\n运输方式:海运", text)
self.assertIn("\n\n协同补充字段:\n", text)
self.assertIn("商品海关编码:-", text)
self.assertNotIn("HS编码", text)
self.assertIn("\n\nTMS报价:\nTMS 暂无匹配报价", text)
self.assertNotIn("未查到合适报价", text)
def test_scheme_card_uses_sea_fields(self) -> None:
facts = {
"起运港": "上海",
@@ -831,6 +962,312 @@ class SeaGroupCopyTests(unittest.TestCase):
"已邀请:销售吴季林、产品罗小华。请在群聊中继续确认报价。",
)
def test_group_only_excel_pdf_copy(self) -> None:
self.assertEqual(
copy.group_only_excel_pdf(),
"群里只收 Excel 和 PDF,其它文件不会存入工单",
)
class SeaGroupOpsTests(unittest.TestCase):
"""群协同:附件类型、产品报价、解散后再拉、终态提醒。"""
def setUp(self) -> None:
self.ledger = MemoryLedger()
self.ledger.set_quote_templates(
[
{
"templateId": "mem-default-sea",
"name": "海运默认",
"bizType": "海运",
"keywords": [],
"isDefault": True,
}
]
)
self.ledger.set_sea_staff(
[
{
"name": "赵产品",
"wecomId": "prod4",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
}
]
)
self.ledger.upsert_staff({"name": "吴吉林", "wecomId": "sales1"})
self.groups = FakeGroupClient()
self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups)
self.replies: list[str] = []
def _reply(self, text: str, extra=None) -> None:
self.replies.append(text)
def _open_group(self) -> tuple[str, str]:
self.flow.on_text(
sender_id="sales1",
text="海运有价",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.flow.on_button(
sender_id="sales1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
sess = self.flow.session_of("sales1")
return sess.work_order_no, sess.collab_chat_id
def test_group_rejects_non_excel_pdf(self) -> None:
wo, chat = self._open_group()
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="img1",
content="",
msg_type="image",
chat_id=chat,
chat_type="group",
media={"filename": "现场.jpg", "media_id": "mid-1"},
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "file_type_rejected")
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
self.assertIn("群里只收 Excel 和 PDF,其它文件不会存入工单", texts)
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertFalse(ticket.attachments)
def test_sales_excel_is_archived_not_quote(self) -> None:
wo, chat = self._open_group()
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="xls1",
content="",
msg_type="file",
chat_id=chat,
chat_type="group",
media={"filename": "对账单.xlsx", "media_id": "mid-2"},
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "file_archived")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.attachments[0]["file_name"], "对账单.xlsx")
self.assertNotEqual(ticket.quote.get("source_label"), "产品附件报价")
def test_product_excel_quotes_and_deal(self) -> None:
wo, chat = self._open_group()
phase = handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="xls2",
content="",
msg_type="file",
chat_id=chat,
chat_type="group",
media={"filename": "产品报价.pdf", "media_id": "mid-3"},
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "product_file_quote")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.attachments[0]["file_name"], "产品报价.pdf")
self.assertEqual(ticket.status, "已报价")
self.assertTrue(any(x.get("kind") == "card" for x in self.groups.sent))
def test_terminal_file_speaks(self) -> None:
wo, chat = self._open_group()
self.ledger.transition(work_order_no=wo, to_status="已成交")
sess = self.flow.session_of("sales1")
sess.status = "已成交"
phase = handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="xls3",
content="",
msg_type="file",
chat_id=chat,
chat_type="group",
media={"filename": "晚到.xlsx", "media_id": "mid-4"},
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "ticket_locked_file")
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
self.assertTrue(any("不能再上传附件" in t for t in texts))
self.assertFalse(self.ledger.get_ticket(work_order_no=wo).attachments)
def test_dissolve_then_cannot_recreate(self) -> None:
wo, chat = self._open_group()
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="dis1",
content="这个群解散吧",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
injected_intent="dissolve",
)
self.assertEqual(phase, "group_dissolved")
self.assertTrue(any(x.get("kind") == "dismiss" for x in self.groups.sent))
self.assertTrue(self.ledger.get_ticket(work_order_no=wo).collab_ended)
sess = self.flow.session_of("sales1")
sess.allowed = (copy.BTN_PULL_COLLAB,)
again = self.flow.on_button(
sender_id="sales1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
self.assertEqual(again, "collab_ended")
self.assertIn("不能再拉群", "\n".join(self.replies))
def test_group_oral_keeps_inquiry_facts_when_snapshot_skinny(self) -> None:
wo, chat = self._open_group()
real_find = self.ledger.find_by_collab_chat
def skinny(*, chat_id: str):
ticket = real_find(chat_id=chat_id)
if ticket is None:
return None
return {
"found": True,
"work_order_no": ticket.work_order_no,
"sales_wecom_id": ticket.sales_wecom_id,
"collab_chat_id": ticket.collab_chat_id,
"collab_member_ids": list(ticket.collab_member_ids),
"collab_facts": dict(ticket.collab_facts),
}
self.ledger.find_by_collab_chat = skinny # type: ignore[method-assign]
self.flow._by_ticket.clear()
self.flow._by_sender.clear()
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="oral-keep-facts-1",
content="不含电、不含油、随时可提",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
injected_intent="collab_fields",
)
self.assertEqual(phase, "collab_field_saved")
texts = "\n".join(x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text")
self.assertIn("上海", texts)
self.assertIn("洛杉矶", texts)
self.assertIn("随时可提", texts)
self.assertNotRegex(texts, r"起运港:\s*-")
self.assertIn("@吴吉林 @赵产品", texts)
self.assertNotRegex(texts, r"^@销售(\s|$)")
briefs = [
x
for x in self.groups.sent
if x.get("kind") == "text" and "询价字段" in (x.get("content") or "")
]
self.assertTrue(briefs)
self.assertIn("sales1", briefs[-1].get("mention_userids") or [])
self.assertIn("prod4", briefs[-1].get("mention_userids") or [])
self.assertNotIn("AI001", briefs[-1].get("mention_userids") or [])
def test_group_oral_oil_power_ready_is_saved(self) -> None:
wo, chat = self._open_group()
n = len([x for x in self.groups.sent if x.get("kind") == "text"])
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="oral-collab-1",
content="不含电、不含油、随时可提",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
injected_intent="collab_fields",
)
self.assertEqual(phase, "collab_field_saved")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.collab_facts.get("是否含电"), "否")
self.assertEqual(ticket.collab_facts.get("是否含油"), "否")
self.assertEqual(ticket.collab_facts.get("货好时间"), "随时可提")
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
self.assertGreater(len(texts), n)
def test_collab_facts_are_projected_for_quote_sheet(self) -> None:
from agent.policy.quote_overlay import overlay_labeled_facts
from agent.policy.sea_group_ops import _facts_for_fill
from openpyxl import Workbook, load_workbook
filled = _facts_for_fill(
{"起运港": "DONGGUAN", "目的港": "海防"},
{"货好时间": "随时可提", "是否含油": "否", "是否含电": "否", "HS编码": "8479"},
)
self.assertEqual(filled["cargo_ready_date"], "随时可提")
self.assertEqual(filled["hs_code"], "8479")
self.assertIn("油:否", filled["oil_elec_mag"])
self.assertIn("电:否", filled["oil_elec_mag"])
import tempfile
from pathlib import Path
wb = Workbook()
ws = wb.active
ws["A1"] = "*货好时间/Cargo Ready Date:"
ws["A2"] = "*商品海关编码/HS Code:"
ws["A3"] = "*是否含油,电,磁/OIL,ELEC,MAG:"
path = str(Path(tempfile.mkdtemp(prefix="collab-sheet-")) / "src.xlsx")
wb.save(path)
out = overlay_labeled_facts(filepath=path, facts=filled)
painted = load_workbook(out["filepath"])
sheet = painted.active
self.assertEqual(sheet["B1"].value, "随时可提")
self.assertEqual(sheet["B2"].value, "8479")
self.assertIn("油:否", str(sheet["B3"].value or ""))
def test_stale_deal_card_speaks(self) -> None:
from agent.policy.sea_group_ops import explain_deal_click
self._open_group()
sess = self.flow.session_of("sales1")
sess.deal_version = 2
sess.collab_chat_id = sess.collab_chat_id or "wr_sea_1"
reason = explain_deal_click(
flow=self.flow,
sender_id="sales1",
action=copy.BTN_DEAL,
card_meta={"event_key": f"deal:{sess.work_order_no}:v1"},
sess=sess,
)
self.assertEqual(reason, "stale_deal")
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
self.assertTrue(any("已过期" in t for t in texts))
def test_parse_group_file_fills_media(self) -> None:
from agent.channel.wecom.parse import parse_plain_xml
xml = (
"<xml><FromUserName>prod4</FromUserName><CreateTime>1</CreateTime>"
"<MsgType>file</MsgType><MediaId>mid-x</MediaId>"
"<FileName>报价.xlsx</FileName><ChatId>wr_sea_1</ChatId></xml>"
)
msg = parse_plain_xml(xml)
self.assertEqual(msg.chat_type, "group")
self.assertEqual(msg.chat_id, "wr_sea_1")
self.assertEqual(msg.media.get("filename"), "报价.xlsx")
self.assertEqual(msg.media.get("media_id"), "mid-x")
if __name__ == "__main__":
unittest.main()
+325
View File
@@ -0,0 +1,325 @@
"""
微盛会话存档:解析、跳过存档席、测服游标、入 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 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_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)
self.assertEqual(call.get("msg_time_start"), 1_800_000_000 - 180)
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_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")
if __name__ == "__main__":
unittest.main()