test_business_assistant.py 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129
  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. class FakeFeishuWebhook:
  101. def __init__(self) -> None:
  102. self.sent: list[dict] = []
  103. async def send(self, webhook_url: str, text: str, *, signing_secret: str = "") -> None:
  104. self.sent.append(
  105. {
  106. "webhook_url": webhook_url,
  107. "text": text,
  108. "signing_secret": signing_secret,
  109. }
  110. )
  111. async def prepare_runtime(app_modules, *, provider: FakeProvider | None = None):
  112. dbassistant = app_modules.load("wbb.utils.dbassistant")
  113. service = app_modules.load("wbb.services.business_assistant")
  114. await dbassistant.upsert_business_connection(connection_payload())
  115. await dbassistant.update_account_settings("conn-a", {"assistant_enabled": True})
  116. knowledge = await dbassistant.create_knowledge_entry(
  117. "conn-a",
  118. {
  119. "question": "营业时间是什么?",
  120. "aliases": ["几点营业"],
  121. "keywords": ["营业时间", "营业"],
  122. "answer": "每天 09:00 至 18:00 营业。",
  123. "priority": 10,
  124. },
  125. )
  126. selected_provider = provider or FakeProvider()
  127. runtime = service.BusinessAssistantRuntime(
  128. token="123456:" + "A" * 30,
  129. session=SimpleNamespace(),
  130. provider=selected_provider,
  131. )
  132. runtime.api = FakeBusinessApi()
  133. return dbassistant, service, runtime, knowledge
  134. async def test_connection_settings_knowledge_quota_and_bot_scope(app_modules):
  135. dbassistant = app_modules.load("wbb.utils.dbassistant")
  136. await dbassistant.upsert_business_connection(connection_payload("conn-a"))
  137. await dbassistant.upsert_business_connection(connection_payload("conn-b"))
  138. settings = await dbassistant.update_account_settings(
  139. "conn-a",
  140. {
  141. "assistant_enabled": True,
  142. "account_daily_limit": 2,
  143. "customer_daily_limit": 1,
  144. "timezone": "Asia/Shanghai",
  145. },
  146. )
  147. first = await dbassistant.create_knowledge_entry(
  148. "conn-a",
  149. {
  150. "question": "营业时间",
  151. "aliases": ["几点开门"],
  152. "keywords": ["开门"],
  153. "answer": "九点开门。",
  154. "priority": 20,
  155. },
  156. )
  157. await dbassistant.create_knowledge_entry(
  158. "conn-b",
  159. {"question": "营业时间", "keywords": ["开门"], "answer": "十点开门。"},
  160. )
  161. await app_modules.wbb.db.business_assistant_knowledge.insert_one(
  162. {
  163. "bot_id": "foreign-bot",
  164. "entry_id": "foreign-entry",
  165. "connection_id": "conn-a",
  166. "question": "营业时间",
  167. "aliases": [],
  168. "keywords": ["开门"],
  169. "answer": "不应跨 Bot 返回。",
  170. "priority": 1000,
  171. "enabled": True,
  172. "updated_at": datetime.now(UTC),
  173. }
  174. )
  175. matched = await dbassistant.match_knowledge("conn-a", "你们几点开门?")
  176. assert [item["entry_id"] for item in matched] == [first["entry_id"]]
  177. items, total = await dbassistant.list_knowledge_entries("conn-a")
  178. assert total == 1
  179. assert items[0]["answer"] == "九点开门。"
  180. await app_modules.wbb.db.business_assistant_connections.insert_one(
  181. {
  182. "bot_id": "foreign-bot",
  183. "connection_id": "foreign-connection",
  184. "updated_at": datetime.now(UTC),
  185. }
  186. )
  187. connections, total = await dbassistant.list_business_connections(page_size=100)
  188. assert total == 2
  189. assert {item["connection_id"] for item in connections} == {"conn-a", "conn-b"}
  190. assert await dbassistant.reserve_ai_usage("conn-a", 101, settings) == (True, "")
  191. assert await dbassistant.reserve_ai_usage("conn-a", 101, settings) == (
  192. False,
  193. "customer_daily_limit",
  194. )
  195. assert await dbassistant.reserve_ai_usage("conn-a", 102, settings) == (True, "")
  196. assert await dbassistant.reserve_ai_usage("conn-a", 103, settings) == (
  197. False,
  198. "account_daily_limit",
  199. )
  200. usage = await dbassistant.usage_metrics(connection_id="conn-a")
  201. assert usage["ai_calls"] == 2
  202. assert usage["customers"] == 2
  203. with pytest.raises(dbassistant.AssistantDataError) as error:
  204. dbassistant.normalize_account_settings({"timezone": "invalid/timezone"})
  205. assert error.value.code == "invalid_setting"
  206. async def test_feishu_settings_are_validated_and_never_exposed(app_modules):
  207. dbassistant = app_modules.load("wbb.utils.dbassistant")
  208. await dbassistant.upsert_business_connection(connection_payload())
  209. webhook_url = "https://open.feishu.cn/open-apis/bot/v2/hook/12345678-abcd-4321-abcd-123456789abc"
  210. stored = await dbassistant.update_account_settings(
  211. "conn-a",
  212. {
  213. "feishu_webhook_enabled": True,
  214. "feishu_webhook_url": webhook_url,
  215. "feishu_webhook_signing_secret": "signing-secret",
  216. "feishu_message_preview_enabled": True,
  217. },
  218. )
  219. assert stored["feishu_webhook_url"] == webhook_url
  220. assert stored["feishu_webhook_signing_secret"] == "signing-secret"
  221. public = dbassistant.public_account_settings(stored)
  222. assert public["feishu_webhook_url"] == ""
  223. assert public["feishu_webhook_signing_secret"] == ""
  224. assert public["feishu_webhook_configured"] is True
  225. assert public["feishu_signing_secret_configured"] is True
  226. connections, _total = await dbassistant.list_business_connections()
  227. assert connections[0]["settings"]["feishu_webhook_url"] == ""
  228. assert connections[0]["settings"]["feishu_webhook_configured"] is True
  229. with pytest.raises(dbassistant.AssistantDataError):
  230. dbassistant.normalize_account_settings(
  231. {
  232. "feishu_webhook_enabled": True,
  233. "feishu_webhook_url": "https://example.com/internal-hook",
  234. }
  235. )
  236. def test_ai_contract_and_reply_window_validation(app_modules):
  237. service = app_modules.load("wbb.services.business_assistant")
  238. parsed = service.parse_ai_decision(
  239. json.dumps(
  240. {
  241. "action": "answer",
  242. "reply": "标准答案",
  243. "matched_entry_ids": ["entry-1", "foreign"],
  244. "summary": "摘要",
  245. }
  246. ),
  247. allowed_entry_ids={"entry-1"},
  248. )
  249. assert parsed["matched_entry_ids"] == ["entry-1"]
  250. with pytest.raises(service.AssistantProviderError):
  251. service.parse_ai_decision(
  252. '{"action":"answer","reply":"没有引用"}', allowed_entry_ids={"entry-1"}
  253. )
  254. now = datetime.now(UTC)
  255. assert service.business_reply_window_open(
  256. {"date": int((now - timedelta(hours=23)).timestamp())}, now=now
  257. )
  258. assert not service.business_reply_window_open(
  259. {"date": int((now - timedelta(hours=25)).timestamp())}, now=now
  260. )
  261. assert not service.business_reply_window_open({}, now=now)
  262. extracted = service.parse_knowledge_extraction(
  263. json.dumps(
  264. {
  265. "items": [
  266. {
  267. "question": "如何配送?",
  268. "aliases": ["配送范围"],
  269. "keywords": ["配送"],
  270. "answer": "仅支持市区配送。",
  271. "tags": ["配送"],
  272. "confidence": 0.9,
  273. }
  274. ]
  275. }
  276. )
  277. )
  278. assert extracted[0]["confidence"] == 0.9
  279. assert (
  280. service.redact_business_knowledge_text(
  281. "电话 138 0013 8000,邮箱 user@example.com,Telegram @private_user"
  282. )
  283. == "电话 [电话已脱敏],邮箱 [邮箱已脱敏],Telegram [用户名已脱敏]"
  284. )
  285. async def test_source_event_candidates_publish_edit_delete_and_isolate(app_modules):
  286. dbassistant = app_modules.load("wbb.utils.dbassistant")
  287. await dbassistant.upsert_business_connection(connection_payload("conn-a"))
  288. await dbassistant.upsert_business_connection(connection_payload("conn-b"))
  289. source = await dbassistant.create_knowledge_source(
  290. "conn-a",
  291. {
  292. "source_type": "channel",
  293. "chat_id": -100100,
  294. "title": "官方频道",
  295. "publication_mode": "auto",
  296. "author_policy": "admins_or_allowlist",
  297. },
  298. )
  299. event, changed = await dbassistant.record_source_event(
  300. source,
  301. event_key="telegram:-100100:10",
  302. content="每天九点营业。",
  303. metadata={"chat_id": -100100, "message_id": 10, "trusted_author": True},
  304. )
  305. assert changed is True
  306. candidates = await dbassistant.replace_event_candidates(
  307. source,
  308. event,
  309. [
  310. {
  311. "question": "几点营业?",
  312. "aliases": ["营业时间"],
  313. "keywords": ["营业"],
  314. "answer": "每天九点营业。",
  315. "tags": ["营业"],
  316. "confidence": 0.95,
  317. }
  318. ],
  319. auto_publish=True,
  320. )
  321. candidate = candidates[0]
  322. first_entry_id = candidate["knowledge_entry_id"]
  323. first_entry = await app_modules.wbb.db.business_assistant_knowledge.find_one(
  324. {"bot_id": "primary", "entry_id": first_entry_id}
  325. )
  326. assert candidate["status"] == "published"
  327. assert first_entry["enabled"] is True
  328. edited_event, changed = await dbassistant.record_source_event(
  329. source,
  330. event_key="telegram:-100100:10",
  331. content="每天十点营业。",
  332. metadata={"chat_id": -100100, "message_id": 10, "trusted_author": True},
  333. )
  334. assert changed is True
  335. edited = await dbassistant.replace_event_candidates(
  336. source,
  337. edited_event,
  338. [
  339. {
  340. "question": "几点营业?",
  341. "aliases": [],
  342. "keywords": ["营业"],
  343. "answer": "每天十点营业。",
  344. "tags": ["营业"],
  345. "confidence": 0.96,
  346. }
  347. ],
  348. auto_publish=True,
  349. )
  350. assert edited[0]["knowledge_entry_id"] == first_entry_id
  351. updated_entry = await app_modules.wbb.db.business_assistant_knowledge.find_one(
  352. {"bot_id": "primary", "entry_id": first_entry_id}
  353. )
  354. assert updated_entry["answer"] == "每天十点营业。"
  355. assert await dbassistant.mark_source_event_deleted(
  356. source["source_id"], "telegram:-100100:10"
  357. )
  358. stale = await dbassistant.get_knowledge_candidate(candidate["candidate_id"])
  359. disabled_entry = await app_modules.wbb.db.business_assistant_knowledge.find_one(
  360. {"bot_id": "primary", "entry_id": first_entry_id}
  361. )
  362. assert stale["status"] == "stale"
  363. assert disabled_entry["enabled"] is False
  364. sources_a, total_a = await dbassistant.list_knowledge_sources("conn-a")
  365. sources_b, total_b = await dbassistant.list_knowledge_sources("conn-b")
  366. assert total_a == 1 and sources_a[0]["source_id"] == source["source_id"]
  367. assert total_b == 0 and sources_b == []
  368. event_indexes = await app_modules.wbb.db.business_assistant_source_events.index_information()
  369. assert event_indexes["expires_at_1"]["expireAfterSeconds"] == 0
  370. async def test_business_human_reply_learns_only_when_source_enabled(app_modules):
  371. dbassistant, _service, runtime, _knowledge = await prepare_runtime(app_modules)
  372. await runtime.process_update(
  373. {
  374. "update_id": 9,
  375. "business_message": customer_message(
  376. message_id=9, text="配送到哪里?", chat_id=509, sender_id=509
  377. ),
  378. }
  379. )
  380. await runtime.process_update(
  381. {
  382. "update_id": 10,
  383. "business_message": customer_message(
  384. message_id=10, text="仅支持市区配送。", chat_id=509, sender_id=900
  385. ),
  386. }
  387. )
  388. assert runtime.provider.extraction_calls == []
  389. await dbassistant.create_knowledge_source(
  390. "conn-a",
  391. {
  392. "source_type": "business",
  393. "title": "人工接待对话",
  394. "publication_mode": "auto",
  395. },
  396. )
  397. await runtime.process_update(
  398. {
  399. "update_id": 15,
  400. "business_message": customer_message(
  401. message_id=15, text="支持哪些区域?", chat_id=515, sender_id=515
  402. ),
  403. }
  404. )
  405. await runtime.process_update(
  406. {
  407. "update_id": 16,
  408. "business_message": customer_message(
  409. message_id=16, text="仅支持市区配送。", chat_id=515, sender_id=900
  410. ),
  411. }
  412. )
  413. assert len(runtime.provider.extraction_calls) == 1
  414. candidates, total = await dbassistant.list_knowledge_candidates(
  415. "conn-a", status="published"
  416. )
  417. assert total == 1
  418. assert candidates[0]["answer"] == "仅支持市区配送。"
  419. entry_id = candidates[0]["knowledge_entry_id"]
  420. await runtime.process_update(
  421. {
  422. "update_id": 17,
  423. "edited_business_message": customer_message(
  424. message_id=16, text="仅二环内支持配送。", chat_id=515, sender_id=900
  425. ),
  426. }
  427. )
  428. edited = await dbassistant.get_knowledge_candidate(candidates[0]["candidate_id"])
  429. assert edited["answer"] == "仅二环内支持配送。"
  430. assert edited["knowledge_entry_id"] == entry_id
  431. await runtime.process_update(
  432. {
  433. "update_id": 18,
  434. "deleted_business_messages": {
  435. "business_connection_id": "conn-a",
  436. "chat": {"id": 515},
  437. "message_ids": [16],
  438. },
  439. }
  440. )
  441. stale = await dbassistant.get_knowledge_candidate(candidates[0]["candidate_id"])
  442. entry = await app_modules.wbb.db.business_assistant_knowledge.find_one(
  443. {"bot_id": "primary", "entry_id": entry_id}
  444. )
  445. assert stale["status"] == "stale"
  446. assert entry["enabled"] is False
  447. async def test_group_collection_requires_trusted_author_for_auto_publish(app_modules):
  448. dbassistant, _service, runtime, _knowledge = await prepare_runtime(app_modules)
  449. module = app_modules.load("wbb.modules.business_assistant")
  450. module._runtime = runtime
  451. await dbassistant.create_knowledge_source(
  452. "conn-a",
  453. {
  454. "source_type": "group",
  455. "chat_id": -100300,
  456. "title": "运营讨论群",
  457. "publication_mode": "auto",
  458. "author_policy": "admins_or_allowlist",
  459. },
  460. )
  461. def group_message(message_id: int, author_id: int, text: str):
  462. return SimpleNamespace(
  463. id=message_id,
  464. text=text,
  465. caption=None,
  466. outgoing=False,
  467. is_automatic_forward=False,
  468. date=datetime.now(UTC),
  469. chat=SimpleNamespace(id=-100300),
  470. from_user=SimpleNamespace(id=author_id, is_bot=False),
  471. )
  472. ordinary = group_message(1, 301, "市区支持当日配送。")
  473. await module.process_knowledge_source_message(ordinary)
  474. pending, pending_total = await dbassistant.list_knowledge_candidates(
  475. "conn-a", status="pending"
  476. )
  477. assert pending_total == 1
  478. assert pending[0]["status"] == "pending"
  479. app_modules.app.members[(-100300, 302)] = SimpleNamespace(
  480. status=ChatMemberStatus.ADMINISTRATOR
  481. )
  482. admin_message = group_message(2, 302, "每周一至周五提供配送。")
  483. await module.process_knowledge_source_message(admin_message)
  484. published, published_total = await dbassistant.list_knowledge_candidates(
  485. "conn-a", status="published"
  486. )
  487. assert published_total == 1
  488. assert published[0]["source_snapshot"]["author_id"] == 302
  489. extraction_count = len(runtime.provider.extraction_calls)
  490. await module.process_knowledge_source_message(admin_message)
  491. assert len(runtime.provider.extraction_calls) == extraction_count
  492. async def test_business_updates_are_idempotent_and_manual_reply_pauses(app_modules):
  493. dbassistant, _service, runtime, knowledge = await prepare_runtime(app_modules)
  494. update = {
  495. "update_id": 11,
  496. "business_message": customer_message(message_id=1),
  497. }
  498. await runtime.process_update(update)
  499. await runtime.process_update(update)
  500. assert len(runtime.api.sent) == 1
  501. assert runtime.api.sent[0]["business_connection_id"] == "conn-a"
  502. assert runtime.api.read == [("conn-a", 501, 1)]
  503. assert len(runtime.provider.calls) == 1
  504. assert runtime.provider.calls[0]["knowledge"][0]["entry_id"] == knowledge["entry_id"]
  505. conversation = await dbassistant.get_conversation_by_chat("conn-a", 501)
  506. assert conversation["summary"] == "客户询问营业时间。"
  507. messages = await dbassistant.recent_conversation_messages(
  508. conversation["conversation_id"], limit=10
  509. )
  510. assert [item["direction"] for item in messages] == ["incoming", "assistant"]
  511. assert messages[0]["expires_at"] - messages[0]["created_at"] == timedelta(days=30)
  512. message_indexes = await app_modules.wbb.db.business_assistant_messages.index_information()
  513. assert message_indexes["expires_at_1"]["expireAfterSeconds"] == 0
  514. await runtime.process_update(
  515. {
  516. "update_id": 12,
  517. "business_message": customer_message(
  518. message_id=2, text="账号本人回复", sender_id=900
  519. ),
  520. }
  521. )
  522. paused = await dbassistant.get_conversation(conversation["conversation_id"])
  523. assert paused["status"] == "human_paused"
  524. assert paused["handoff_reason"] == "human_reply_detected"
  525. assert paused["customer"]["id"] == 501
  526. await runtime.process_update(
  527. {
  528. "update_id": 13,
  529. "business_message": customer_message(
  530. message_id=3,
  531. sender_business_bot={"id": 999},
  532. ),
  533. }
  534. )
  535. await runtime.process_update(
  536. {
  537. "update_id": 14,
  538. "business_message": customer_message(
  539. message_id=4,
  540. is_from_offline=True,
  541. ),
  542. }
  543. )
  544. assert len(runtime.api.sent) == 1
  545. async def test_every_customer_message_notifies_feishu_once(app_modules):
  546. dbassistant, _service, runtime, _knowledge = await prepare_runtime(app_modules)
  547. webhook_url = "https://open.feishu.cn/open-apis/bot/v2/hook/12345678-abcd-4321-abcd-123456789abc"
  548. await dbassistant.update_account_settings(
  549. "conn-a",
  550. {
  551. "assistant_enabled": False,
  552. "feishu_webhook_enabled": True,
  553. "feishu_webhook_url": webhook_url,
  554. "feishu_webhook_signing_secret": "secret",
  555. "feishu_message_preview_enabled": False,
  556. },
  557. )
  558. feishu = FakeFeishuWebhook()
  559. runtime.feishu = feishu
  560. await runtime.process_update(
  561. {"update_id": 101, "business_message": customer_message(message_id=1)}
  562. )
  563. await runtime.process_update(
  564. {"update_id": 102, "business_message": customer_message(message_id=1)}
  565. )
  566. assert len(feishu.sent) == 1
  567. assert feishu.sent[0]["webhook_url"] == webhook_url
  568. assert feishu.sent[0]["signing_secret"] == "secret"
  569. assert "营业时间是什么" not in feishu.sent[0]["text"]
  570. await dbassistant.update_account_settings(
  571. "conn-a", {"feishu_message_preview_enabled": True}
  572. )
  573. await runtime.process_update(
  574. {
  575. "update_id": 103,
  576. "business_message": customer_message(
  577. message_id=2, text="第二条客户消息"
  578. ),
  579. }
  580. )
  581. assert len(feishu.sent) == 2
  582. assert "第二条客户消息" in feishu.sent[1]["text"]
  583. await runtime.process_update(
  584. {
  585. "update_id": 1030,
  586. "business_message": customer_message(
  587. message_id=20,
  588. text=None,
  589. caption="图片说明",
  590. photo=[{"file_id": "photo"}],
  591. ),
  592. }
  593. )
  594. assert len(feishu.sent) == 3
  595. assert "类型:非文本" in feishu.sent[2]["text"]
  596. assert "内容:图片说明" in feishu.sent[2]["text"]
  597. await runtime.process_update(
  598. {
  599. "update_id": 104,
  600. "business_message": customer_message(
  601. message_id=3, text="账号本人回复", sender_id=900
  602. ),
  603. }
  604. )
  605. await runtime.process_update(
  606. {
  607. "update_id": 105,
  608. "business_message": customer_message(message_id=4, is_from_offline=True),
  609. }
  610. )
  611. await runtime.process_update(
  612. {
  613. "update_id": 106,
  614. "business_message": customer_message(
  615. message_id=5, sender_business_bot={"id": 999}
  616. ),
  617. }
  618. )
  619. assert len(feishu.sent) == 3
  620. conversation = await dbassistant.get_conversation_by_chat("conn-a", 501)
  621. messages = await dbassistant.recent_conversation_messages(
  622. conversation["conversation_id"], limit=10
  623. )
  624. incoming = [item for item in messages if item["telegram_message_id"] == 2][0]
  625. assert incoming["metadata"]["feishu_notification_status"] == "sent"
  626. async def test_handoff_sensitive_non_text_provider_error_and_expired_window(app_modules):
  627. dbassistant, service, runtime, _knowledge = await prepare_runtime(app_modules)
  628. await dbassistant.update_account_settings(
  629. "conn-a",
  630. {
  631. "notification_destination": "both",
  632. "ops_group_id": -100123,
  633. "account_daily_limit": 1,
  634. "customer_daily_limit": 1,
  635. },
  636. )
  637. await runtime.process_update(
  638. {
  639. "update_id": 21,
  640. "business_message": customer_message(
  641. message_id=1, text="我要退款", chat_id=601, sender_id=601
  642. ),
  643. }
  644. )
  645. sensitive = await dbassistant.get_conversation_by_chat("conn-a", 601)
  646. assert sensitive["status"] == "handoff"
  647. assert sensitive["handoff_reason"] == "sensitive_request"
  648. assert runtime.api.sent[-1]["business_connection_id"] == ""
  649. assert "message_id=1" in runtime.api.sent[-1]["text"]
  650. assert {item["chat_id"] for item in runtime.api.sent[-2:]} == {900, -100123}
  651. await runtime.process_update(
  652. {
  653. "update_id": 22,
  654. "business_message": customer_message(
  655. message_id=2, text=None, chat_id=602, sender_id=602, photo=[{"file_id": "x"}]
  656. ),
  657. }
  658. )
  659. unsupported = await dbassistant.get_conversation_by_chat("conn-a", 602)
  660. assert unsupported["handoff_reason"] == "unsupported_message"
  661. runtime.provider = FakeProvider(error=service.AssistantProviderError("模型超时"))
  662. await runtime.process_update(
  663. {
  664. "update_id": 23,
  665. "business_message": customer_message(
  666. message_id=3, chat_id=603, sender_id=603
  667. ),
  668. }
  669. )
  670. provider_failed = await dbassistant.get_conversation_by_chat("conn-a", 603)
  671. assert provider_failed["handoff_reason"] == "provider_error"
  672. await runtime.process_update(
  673. {
  674. "update_id": 230,
  675. "business_message": customer_message(
  676. message_id=30, chat_id=605, sender_id=605
  677. ),
  678. }
  679. )
  680. quota_handoff = await dbassistant.get_conversation_by_chat("conn-a", 605)
  681. assert quota_handoff["handoff_reason"] == "account_daily_limit"
  682. sent_before = len(runtime.api.sent)
  683. await runtime.process_update(
  684. {
  685. "update_id": 24,
  686. "business_message": customer_message(
  687. message_id=4,
  688. chat_id=604,
  689. sender_id=604,
  690. sent_at=datetime.now(UTC) - timedelta(hours=25),
  691. ),
  692. }
  693. )
  694. expired = await dbassistant.get_conversation_by_chat("conn-a", 604)
  695. assert expired["handoff_reason"] == "reply_window_expired"
  696. new_sends = runtime.api.sent[sent_before:]
  697. assert len(new_sends) == 2
  698. assert all(item["business_connection_id"] == "" for item in new_sends)
  699. async def test_failed_update_goes_to_dead_letter_without_blocking(app_modules, monkeypatch):
  700. dbassistant, _service, runtime, _knowledge = await prepare_runtime(app_modules)
  701. async def fail(_message):
  702. raise RuntimeError("persistent failure")
  703. async def no_sleep(_seconds):
  704. return None
  705. runtime._handle_business_message = fail
  706. monkeypatch.setattr("wbb.services.business_assistant.asyncio.sleep", no_sleep)
  707. await runtime.process_update(
  708. {"update_id": 31, "business_message": customer_message(message_id=1)}
  709. )
  710. dead_letter = await app_modules.wbb.db.business_assistant_dead_letters.find_one(
  711. {"bot_id": "primary", "update_id": 31}
  712. )
  713. update = await app_modules.wbb.db.business_assistant_updates.find_one(
  714. {"bot_id": "primary", "update_id": 31}
  715. )
  716. assert dead_letter["error"] == "persistent failure"
  717. assert update["status"] == "done"
  718. assert await dbassistant.claim_update(31) is False
  719. async def test_connection_updates_revoke_permissions_and_persist_offset(app_modules):
  720. service = app_modules.load("wbb.services.business_assistant")
  721. dbassistant = app_modules.load("wbb.utils.dbassistant")
  722. runtime = service.BusinessAssistantRuntime(
  723. token="123456:" + "A" * 30,
  724. session=SimpleNamespace(),
  725. provider=FakeProvider(),
  726. )
  727. runtime.api = FakeBusinessApi()
  728. await runtime.process_update(
  729. {"update_id": 41, "business_connection": connection_payload(can_reply=True)}
  730. )
  731. connected = await dbassistant.get_business_connection("conn-a")
  732. assert connected["is_enabled"] is True
  733. assert connected["rights"]["can_reply"] is True
  734. await dbassistant.update_account_settings("conn-a", {"assistant_enabled": True})
  735. revoked = connection_payload(can_reply=False)
  736. revoked["is_enabled"] = False
  737. await runtime.process_update({"update_id": 42, "business_connection": revoked})
  738. disconnected = await dbassistant.get_business_connection("conn-a")
  739. assert disconnected["is_enabled"] is False
  740. assert "can_reply" not in disconnected["rights"]
  741. await runtime.process_update(
  742. {"update_id": 420, "business_message": customer_message(message_id=20)}
  743. )
  744. assert runtime.api.sent == []
  745. await dbassistant.save_update_offset(43)
  746. assert await dbassistant.load_update_offset() == 43
  747. await dbassistant.save_update_offset(44)
  748. assert await dbassistant.load_update_offset() == 44
  749. class StartupApi:
  750. def __init__(self, *, supported: bool, webhook_url: str = "") -> None:
  751. self.supported = supported
  752. self.webhook_url = webhook_url
  753. async def get_me(self):
  754. return {"username": "business_bot", "can_connect_to_business": self.supported}
  755. async def get_webhook_info(self):
  756. return {"url": self.webhook_url}
  757. async def test_runtime_blocks_webhook_conflict_and_disabled_business_mode(app_modules):
  758. service = app_modules.load("wbb.services.business_assistant")
  759. runtime = service.BusinessAssistantRuntime(
  760. token="123456:" + "A" * 30,
  761. session=SimpleNamespace(),
  762. provider=FakeProvider(),
  763. )
  764. runtime.api = StartupApi(supported=True, webhook_url="https://hooks.example.com/tg")
  765. webhook_status = await runtime.start()
  766. assert webhook_status["polling_state"] == "blocked"
  767. assert webhook_status["webhook_conflict"] is True
  768. assert runtime._poll_task is None
  769. runtime.api = StartupApi(supported=False)
  770. business_status = await runtime.start()
  771. assert business_status["polling_state"] == "blocked"
  772. assert business_status["business_mode_supported"] is False
  773. assert "BotFather" in business_status["last_error"]
  774. class FakeResponse:
  775. def __init__(self, status: int, payload: dict) -> None:
  776. self.status = status
  777. self.payload = payload
  778. self.headers: dict[str, str] = {}
  779. async def __aenter__(self):
  780. return self
  781. async def __aexit__(self, *_args):
  782. return None
  783. async def json(self, **_kwargs):
  784. return self.payload
  785. class RetrySession:
  786. def __init__(self, responses: list[FakeResponse]) -> None:
  787. self.responses = responses
  788. self.calls = 0
  789. def post(self, *_args, **_kwargs):
  790. response = self.responses[self.calls]
  791. self.calls += 1
  792. return response
  793. async def test_telegram_api_retries_429_and_5xx(app_modules, monkeypatch):
  794. service = app_modules.load("wbb.services.business_assistant")
  795. session = RetrySession(
  796. [
  797. FakeResponse(
  798. 429,
  799. {
  800. "ok": False,
  801. "error_code": 429,
  802. "description": "Too Many Requests",
  803. "parameters": {"retry_after": 3},
  804. },
  805. ),
  806. FakeResponse(
  807. 502,
  808. {"ok": False, "error_code": 502, "description": "Bad Gateway"},
  809. ),
  810. FakeResponse(200, {"ok": True, "result": {"id": 999}}),
  811. ]
  812. )
  813. sleeps: list[int] = []
  814. async def record_sleep(seconds):
  815. sleeps.append(seconds)
  816. monkeypatch.setattr("wbb.services.business_assistant.asyncio.sleep", record_sleep)
  817. api = service.TelegramBusinessApi("123456:" + "A" * 30, session)
  818. assert await api.get_me() == {"id": 999}
  819. assert session.calls == 3
  820. assert sleeps == [3, 2]
  821. async def test_feishu_webhook_signature_and_success_contract(app_modules):
  822. service = app_modules.load("wbb.services.business_assistant")
  823. response = FakeResponse(200, {"code": 0, "msg": "success"})
  824. class CaptureSession:
  825. def __init__(self) -> None:
  826. self.payload = None
  827. def post(self, _url, *, json, timeout):
  828. self.payload = json
  829. assert timeout.total == 8
  830. return response
  831. session = CaptureSession()
  832. client = service.FeishuWebhookClient(session)
  833. await client.send(
  834. "https://open.feishu.cn/open-apis/bot/v2/hook/12345678-abcd-4321-abcd-123456789abc",
  835. "Telegram 新消息提醒",
  836. signing_secret="secret",
  837. )
  838. assert session.payload["msg_type"] == "text"
  839. assert session.payload["content"]["text"] == "Telegram 新消息提醒"
  840. assert session.payload["timestamp"]
  841. assert session.payload["sign"]
  842. async def _login_and_change_password(client: TestClient) -> str:
  843. login = await client.post(
  844. "/api/admin/v1/auth/login",
  845. json={"username": "admin", "password": "qwe0.123456"},
  846. )
  847. login_data = (await login.json())["data"]
  848. changed = await client.put(
  849. "/api/admin/v1/auth/password",
  850. headers={"X-CSRF-Token": login_data["csrf_token"]},
  851. json={
  852. "current_password": "qwe0.123456",
  853. "new_password": "changed-pass-123",
  854. },
  855. )
  856. return (await changed.json())["data"]["csrf_token"]
  857. async def test_business_assistant_api_permission_csrf_audit_and_clear(app_modules):
  858. dbassistant = app_modules.load("wbb.utils.dbassistant")
  859. await dbassistant.upsert_business_connection(connection_payload())
  860. conversation = await dbassistant.get_or_create_conversation(
  861. "conn-a", 701, customer={"id": 701, "first_name": "待清理客户"}
  862. )
  863. await dbassistant.append_conversation_message(
  864. conversation["conversation_id"],
  865. direction="incoming",
  866. telegram_message_id=1,
  867. text="请清理",
  868. )
  869. await dbassistant.update_conversation_summary(conversation["conversation_id"], "待清理摘要")
  870. admin_api = app_modules.load("wbb.admin.api")
  871. application = admin_api.build_admin_application()
  872. await application["admin_api"].initialize()
  873. client = TestClient(TestServer(application), cookie_jar=CookieJar(unsafe=True))
  874. await client.start_server()
  875. try:
  876. csrf = await _login_and_change_password(client)
  877. app_modules.wbb.BOT_PERMISSIONS = set()
  878. denied = await client.get("/api/admin/v1/business-assistant/status")
  879. assert denied.status == 403
  880. app_modules.wbb.BOT_PERMISSIONS = {"business_assistant.manage"}
  881. no_csrf = await client.put(
  882. "/api/admin/v1/business-assistant/settings",
  883. json={"connection_id": "conn-a", "assistant_enabled": True, "confirm": True},
  884. )
  885. assert no_csrf.status == 403
  886. unconfirmed = await client.put(
  887. "/api/admin/v1/business-assistant/settings",
  888. headers={"X-CSRF-Token": csrf},
  889. json={"connection_id": "conn-a", "assistant_enabled": True},
  890. )
  891. assert unconfirmed.status == 409
  892. saved = await client.put(
  893. "/api/admin/v1/business-assistant/settings",
  894. headers={"X-CSRF-Token": csrf},
  895. json={
  896. "connection_id": "conn-a",
  897. "assistant_enabled": True,
  898. "feishu_webhook_enabled": True,
  899. "feishu_webhook_url": "https://open.feishu.cn/open-apis/bot/v2/hook/12345678-abcd-4321-abcd-123456789abc",
  900. "feishu_webhook_signing_secret": "api-secret",
  901. "confirm": True,
  902. },
  903. )
  904. assert saved.status == 200
  905. saved_data = (await saved.json())["data"]
  906. assert saved_data["assistant_enabled"] is True
  907. assert saved_data["feishu_webhook_url"] == ""
  908. assert saved_data["feishu_webhook_signing_secret"] == ""
  909. assert saved_data["feishu_webhook_configured"] is True
  910. stored_settings = await dbassistant.get_account_settings("conn-a")
  911. assert stored_settings["feishu_webhook_signing_secret"] == "api-secret"
  912. listed_connections = await client.get(
  913. "/api/admin/v1/business-assistant/connections?page=1&page_size=100"
  914. )
  915. listed_settings = (await listed_connections.json())["data"]["items"][0][
  916. "settings"
  917. ]
  918. assert listed_settings["feishu_webhook_url"] == ""
  919. assert listed_settings["feishu_webhook_signing_secret"] == ""
  920. class FakeRuntime:
  921. def __init__(self) -> None:
  922. self.calls = []
  923. async def test_feishu_webhook(self, connection_id, overrides):
  924. self.calls.append((connection_id, overrides))
  925. return {"ok": True, "account": "店主"}
  926. module = app_modules.load("wbb.modules.business_assistant")
  927. fake_runtime = FakeRuntime()
  928. module._runtime = fake_runtime
  929. tested = await client.post(
  930. "/api/admin/v1/business-assistant/feishu-webhook/test",
  931. headers={"X-CSRF-Token": csrf},
  932. json={"connection_id": "conn-a", "confirm": True},
  933. )
  934. assert tested.status == 200
  935. assert (await tested.json())["data"]["ok"] is True
  936. assert fake_runtime.calls == [("conn-a", {})]
  937. created = await client.post(
  938. "/api/admin/v1/business-assistant/knowledge",
  939. headers={"X-CSRF-Token": csrf},
  940. json={
  941. "connection_id": "conn-a",
  942. "question": "配送范围",
  943. "keywords": ["配送"],
  944. "answer": "仅限市区。",
  945. "confirm": True,
  946. },
  947. )
  948. assert created.status == 201
  949. listed = await client.get(
  950. "/api/admin/v1/business-assistant/knowledge?connection_id=conn-a"
  951. )
  952. assert (await listed.json())["data"]["total"] == 1
  953. created_source = await client.post(
  954. "/api/admin/v1/business-assistant/knowledge-sources",
  955. headers={"X-CSRF-Token": csrf},
  956. json={
  957. "connection_id": "conn-a",
  958. "source_type": "business",
  959. "title": "人工对话",
  960. "publication_mode": "review",
  961. "confirm": True,
  962. },
  963. )
  964. assert created_source.status == 201
  965. source = (await created_source.json())["data"]
  966. event, _ = await dbassistant.record_source_event(
  967. source,
  968. event_key="business:701:8",
  969. content="市区可配送。",
  970. metadata={"chat_id": 701, "message_id": 8, "trusted_author": True},
  971. )
  972. candidates = await dbassistant.replace_event_candidates(
  973. source,
  974. event,
  975. [
  976. {
  977. "question": "配送范围?",
  978. "keywords": ["配送"],
  979. "answer": "市区可配送。",
  980. "confidence": 0.9,
  981. }
  982. ],
  983. auto_publish=False,
  984. )
  985. candidate_page = await client.get(
  986. "/api/admin/v1/business-assistant/knowledge-candidates"
  987. "?connection_id=conn-a&status=pending"
  988. )
  989. assert candidate_page.status == 200
  990. assert (await candidate_page.json())["data"]["total"] == 1
  991. approved = await client.post(
  992. "/api/admin/v1/business-assistant/knowledge-candidates/"
  993. f"{candidates[0]['candidate_id']}/actions",
  994. headers={"X-CSRF-Token": csrf},
  995. json={"action": "approve", "confirm": True},
  996. )
  997. assert approved.status == 200
  998. assert (await approved.json())["data"]["status"] == "published"
  999. cleared = await client.post(
  1000. f"/api/admin/v1/business-assistant/conversations/{conversation['conversation_id']}/actions",
  1001. headers={"X-CSRF-Token": csrf},
  1002. json={"action": "clear", "confirm": True},
  1003. )
  1004. assert cleared.status == 200
  1005. detail = await dbassistant.conversation_detail(conversation["conversation_id"])
  1006. assert detail["summary"] == ""
  1007. assert detail["messages"] == []
  1008. audit = await app_modules.wbb.db.admin_audit_logs.find_one(
  1009. {"action": "business_assistant.conversation.clear"}
  1010. )
  1011. assert audit["success"] is True
  1012. finally:
  1013. await client.close()