|
|
@@ -115,6 +115,20 @@ class FakeBusinessApi:
|
|
|
self.read.append((connection_id, chat_id, message_id))
|
|
|
|
|
|
|
|
|
+class FakeFeishuWebhook:
|
|
|
+ def __init__(self) -> None:
|
|
|
+ self.sent: list[dict] = []
|
|
|
+
|
|
|
+ async def send(self, webhook_url: str, text: str, *, signing_secret: str = "") -> None:
|
|
|
+ self.sent.append(
|
|
|
+ {
|
|
|
+ "webhook_url": webhook_url,
|
|
|
+ "text": text,
|
|
|
+ "signing_secret": signing_secret,
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
async def prepare_runtime(app_modules, *, provider: FakeProvider | None = None):
|
|
|
dbassistant = app_modules.load("wbb.utils.dbassistant")
|
|
|
service = app_modules.load("wbb.services.business_assistant")
|
|
|
@@ -218,6 +232,41 @@ async def test_connection_settings_knowledge_quota_and_bot_scope(app_modules):
|
|
|
assert error.value.code == "invalid_setting"
|
|
|
|
|
|
|
|
|
+async def test_feishu_settings_are_validated_and_never_exposed(app_modules):
|
|
|
+ dbassistant = app_modules.load("wbb.utils.dbassistant")
|
|
|
+ await dbassistant.upsert_business_connection(connection_payload())
|
|
|
+ webhook_url = "https://open.feishu.cn/open-apis/bot/v2/hook/12345678-abcd-4321-abcd-123456789abc"
|
|
|
+ stored = await dbassistant.update_account_settings(
|
|
|
+ "conn-a",
|
|
|
+ {
|
|
|
+ "feishu_webhook_enabled": True,
|
|
|
+ "feishu_webhook_url": webhook_url,
|
|
|
+ "feishu_webhook_signing_secret": "signing-secret",
|
|
|
+ "feishu_message_preview_enabled": True,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ assert stored["feishu_webhook_url"] == webhook_url
|
|
|
+ assert stored["feishu_webhook_signing_secret"] == "signing-secret"
|
|
|
+
|
|
|
+ public = dbassistant.public_account_settings(stored)
|
|
|
+ assert public["feishu_webhook_url"] == ""
|
|
|
+ assert public["feishu_webhook_signing_secret"] == ""
|
|
|
+ assert public["feishu_webhook_configured"] is True
|
|
|
+ assert public["feishu_signing_secret_configured"] is True
|
|
|
+
|
|
|
+ connections, _total = await dbassistant.list_business_connections()
|
|
|
+ assert connections[0]["settings"]["feishu_webhook_url"] == ""
|
|
|
+ assert connections[0]["settings"]["feishu_webhook_configured"] is True
|
|
|
+
|
|
|
+ with pytest.raises(dbassistant.AssistantDataError):
|
|
|
+ dbassistant.normalize_account_settings(
|
|
|
+ {
|
|
|
+ "feishu_webhook_enabled": True,
|
|
|
+ "feishu_webhook_url": "https://example.com/internal-hook",
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
def test_ai_contract_and_reply_window_validation(app_modules):
|
|
|
service = app_modules.load("wbb.services.business_assistant")
|
|
|
parsed = service.parse_ai_decision(
|
|
|
@@ -550,6 +599,93 @@ async def test_business_updates_are_idempotent_and_manual_reply_pauses(app_modul
|
|
|
assert len(runtime.api.sent) == 1
|
|
|
|
|
|
|
|
|
+async def test_every_customer_message_notifies_feishu_once(app_modules):
|
|
|
+ dbassistant, _service, runtime, _knowledge = await prepare_runtime(app_modules)
|
|
|
+ webhook_url = "https://open.feishu.cn/open-apis/bot/v2/hook/12345678-abcd-4321-abcd-123456789abc"
|
|
|
+ await dbassistant.update_account_settings(
|
|
|
+ "conn-a",
|
|
|
+ {
|
|
|
+ "assistant_enabled": False,
|
|
|
+ "feishu_webhook_enabled": True,
|
|
|
+ "feishu_webhook_url": webhook_url,
|
|
|
+ "feishu_webhook_signing_secret": "secret",
|
|
|
+ "feishu_message_preview_enabled": False,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ feishu = FakeFeishuWebhook()
|
|
|
+ runtime.feishu = feishu
|
|
|
+
|
|
|
+ await runtime.process_update(
|
|
|
+ {"update_id": 101, "business_message": customer_message(message_id=1)}
|
|
|
+ )
|
|
|
+ await runtime.process_update(
|
|
|
+ {"update_id": 102, "business_message": customer_message(message_id=1)}
|
|
|
+ )
|
|
|
+ assert len(feishu.sent) == 1
|
|
|
+ assert feishu.sent[0]["webhook_url"] == webhook_url
|
|
|
+ assert feishu.sent[0]["signing_secret"] == "secret"
|
|
|
+ assert "营业时间是什么" not in feishu.sent[0]["text"]
|
|
|
+
|
|
|
+ await dbassistant.update_account_settings(
|
|
|
+ "conn-a", {"feishu_message_preview_enabled": True}
|
|
|
+ )
|
|
|
+ await runtime.process_update(
|
|
|
+ {
|
|
|
+ "update_id": 103,
|
|
|
+ "business_message": customer_message(
|
|
|
+ message_id=2, text="第二条客户消息"
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ assert len(feishu.sent) == 2
|
|
|
+ assert "第二条客户消息" in feishu.sent[1]["text"]
|
|
|
+
|
|
|
+ await runtime.process_update(
|
|
|
+ {
|
|
|
+ "update_id": 1030,
|
|
|
+ "business_message": customer_message(
|
|
|
+ message_id=20,
|
|
|
+ text=None,
|
|
|
+ caption="图片说明",
|
|
|
+ photo=[{"file_id": "photo"}],
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ assert len(feishu.sent) == 3
|
|
|
+ assert "类型:非文本" in feishu.sent[2]["text"]
|
|
|
+ assert "内容:图片说明" in feishu.sent[2]["text"]
|
|
|
+
|
|
|
+ await runtime.process_update(
|
|
|
+ {
|
|
|
+ "update_id": 104,
|
|
|
+ "business_message": customer_message(
|
|
|
+ message_id=3, text="账号本人回复", sender_id=900
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ await runtime.process_update(
|
|
|
+ {
|
|
|
+ "update_id": 105,
|
|
|
+ "business_message": customer_message(message_id=4, is_from_offline=True),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ await runtime.process_update(
|
|
|
+ {
|
|
|
+ "update_id": 106,
|
|
|
+ "business_message": customer_message(
|
|
|
+ message_id=5, sender_business_bot={"id": 999}
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ assert len(feishu.sent) == 3
|
|
|
+ conversation = await dbassistant.get_conversation_by_chat("conn-a", 501)
|
|
|
+ messages = await dbassistant.recent_conversation_messages(
|
|
|
+ conversation["conversation_id"], limit=10
|
|
|
+ )
|
|
|
+ incoming = [item for item in messages if item["telegram_message_id"] == 2][0]
|
|
|
+ assert incoming["metadata"]["feishu_notification_status"] == "sent"
|
|
|
+
|
|
|
+
|
|
|
async def test_handoff_sensitive_non_text_provider_error_and_expired_window(app_modules):
|
|
|
dbassistant, service, runtime, _knowledge = await prepare_runtime(app_modules)
|
|
|
await dbassistant.update_account_settings(
|
|
|
@@ -726,6 +862,7 @@ class FakeResponse:
|
|
|
def __init__(self, status: int, payload: dict) -> None:
|
|
|
self.status = status
|
|
|
self.payload = payload
|
|
|
+ self.headers: dict[str, str] = {}
|
|
|
|
|
|
async def __aenter__(self):
|
|
|
return self
|
|
|
@@ -780,6 +917,32 @@ async def test_telegram_api_retries_429_and_5xx(app_modules, monkeypatch):
|
|
|
assert sleeps == [3, 2]
|
|
|
|
|
|
|
|
|
+async def test_feishu_webhook_signature_and_success_contract(app_modules):
|
|
|
+ service = app_modules.load("wbb.services.business_assistant")
|
|
|
+ response = FakeResponse(200, {"code": 0, "msg": "success"})
|
|
|
+
|
|
|
+ class CaptureSession:
|
|
|
+ def __init__(self) -> None:
|
|
|
+ self.payload = None
|
|
|
+
|
|
|
+ def post(self, _url, *, json, timeout):
|
|
|
+ self.payload = json
|
|
|
+ assert timeout.total == 8
|
|
|
+ return response
|
|
|
+
|
|
|
+ session = CaptureSession()
|
|
|
+ client = service.FeishuWebhookClient(session)
|
|
|
+ await client.send(
|
|
|
+ "https://open.feishu.cn/open-apis/bot/v2/hook/12345678-abcd-4321-abcd-123456789abc",
|
|
|
+ "Telegram 新消息提醒",
|
|
|
+ signing_secret="secret",
|
|
|
+ )
|
|
|
+ assert session.payload["msg_type"] == "text"
|
|
|
+ assert session.payload["content"]["text"] == "Telegram 新消息提醒"
|
|
|
+ assert session.payload["timestamp"]
|
|
|
+ assert session.payload["sign"]
|
|
|
+
|
|
|
+
|
|
|
async def _login_and_change_password(client: TestClient) -> str:
|
|
|
login = await client.post(
|
|
|
"/api/admin/v1/auth/login",
|
|
|
@@ -838,10 +1001,51 @@ async def test_business_assistant_api_permission_csrf_audit_and_clear(app_module
|
|
|
saved = await client.put(
|
|
|
"/api/admin/v1/business-assistant/settings",
|
|
|
headers={"X-CSRF-Token": csrf},
|
|
|
- json={"connection_id": "conn-a", "assistant_enabled": True, "confirm": True},
|
|
|
+ json={
|
|
|
+ "connection_id": "conn-a",
|
|
|
+ "assistant_enabled": True,
|
|
|
+ "feishu_webhook_enabled": True,
|
|
|
+ "feishu_webhook_url": "https://open.feishu.cn/open-apis/bot/v2/hook/12345678-abcd-4321-abcd-123456789abc",
|
|
|
+ "feishu_webhook_signing_secret": "api-secret",
|
|
|
+ "confirm": True,
|
|
|
+ },
|
|
|
)
|
|
|
assert saved.status == 200
|
|
|
- assert (await saved.json())["data"]["assistant_enabled"] is True
|
|
|
+ saved_data = (await saved.json())["data"]
|
|
|
+ assert saved_data["assistant_enabled"] is True
|
|
|
+ assert saved_data["feishu_webhook_url"] == ""
|
|
|
+ assert saved_data["feishu_webhook_signing_secret"] == ""
|
|
|
+ assert saved_data["feishu_webhook_configured"] is True
|
|
|
+ stored_settings = await dbassistant.get_account_settings("conn-a")
|
|
|
+ assert stored_settings["feishu_webhook_signing_secret"] == "api-secret"
|
|
|
+ listed_connections = await client.get(
|
|
|
+ "/api/admin/v1/business-assistant/connections?page=1&page_size=100"
|
|
|
+ )
|
|
|
+ listed_settings = (await listed_connections.json())["data"]["items"][0][
|
|
|
+ "settings"
|
|
|
+ ]
|
|
|
+ assert listed_settings["feishu_webhook_url"] == ""
|
|
|
+ assert listed_settings["feishu_webhook_signing_secret"] == ""
|
|
|
+
|
|
|
+ class FakeRuntime:
|
|
|
+ def __init__(self) -> None:
|
|
|
+ self.calls = []
|
|
|
+
|
|
|
+ async def test_feishu_webhook(self, connection_id, overrides):
|
|
|
+ self.calls.append((connection_id, overrides))
|
|
|
+ return {"ok": True, "account": "店主"}
|
|
|
+
|
|
|
+ module = app_modules.load("wbb.modules.business_assistant")
|
|
|
+ fake_runtime = FakeRuntime()
|
|
|
+ module._runtime = fake_runtime
|
|
|
+ tested = await client.post(
|
|
|
+ "/api/admin/v1/business-assistant/feishu-webhook/test",
|
|
|
+ headers={"X-CSRF-Token": csrf},
|
|
|
+ json={"connection_id": "conn-a", "confirm": True},
|
|
|
+ )
|
|
|
+ assert tested.status == 200
|
|
|
+ assert (await tested.json())["data"]["ok"] is True
|
|
|
+ assert fake_runtime.calls == [("conn-a", {})]
|
|
|
|
|
|
created = await client.post(
|
|
|
"/api/admin/v1/business-assistant/knowledge",
|