test_business_assistant.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925
  1. from __future__ import annotations
  2. import json
  3. from datetime import UTC, datetime, timedelta
  4. from types import SimpleNamespace
  5. import pytest
  6. from aiohttp import CookieJar
  7. from aiohttp.test_utils import TestClient, TestServer
  8. from pyrogram.enums import ChatMemberStatus
  9. def connection_payload(connection_id: str = "conn-a", *, can_reply: bool = True) -> dict:
  10. return {
  11. "id": connection_id,
  12. "user": {"id": 900, "username": "owner", "first_name": "店主"},
  13. "user_chat_id": 900,
  14. "date": int(datetime.now(UTC).timestamp()),
  15. "rights": {"can_reply": can_reply, "can_read_messages": True},
  16. "is_enabled": True,
  17. }
  18. def customer_message(
  19. *,
  20. message_id: int,
  21. text: str | None = "营业时间是什么?",
  22. chat_id: int = 501,
  23. sender_id: int = 501,
  24. sent_at: datetime | None = None,
  25. **values,
  26. ) -> dict:
  27. message = {
  28. "business_connection_id": "conn-a",
  29. "message_id": message_id,
  30. "date": int((sent_at or datetime.now(UTC)).timestamp()),
  31. "chat": {"id": chat_id, "type": "private"},
  32. "from": {"id": sender_id, "username": f"user{sender_id}", "first_name": "客户"},
  33. **values,
  34. }
  35. if text is not None:
  36. message["text"] = text
  37. return message
  38. class FakeProvider:
  39. configured = True
  40. def __init__(self, *, error: Exception | None = None) -> None:
  41. self.error = error
  42. self.calls: list[dict] = []
  43. self.extraction_calls: list[dict] = []
  44. async def decide(self, **values):
  45. self.calls.append(values)
  46. if self.error:
  47. raise self.error
  48. entry_id = str(values["knowledge"][0]["entry_id"])
  49. return {
  50. "action": "answer",
  51. "reply": "每天 09:00 至 18:00 营业。",
  52. "handoff_reason": "",
  53. "matched_entry_ids": [entry_id],
  54. "summary": "客户询问营业时间。",
  55. }
  56. async def test_connection(self):
  57. return {"ok": True, "model": "test-model", "response_id": "response-1"}
  58. async def extract_knowledge(self, **values):
  59. self.extraction_calls.append(values)
  60. if self.error:
  61. raise self.error
  62. return [
  63. {
  64. "question": values.get("question_context") or "如何办理?",
  65. "aliases": [],
  66. "keywords": ["办理"],
  67. "answer": values["content"],
  68. "tags": ["自动采集"],
  69. "confidence": 0.92,
  70. }
  71. ]
  72. class FakeBusinessApi:
  73. def __init__(self) -> None:
  74. self.sent: list[dict] = []
  75. self.read: list[tuple[str, int, int]] = []
  76. async def send_message(
  77. self,
  78. chat_id: int,
  79. text: str,
  80. *,
  81. business_connection_id: str = "",
  82. reply_markup: dict | None = None,
  83. ) -> dict:
  84. self.sent.append(
  85. {
  86. "chat_id": chat_id,
  87. "text": text,
  88. "business_connection_id": business_connection_id,
  89. "reply_markup": reply_markup,
  90. }
  91. )
  92. return {
  93. "message_id": 1000 + len(self.sent),
  94. "sender_business_bot": {"id": 999},
  95. }
  96. async def read_business_message(
  97. self, connection_id: str, chat_id: int, message_id: int
  98. ) -> None:
  99. self.read.append((connection_id, chat_id, message_id))
  100. async def prepare_runtime(app_modules, *, provider: FakeProvider | None = None):
  101. dbassistant = app_modules.load("wbb.utils.dbassistant")
  102. service = app_modules.load("wbb.services.business_assistant")
  103. await dbassistant.upsert_business_connection(connection_payload())
  104. await dbassistant.update_account_settings("conn-a", {"assistant_enabled": True})
  105. knowledge = await dbassistant.create_knowledge_entry(
  106. "conn-a",
  107. {
  108. "question": "营业时间是什么?",
  109. "aliases": ["几点营业"],
  110. "keywords": ["营业时间", "营业"],
  111. "answer": "每天 09:00 至 18:00 营业。",
  112. "priority": 10,
  113. },
  114. )
  115. selected_provider = provider or FakeProvider()
  116. runtime = service.BusinessAssistantRuntime(
  117. token="123456:" + "A" * 30,
  118. session=SimpleNamespace(),
  119. provider=selected_provider,
  120. )
  121. runtime.api = FakeBusinessApi()
  122. return dbassistant, service, runtime, knowledge
  123. async def test_connection_settings_knowledge_quota_and_bot_scope(app_modules):
  124. dbassistant = app_modules.load("wbb.utils.dbassistant")
  125. await dbassistant.upsert_business_connection(connection_payload("conn-a"))
  126. await dbassistant.upsert_business_connection(connection_payload("conn-b"))
  127. settings = await dbassistant.update_account_settings(
  128. "conn-a",
  129. {
  130. "assistant_enabled": True,
  131. "account_daily_limit": 2,
  132. "customer_daily_limit": 1,
  133. "timezone": "Asia/Shanghai",
  134. },
  135. )
  136. first = await dbassistant.create_knowledge_entry(
  137. "conn-a",
  138. {
  139. "question": "营业时间",
  140. "aliases": ["几点开门"],
  141. "keywords": ["开门"],
  142. "answer": "九点开门。",
  143. "priority": 20,
  144. },
  145. )
  146. await dbassistant.create_knowledge_entry(
  147. "conn-b",
  148. {"question": "营业时间", "keywords": ["开门"], "answer": "十点开门。"},
  149. )
  150. await app_modules.wbb.db.business_assistant_knowledge.insert_one(
  151. {
  152. "bot_id": "foreign-bot",
  153. "entry_id": "foreign-entry",
  154. "connection_id": "conn-a",
  155. "question": "营业时间",
  156. "aliases": [],
  157. "keywords": ["开门"],
  158. "answer": "不应跨 Bot 返回。",
  159. "priority": 1000,
  160. "enabled": True,
  161. "updated_at": datetime.now(UTC),
  162. }
  163. )
  164. matched = await dbassistant.match_knowledge("conn-a", "你们几点开门?")
  165. assert [item["entry_id"] for item in matched] == [first["entry_id"]]
  166. items, total = await dbassistant.list_knowledge_entries("conn-a")
  167. assert total == 1
  168. assert items[0]["answer"] == "九点开门。"
  169. await app_modules.wbb.db.business_assistant_connections.insert_one(
  170. {
  171. "bot_id": "foreign-bot",
  172. "connection_id": "foreign-connection",
  173. "updated_at": datetime.now(UTC),
  174. }
  175. )
  176. connections, total = await dbassistant.list_business_connections(page_size=100)
  177. assert total == 2
  178. assert {item["connection_id"] for item in connections} == {"conn-a", "conn-b"}
  179. assert await dbassistant.reserve_ai_usage("conn-a", 101, settings) == (True, "")
  180. assert await dbassistant.reserve_ai_usage("conn-a", 101, settings) == (
  181. False,
  182. "customer_daily_limit",
  183. )
  184. assert await dbassistant.reserve_ai_usage("conn-a", 102, settings) == (True, "")
  185. assert await dbassistant.reserve_ai_usage("conn-a", 103, settings) == (
  186. False,
  187. "account_daily_limit",
  188. )
  189. usage = await dbassistant.usage_metrics(connection_id="conn-a")
  190. assert usage["ai_calls"] == 2
  191. assert usage["customers"] == 2
  192. with pytest.raises(dbassistant.AssistantDataError) as error:
  193. dbassistant.normalize_account_settings({"timezone": "invalid/timezone"})
  194. assert error.value.code == "invalid_setting"
  195. def test_ai_contract_and_reply_window_validation(app_modules):
  196. service = app_modules.load("wbb.services.business_assistant")
  197. parsed = service.parse_ai_decision(
  198. json.dumps(
  199. {
  200. "action": "answer",
  201. "reply": "标准答案",
  202. "matched_entry_ids": ["entry-1", "foreign"],
  203. "summary": "摘要",
  204. }
  205. ),
  206. allowed_entry_ids={"entry-1"},
  207. )
  208. assert parsed["matched_entry_ids"] == ["entry-1"]
  209. with pytest.raises(service.AssistantProviderError):
  210. service.parse_ai_decision(
  211. '{"action":"answer","reply":"没有引用"}', allowed_entry_ids={"entry-1"}
  212. )
  213. now = datetime.now(UTC)
  214. assert service.business_reply_window_open(
  215. {"date": int((now - timedelta(hours=23)).timestamp())}, now=now
  216. )
  217. assert not service.business_reply_window_open(
  218. {"date": int((now - timedelta(hours=25)).timestamp())}, now=now
  219. )
  220. assert not service.business_reply_window_open({}, now=now)
  221. extracted = service.parse_knowledge_extraction(
  222. json.dumps(
  223. {
  224. "items": [
  225. {
  226. "question": "如何配送?",
  227. "aliases": ["配送范围"],
  228. "keywords": ["配送"],
  229. "answer": "仅支持市区配送。",
  230. "tags": ["配送"],
  231. "confidence": 0.9,
  232. }
  233. ]
  234. }
  235. )
  236. )
  237. assert extracted[0]["confidence"] == 0.9
  238. assert (
  239. service.redact_business_knowledge_text(
  240. "电话 138 0013 8000,邮箱 user@example.com,Telegram @private_user"
  241. )
  242. == "电话 [电话已脱敏],邮箱 [邮箱已脱敏],Telegram [用户名已脱敏]"
  243. )
  244. async def test_source_event_candidates_publish_edit_delete_and_isolate(app_modules):
  245. dbassistant = app_modules.load("wbb.utils.dbassistant")
  246. await dbassistant.upsert_business_connection(connection_payload("conn-a"))
  247. await dbassistant.upsert_business_connection(connection_payload("conn-b"))
  248. source = await dbassistant.create_knowledge_source(
  249. "conn-a",
  250. {
  251. "source_type": "channel",
  252. "chat_id": -100100,
  253. "title": "官方频道",
  254. "publication_mode": "auto",
  255. "author_policy": "admins_or_allowlist",
  256. },
  257. )
  258. event, changed = await dbassistant.record_source_event(
  259. source,
  260. event_key="telegram:-100100:10",
  261. content="每天九点营业。",
  262. metadata={"chat_id": -100100, "message_id": 10, "trusted_author": True},
  263. )
  264. assert changed is True
  265. candidates = await dbassistant.replace_event_candidates(
  266. source,
  267. event,
  268. [
  269. {
  270. "question": "几点营业?",
  271. "aliases": ["营业时间"],
  272. "keywords": ["营业"],
  273. "answer": "每天九点营业。",
  274. "tags": ["营业"],
  275. "confidence": 0.95,
  276. }
  277. ],
  278. auto_publish=True,
  279. )
  280. candidate = candidates[0]
  281. first_entry_id = candidate["knowledge_entry_id"]
  282. first_entry = await app_modules.wbb.db.business_assistant_knowledge.find_one(
  283. {"bot_id": "primary", "entry_id": first_entry_id}
  284. )
  285. assert candidate["status"] == "published"
  286. assert first_entry["enabled"] is True
  287. edited_event, changed = await dbassistant.record_source_event(
  288. source,
  289. event_key="telegram:-100100:10",
  290. content="每天十点营业。",
  291. metadata={"chat_id": -100100, "message_id": 10, "trusted_author": True},
  292. )
  293. assert changed is True
  294. edited = await dbassistant.replace_event_candidates(
  295. source,
  296. edited_event,
  297. [
  298. {
  299. "question": "几点营业?",
  300. "aliases": [],
  301. "keywords": ["营业"],
  302. "answer": "每天十点营业。",
  303. "tags": ["营业"],
  304. "confidence": 0.96,
  305. }
  306. ],
  307. auto_publish=True,
  308. )
  309. assert edited[0]["knowledge_entry_id"] == first_entry_id
  310. updated_entry = await app_modules.wbb.db.business_assistant_knowledge.find_one(
  311. {"bot_id": "primary", "entry_id": first_entry_id}
  312. )
  313. assert updated_entry["answer"] == "每天十点营业。"
  314. assert await dbassistant.mark_source_event_deleted(
  315. source["source_id"], "telegram:-100100:10"
  316. )
  317. stale = await dbassistant.get_knowledge_candidate(candidate["candidate_id"])
  318. disabled_entry = await app_modules.wbb.db.business_assistant_knowledge.find_one(
  319. {"bot_id": "primary", "entry_id": first_entry_id}
  320. )
  321. assert stale["status"] == "stale"
  322. assert disabled_entry["enabled"] is False
  323. sources_a, total_a = await dbassistant.list_knowledge_sources("conn-a")
  324. sources_b, total_b = await dbassistant.list_knowledge_sources("conn-b")
  325. assert total_a == 1 and sources_a[0]["source_id"] == source["source_id"]
  326. assert total_b == 0 and sources_b == []
  327. event_indexes = await app_modules.wbb.db.business_assistant_source_events.index_information()
  328. assert event_indexes["expires_at_1"]["expireAfterSeconds"] == 0
  329. async def test_business_human_reply_learns_only_when_source_enabled(app_modules):
  330. dbassistant, _service, runtime, _knowledge = await prepare_runtime(app_modules)
  331. await runtime.process_update(
  332. {
  333. "update_id": 9,
  334. "business_message": customer_message(
  335. message_id=9, text="配送到哪里?", chat_id=509, sender_id=509
  336. ),
  337. }
  338. )
  339. await runtime.process_update(
  340. {
  341. "update_id": 10,
  342. "business_message": customer_message(
  343. message_id=10, text="仅支持市区配送。", chat_id=509, sender_id=900
  344. ),
  345. }
  346. )
  347. assert runtime.provider.extraction_calls == []
  348. await dbassistant.create_knowledge_source(
  349. "conn-a",
  350. {
  351. "source_type": "business",
  352. "title": "人工接待对话",
  353. "publication_mode": "auto",
  354. },
  355. )
  356. await runtime.process_update(
  357. {
  358. "update_id": 15,
  359. "business_message": customer_message(
  360. message_id=15, text="支持哪些区域?", chat_id=515, sender_id=515
  361. ),
  362. }
  363. )
  364. await runtime.process_update(
  365. {
  366. "update_id": 16,
  367. "business_message": customer_message(
  368. message_id=16, text="仅支持市区配送。", chat_id=515, sender_id=900
  369. ),
  370. }
  371. )
  372. assert len(runtime.provider.extraction_calls) == 1
  373. candidates, total = await dbassistant.list_knowledge_candidates(
  374. "conn-a", status="published"
  375. )
  376. assert total == 1
  377. assert candidates[0]["answer"] == "仅支持市区配送。"
  378. entry_id = candidates[0]["knowledge_entry_id"]
  379. await runtime.process_update(
  380. {
  381. "update_id": 17,
  382. "edited_business_message": customer_message(
  383. message_id=16, text="仅二环内支持配送。", chat_id=515, sender_id=900
  384. ),
  385. }
  386. )
  387. edited = await dbassistant.get_knowledge_candidate(candidates[0]["candidate_id"])
  388. assert edited["answer"] == "仅二环内支持配送。"
  389. assert edited["knowledge_entry_id"] == entry_id
  390. await runtime.process_update(
  391. {
  392. "update_id": 18,
  393. "deleted_business_messages": {
  394. "business_connection_id": "conn-a",
  395. "chat": {"id": 515},
  396. "message_ids": [16],
  397. },
  398. }
  399. )
  400. stale = await dbassistant.get_knowledge_candidate(candidates[0]["candidate_id"])
  401. entry = await app_modules.wbb.db.business_assistant_knowledge.find_one(
  402. {"bot_id": "primary", "entry_id": entry_id}
  403. )
  404. assert stale["status"] == "stale"
  405. assert entry["enabled"] is False
  406. async def test_group_collection_requires_trusted_author_for_auto_publish(app_modules):
  407. dbassistant, _service, runtime, _knowledge = await prepare_runtime(app_modules)
  408. module = app_modules.load("wbb.modules.business_assistant")
  409. module._runtime = runtime
  410. await dbassistant.create_knowledge_source(
  411. "conn-a",
  412. {
  413. "source_type": "group",
  414. "chat_id": -100300,
  415. "title": "运营讨论群",
  416. "publication_mode": "auto",
  417. "author_policy": "admins_or_allowlist",
  418. },
  419. )
  420. def group_message(message_id: int, author_id: int, text: str):
  421. return SimpleNamespace(
  422. id=message_id,
  423. text=text,
  424. caption=None,
  425. outgoing=False,
  426. is_automatic_forward=False,
  427. date=datetime.now(UTC),
  428. chat=SimpleNamespace(id=-100300),
  429. from_user=SimpleNamespace(id=author_id, is_bot=False),
  430. )
  431. ordinary = group_message(1, 301, "市区支持当日配送。")
  432. await module.process_knowledge_source_message(ordinary)
  433. pending, pending_total = await dbassistant.list_knowledge_candidates(
  434. "conn-a", status="pending"
  435. )
  436. assert pending_total == 1
  437. assert pending[0]["status"] == "pending"
  438. app_modules.app.members[(-100300, 302)] = SimpleNamespace(
  439. status=ChatMemberStatus.ADMINISTRATOR
  440. )
  441. admin_message = group_message(2, 302, "每周一至周五提供配送。")
  442. await module.process_knowledge_source_message(admin_message)
  443. published, published_total = await dbassistant.list_knowledge_candidates(
  444. "conn-a", status="published"
  445. )
  446. assert published_total == 1
  447. assert published[0]["source_snapshot"]["author_id"] == 302
  448. extraction_count = len(runtime.provider.extraction_calls)
  449. await module.process_knowledge_source_message(admin_message)
  450. assert len(runtime.provider.extraction_calls) == extraction_count
  451. async def test_business_updates_are_idempotent_and_manual_reply_pauses(app_modules):
  452. dbassistant, _service, runtime, knowledge = await prepare_runtime(app_modules)
  453. update = {
  454. "update_id": 11,
  455. "business_message": customer_message(message_id=1),
  456. }
  457. await runtime.process_update(update)
  458. await runtime.process_update(update)
  459. assert len(runtime.api.sent) == 1
  460. assert runtime.api.sent[0]["business_connection_id"] == "conn-a"
  461. assert runtime.api.read == [("conn-a", 501, 1)]
  462. assert len(runtime.provider.calls) == 1
  463. assert runtime.provider.calls[0]["knowledge"][0]["entry_id"] == knowledge["entry_id"]
  464. conversation = await dbassistant.get_conversation_by_chat("conn-a", 501)
  465. assert conversation["summary"] == "客户询问营业时间。"
  466. messages = await dbassistant.recent_conversation_messages(
  467. conversation["conversation_id"], limit=10
  468. )
  469. assert [item["direction"] for item in messages] == ["incoming", "assistant"]
  470. assert messages[0]["expires_at"] - messages[0]["created_at"] == timedelta(days=30)
  471. message_indexes = await app_modules.wbb.db.business_assistant_messages.index_information()
  472. assert message_indexes["expires_at_1"]["expireAfterSeconds"] == 0
  473. await runtime.process_update(
  474. {
  475. "update_id": 12,
  476. "business_message": customer_message(
  477. message_id=2, text="账号本人回复", sender_id=900
  478. ),
  479. }
  480. )
  481. paused = await dbassistant.get_conversation(conversation["conversation_id"])
  482. assert paused["status"] == "human_paused"
  483. assert paused["handoff_reason"] == "human_reply_detected"
  484. assert paused["customer"]["id"] == 501
  485. await runtime.process_update(
  486. {
  487. "update_id": 13,
  488. "business_message": customer_message(
  489. message_id=3,
  490. sender_business_bot={"id": 999},
  491. ),
  492. }
  493. )
  494. await runtime.process_update(
  495. {
  496. "update_id": 14,
  497. "business_message": customer_message(
  498. message_id=4,
  499. is_from_offline=True,
  500. ),
  501. }
  502. )
  503. assert len(runtime.api.sent) == 1
  504. async def test_handoff_sensitive_non_text_provider_error_and_expired_window(app_modules):
  505. dbassistant, service, runtime, _knowledge = await prepare_runtime(app_modules)
  506. await dbassistant.update_account_settings(
  507. "conn-a",
  508. {
  509. "notification_destination": "both",
  510. "ops_group_id": -100123,
  511. "account_daily_limit": 1,
  512. "customer_daily_limit": 1,
  513. },
  514. )
  515. await runtime.process_update(
  516. {
  517. "update_id": 21,
  518. "business_message": customer_message(
  519. message_id=1, text="我要退款", chat_id=601, sender_id=601
  520. ),
  521. }
  522. )
  523. sensitive = await dbassistant.get_conversation_by_chat("conn-a", 601)
  524. assert sensitive["status"] == "handoff"
  525. assert sensitive["handoff_reason"] == "sensitive_request"
  526. assert runtime.api.sent[-1]["business_connection_id"] == ""
  527. assert "message_id=1" in runtime.api.sent[-1]["text"]
  528. assert {item["chat_id"] for item in runtime.api.sent[-2:]} == {900, -100123}
  529. await runtime.process_update(
  530. {
  531. "update_id": 22,
  532. "business_message": customer_message(
  533. message_id=2, text=None, chat_id=602, sender_id=602, photo=[{"file_id": "x"}]
  534. ),
  535. }
  536. )
  537. unsupported = await dbassistant.get_conversation_by_chat("conn-a", 602)
  538. assert unsupported["handoff_reason"] == "unsupported_message"
  539. runtime.provider = FakeProvider(error=service.AssistantProviderError("模型超时"))
  540. await runtime.process_update(
  541. {
  542. "update_id": 23,
  543. "business_message": customer_message(
  544. message_id=3, chat_id=603, sender_id=603
  545. ),
  546. }
  547. )
  548. provider_failed = await dbassistant.get_conversation_by_chat("conn-a", 603)
  549. assert provider_failed["handoff_reason"] == "provider_error"
  550. await runtime.process_update(
  551. {
  552. "update_id": 230,
  553. "business_message": customer_message(
  554. message_id=30, chat_id=605, sender_id=605
  555. ),
  556. }
  557. )
  558. quota_handoff = await dbassistant.get_conversation_by_chat("conn-a", 605)
  559. assert quota_handoff["handoff_reason"] == "account_daily_limit"
  560. sent_before = len(runtime.api.sent)
  561. await runtime.process_update(
  562. {
  563. "update_id": 24,
  564. "business_message": customer_message(
  565. message_id=4,
  566. chat_id=604,
  567. sender_id=604,
  568. sent_at=datetime.now(UTC) - timedelta(hours=25),
  569. ),
  570. }
  571. )
  572. expired = await dbassistant.get_conversation_by_chat("conn-a", 604)
  573. assert expired["handoff_reason"] == "reply_window_expired"
  574. new_sends = runtime.api.sent[sent_before:]
  575. assert len(new_sends) == 2
  576. assert all(item["business_connection_id"] == "" for item in new_sends)
  577. async def test_failed_update_goes_to_dead_letter_without_blocking(app_modules, monkeypatch):
  578. dbassistant, _service, runtime, _knowledge = await prepare_runtime(app_modules)
  579. async def fail(_message):
  580. raise RuntimeError("persistent failure")
  581. async def no_sleep(_seconds):
  582. return None
  583. runtime._handle_business_message = fail
  584. monkeypatch.setattr("wbb.services.business_assistant.asyncio.sleep", no_sleep)
  585. await runtime.process_update(
  586. {"update_id": 31, "business_message": customer_message(message_id=1)}
  587. )
  588. dead_letter = await app_modules.wbb.db.business_assistant_dead_letters.find_one(
  589. {"bot_id": "primary", "update_id": 31}
  590. )
  591. update = await app_modules.wbb.db.business_assistant_updates.find_one(
  592. {"bot_id": "primary", "update_id": 31}
  593. )
  594. assert dead_letter["error"] == "persistent failure"
  595. assert update["status"] == "done"
  596. assert await dbassistant.claim_update(31) is False
  597. async def test_connection_updates_revoke_permissions_and_persist_offset(app_modules):
  598. service = app_modules.load("wbb.services.business_assistant")
  599. dbassistant = app_modules.load("wbb.utils.dbassistant")
  600. runtime = service.BusinessAssistantRuntime(
  601. token="123456:" + "A" * 30,
  602. session=SimpleNamespace(),
  603. provider=FakeProvider(),
  604. )
  605. runtime.api = FakeBusinessApi()
  606. await runtime.process_update(
  607. {"update_id": 41, "business_connection": connection_payload(can_reply=True)}
  608. )
  609. connected = await dbassistant.get_business_connection("conn-a")
  610. assert connected["is_enabled"] is True
  611. assert connected["rights"]["can_reply"] is True
  612. await dbassistant.update_account_settings("conn-a", {"assistant_enabled": True})
  613. revoked = connection_payload(can_reply=False)
  614. revoked["is_enabled"] = False
  615. await runtime.process_update({"update_id": 42, "business_connection": revoked})
  616. disconnected = await dbassistant.get_business_connection("conn-a")
  617. assert disconnected["is_enabled"] is False
  618. assert "can_reply" not in disconnected["rights"]
  619. await runtime.process_update(
  620. {"update_id": 420, "business_message": customer_message(message_id=20)}
  621. )
  622. assert runtime.api.sent == []
  623. await dbassistant.save_update_offset(43)
  624. assert await dbassistant.load_update_offset() == 43
  625. await dbassistant.save_update_offset(44)
  626. assert await dbassistant.load_update_offset() == 44
  627. class StartupApi:
  628. def __init__(self, *, supported: bool, webhook_url: str = "") -> None:
  629. self.supported = supported
  630. self.webhook_url = webhook_url
  631. async def get_me(self):
  632. return {"username": "business_bot", "can_connect_to_business": self.supported}
  633. async def get_webhook_info(self):
  634. return {"url": self.webhook_url}
  635. async def test_runtime_blocks_webhook_conflict_and_disabled_business_mode(app_modules):
  636. service = app_modules.load("wbb.services.business_assistant")
  637. runtime = service.BusinessAssistantRuntime(
  638. token="123456:" + "A" * 30,
  639. session=SimpleNamespace(),
  640. provider=FakeProvider(),
  641. )
  642. runtime.api = StartupApi(supported=True, webhook_url="https://hooks.example.com/tg")
  643. webhook_status = await runtime.start()
  644. assert webhook_status["polling_state"] == "blocked"
  645. assert webhook_status["webhook_conflict"] is True
  646. assert runtime._poll_task is None
  647. runtime.api = StartupApi(supported=False)
  648. business_status = await runtime.start()
  649. assert business_status["polling_state"] == "blocked"
  650. assert business_status["business_mode_supported"] is False
  651. assert "BotFather" in business_status["last_error"]
  652. class FakeResponse:
  653. def __init__(self, status: int, payload: dict) -> None:
  654. self.status = status
  655. self.payload = payload
  656. async def __aenter__(self):
  657. return self
  658. async def __aexit__(self, *_args):
  659. return None
  660. async def json(self, **_kwargs):
  661. return self.payload
  662. class RetrySession:
  663. def __init__(self, responses: list[FakeResponse]) -> None:
  664. self.responses = responses
  665. self.calls = 0
  666. def post(self, *_args, **_kwargs):
  667. response = self.responses[self.calls]
  668. self.calls += 1
  669. return response
  670. async def test_telegram_api_retries_429_and_5xx(app_modules, monkeypatch):
  671. service = app_modules.load("wbb.services.business_assistant")
  672. session = RetrySession(
  673. [
  674. FakeResponse(
  675. 429,
  676. {
  677. "ok": False,
  678. "error_code": 429,
  679. "description": "Too Many Requests",
  680. "parameters": {"retry_after": 3},
  681. },
  682. ),
  683. FakeResponse(
  684. 502,
  685. {"ok": False, "error_code": 502, "description": "Bad Gateway"},
  686. ),
  687. FakeResponse(200, {"ok": True, "result": {"id": 999}}),
  688. ]
  689. )
  690. sleeps: list[int] = []
  691. async def record_sleep(seconds):
  692. sleeps.append(seconds)
  693. monkeypatch.setattr("wbb.services.business_assistant.asyncio.sleep", record_sleep)
  694. api = service.TelegramBusinessApi("123456:" + "A" * 30, session)
  695. assert await api.get_me() == {"id": 999}
  696. assert session.calls == 3
  697. assert sleeps == [3, 2]
  698. async def _login_and_change_password(client: TestClient) -> str:
  699. login = await client.post(
  700. "/api/admin/v1/auth/login",
  701. json={"username": "admin", "password": "qwe0.123456"},
  702. )
  703. login_data = (await login.json())["data"]
  704. changed = await client.put(
  705. "/api/admin/v1/auth/password",
  706. headers={"X-CSRF-Token": login_data["csrf_token"]},
  707. json={
  708. "current_password": "qwe0.123456",
  709. "new_password": "changed-pass-123",
  710. },
  711. )
  712. return (await changed.json())["data"]["csrf_token"]
  713. async def test_business_assistant_api_permission_csrf_audit_and_clear(app_modules):
  714. dbassistant = app_modules.load("wbb.utils.dbassistant")
  715. await dbassistant.upsert_business_connection(connection_payload())
  716. conversation = await dbassistant.get_or_create_conversation(
  717. "conn-a", 701, customer={"id": 701, "first_name": "待清理客户"}
  718. )
  719. await dbassistant.append_conversation_message(
  720. conversation["conversation_id"],
  721. direction="incoming",
  722. telegram_message_id=1,
  723. text="请清理",
  724. )
  725. await dbassistant.update_conversation_summary(conversation["conversation_id"], "待清理摘要")
  726. admin_api = app_modules.load("wbb.admin.api")
  727. application = admin_api.build_admin_application()
  728. await application["admin_api"].initialize()
  729. client = TestClient(TestServer(application), cookie_jar=CookieJar(unsafe=True))
  730. await client.start_server()
  731. try:
  732. csrf = await _login_and_change_password(client)
  733. app_modules.wbb.BOT_PERMISSIONS = set()
  734. denied = await client.get("/api/admin/v1/business-assistant/status")
  735. assert denied.status == 403
  736. app_modules.wbb.BOT_PERMISSIONS = {"business_assistant.manage"}
  737. no_csrf = await client.put(
  738. "/api/admin/v1/business-assistant/settings",
  739. json={"connection_id": "conn-a", "assistant_enabled": True, "confirm": True},
  740. )
  741. assert no_csrf.status == 403
  742. unconfirmed = await client.put(
  743. "/api/admin/v1/business-assistant/settings",
  744. headers={"X-CSRF-Token": csrf},
  745. json={"connection_id": "conn-a", "assistant_enabled": True},
  746. )
  747. assert unconfirmed.status == 409
  748. saved = await client.put(
  749. "/api/admin/v1/business-assistant/settings",
  750. headers={"X-CSRF-Token": csrf},
  751. json={"connection_id": "conn-a", "assistant_enabled": True, "confirm": True},
  752. )
  753. assert saved.status == 200
  754. assert (await saved.json())["data"]["assistant_enabled"] is True
  755. created = await client.post(
  756. "/api/admin/v1/business-assistant/knowledge",
  757. headers={"X-CSRF-Token": csrf},
  758. json={
  759. "connection_id": "conn-a",
  760. "question": "配送范围",
  761. "keywords": ["配送"],
  762. "answer": "仅限市区。",
  763. "confirm": True,
  764. },
  765. )
  766. assert created.status == 201
  767. listed = await client.get(
  768. "/api/admin/v1/business-assistant/knowledge?connection_id=conn-a"
  769. )
  770. assert (await listed.json())["data"]["total"] == 1
  771. created_source = await client.post(
  772. "/api/admin/v1/business-assistant/knowledge-sources",
  773. headers={"X-CSRF-Token": csrf},
  774. json={
  775. "connection_id": "conn-a",
  776. "source_type": "business",
  777. "title": "人工对话",
  778. "publication_mode": "review",
  779. "confirm": True,
  780. },
  781. )
  782. assert created_source.status == 201
  783. source = (await created_source.json())["data"]
  784. event, _ = await dbassistant.record_source_event(
  785. source,
  786. event_key="business:701:8",
  787. content="市区可配送。",
  788. metadata={"chat_id": 701, "message_id": 8, "trusted_author": True},
  789. )
  790. candidates = await dbassistant.replace_event_candidates(
  791. source,
  792. event,
  793. [
  794. {
  795. "question": "配送范围?",
  796. "keywords": ["配送"],
  797. "answer": "市区可配送。",
  798. "confidence": 0.9,
  799. }
  800. ],
  801. auto_publish=False,
  802. )
  803. candidate_page = await client.get(
  804. "/api/admin/v1/business-assistant/knowledge-candidates"
  805. "?connection_id=conn-a&status=pending"
  806. )
  807. assert candidate_page.status == 200
  808. assert (await candidate_page.json())["data"]["total"] == 1
  809. approved = await client.post(
  810. "/api/admin/v1/business-assistant/knowledge-candidates/"
  811. f"{candidates[0]['candidate_id']}/actions",
  812. headers={"X-CSRF-Token": csrf},
  813. json={"action": "approve", "confirm": True},
  814. )
  815. assert approved.status == 200
  816. assert (await approved.json())["data"]["status"] == "published"
  817. cleared = await client.post(
  818. f"/api/admin/v1/business-assistant/conversations/{conversation['conversation_id']}/actions",
  819. headers={"X-CSRF-Token": csrf},
  820. json={"action": "clear", "confirm": True},
  821. )
  822. assert cleared.status == 200
  823. detail = await dbassistant.conversation_detail(conversation["conversation_id"])
  824. assert detail["summary"] == ""
  825. assert detail["messages"] == []
  826. audit = await app_modules.wbb.db.admin_audit_logs.find_one(
  827. {"action": "business_assistant.conversation.clear"}
  828. )
  829. assert audit["success"] is True
  830. finally:
  831. await client.close()