Przeglądaj źródła

feat: forward managed account messages to Feishu

AI-Co-Authored-By: Codex
chendeben 3 dni temu
rodzic
commit
1d8f61dfc0

+ 7 - 2
admin-web/src/pages/BusinessAssistant.tsx

@@ -724,8 +724,8 @@ export default function BusinessAssistantPage() {
                           <Alert
                             type="info"
                             showIcon
-                            message="第一期按每条用户消息提醒"
-                            description="启用后,每一条新的用户 Business 消息都会推送到飞书;账号本人回复、机器人消息及 Telegram 原生欢迎/离线自动消息不会触发。"
+                            message="按消息方向选择飞书提醒"
+                            description="开启飞书消息提醒后,每条新的客户 Business 消息都会推送;可另选托管账号发出的本人回复和智能接待消息。Telegram 原生欢迎/离线自动消息不会触发。"
                             style={{ marginBottom: 16 }}
                           />
                         </Col>
@@ -734,6 +734,11 @@ export default function BusinessAssistantPage() {
                             <Switch checkedChildren="开启" unCheckedChildren="关闭" />
                           </Form.Item>
                         </Col>
+                        <Col xs={24} md={8}>
+                          <Form.Item name="feishu_outgoing_message_enabled" label="托管账号发出的消息" valuePropName="checked" extra="包含账号本人回复和智能接待发给客户的消息。">
+                            <Switch checkedChildren="推送" unCheckedChildren="不推送" />
+                          </Form.Item>
+                        </Col>
                         <Col xs={24} md={8}>
                           <Form.Item name="feishu_message_preview_enabled" label="包含消息内容" valuePropName="checked" extra="关闭时卡片会显示“消息已隐藏”。">
                             <Switch checkedChildren="包含" unCheckedChildren="隐藏" />

+ 1 - 0
admin-web/src/types.ts

@@ -105,6 +105,7 @@ export interface AssistantAccountSettings {
   feishu_webhook_configured: boolean;
   feishu_signing_secret_configured: boolean;
   feishu_message_preview_enabled: boolean;
+  feishu_outgoing_message_enabled: boolean;
 }
 
 export interface AssistantConnection {

+ 14 - 1
admin-web/tests/unit/BusinessAssistant.test.tsx

@@ -64,6 +64,7 @@ const settings = {
   feishu_webhook_configured: true,
   feishu_signing_secret_configured: true,
   feishu_message_preview_enabled: false,
+  feishu_outgoing_message_enabled: false,
 };
 
 beforeEach(() => {
@@ -246,11 +247,23 @@ test('展示脱敏的飞书提醒配置并可发送测试消息', async () => {
   await screen.findAllByText('店主');
 
   expect(screen.getByText('消息提醒')).toBeInTheDocument();
-  expect(screen.getByText('第一期按每条用户消息提醒')).toBeInTheDocument();
+  expect(screen.getByText('按消息方向选择飞书提醒')).toBeInTheDocument();
   expect(screen.getByText('飞书消息提醒')).toBeInTheDocument();
+  expect(screen.getByText('托管账号发出的消息')).toBeInTheDocument();
   expect(screen.getByText('Webhook 已配置')).toBeInTheDocument();
   expect(screen.getByText('签名已配置')).toBeInTheDocument();
 
+  fireEvent.click(screen.getByRole('switch', { name: '托管账号发出的消息' }));
+  fireEvent.click(screen.getByRole('button', { name: '保存接待设置' }));
+  fireEvent.click(await screen.findByRole('button', { name: '确认保存' }));
+  await waitFor(() => {
+    const saveCall = vi.mocked(apiRequest).mock.calls.find(
+      ([path, options]) => path === '/business-assistant/settings' && options?.method === 'PUT',
+    );
+    expect(saveCall).toBeDefined();
+    expect(JSON.parse(String(saveCall?.[1]?.body)).feishu_outgoing_message_enabled).toBe(true);
+  });
+
   fireEvent.click(screen.getByRole('button', { name: '发送测试提醒' }));
   fireEvent.click(await screen.findByRole('button', { name: '发送测试' }));
 

+ 65 - 0
tests/test_business_assistant.py

@@ -263,16 +263,19 @@ async def test_feishu_settings_are_validated_and_never_exposed(app_modules):
             "feishu_webhook_url": webhook_url,
             "feishu_webhook_signing_secret": "signing-secret",
             "feishu_message_preview_enabled": True,
+            "feishu_outgoing_message_enabled": True,
         },
     )
     assert stored["feishu_webhook_url"] == webhook_url
     assert stored["feishu_webhook_signing_secret"] == "signing-secret"
+    assert stored["feishu_outgoing_message_enabled"] is True
 
     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
+    assert public["feishu_outgoing_message_enabled"] is True
 
     connections, _total = await dbassistant.list_business_connections()
     assert connections[0]["settings"]["feishu_webhook_url"] == ""
@@ -732,6 +735,66 @@ async def test_every_user_message_notifies_feishu_once(app_modules):
     assert incoming["metadata"]["feishu_notification_status"] == "sent"
 
 
+async def test_outgoing_business_messages_notify_feishu_when_enabled(app_modules):
+    dbassistant, _service, runtime, _knowledge = await prepare_runtime(app_modules)
+    await dbassistant.update_account_settings(
+        "conn-a",
+        {
+            "assistant_enabled": False,
+            "feishu_webhook_enabled": True,
+            "feishu_webhook_url": "https://open.feishu.cn/open-apis/bot/v2/hook/12345678-abcd-4321-abcd-123456789abc",
+            "feishu_message_preview_enabled": True,
+        },
+    )
+    feishu = FakeFeishuWebhook()
+    runtime.feishu = feishu
+    await runtime.process_update(
+        {"update_id": 201, "business_message": customer_message(message_id=1)}
+    )
+    await runtime.process_update(
+        {
+            "update_id": 202,
+            "business_message": customer_message(
+                message_id=2, sender_id=900, text="默认不推送"
+            ),
+        }
+    )
+    assert len(feishu.sent) == 1
+
+    await dbassistant.update_account_settings(
+        "conn-a", {"feishu_outgoing_message_enabled": True}
+    )
+    owner_reply = customer_message(message_id=3, sender_id=900, text="人工回复")
+    await runtime.process_update({"update_id": 203, "business_message": owner_reply})
+    await runtime.process_update({"update_id": 204, "business_message": owner_reply})
+    assert len(feishu.sent) == 2
+    assert feishu.sent[-1]["title"] == "店主 · 托管账号"
+    assert "人工回复" in feishu.sent[-1]["text"]
+    assert feishu.sent[-1]["link_url"] == "https://t.me/user501"
+    conversation = await dbassistant.get_conversation_by_chat("conn-a", 501)
+    messages = await dbassistant.recent_conversation_messages(
+        conversation["conversation_id"], limit=10
+    )
+    human = next(
+        item
+        for item in messages
+        if item["direction"] == "human" and item["telegram_message_id"] == 3
+    )
+    assert human["metadata"]["feishu_notification_status"] == "sent"
+
+    await dbassistant.update_account_settings("conn-a", {"assistant_enabled": True})
+    await runtime.process_update(
+        {
+            "update_id": 205,
+            "business_message": customer_message(message_id=4, chat_id=503, sender_id=503),
+        }
+    )
+    assert len(feishu.sent) == 4
+    assert feishu.sent[-1]["title"] == "智能接待 · 托管账号"
+    assert "每天 09:00 至 18:00 营业" in feishu.sent[-1]["text"]
+    assert feishu.sent[-1]["link_url"] == "https://t.me/user503"
+
+
 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(
@@ -1115,6 +1178,7 @@ async def test_business_assistant_api_permission_csrf_audit_and_clear(app_module
                 "connection_id": "conn-a",
                 "assistant_enabled": True,
                 "feishu_webhook_enabled": True,
+                "feishu_outgoing_message_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,
@@ -1126,6 +1190,7 @@ async def test_business_assistant_api_permission_csrf_audit_and_clear(app_module
         assert saved_data["feishu_webhook_url"] == ""
         assert saved_data["feishu_webhook_signing_secret"] == ""
         assert saved_data["feishu_webhook_configured"] is True
+        assert saved_data["feishu_outgoing_message_enabled"] 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(

+ 51 - 10
wbb/services/business_assistant.py

@@ -920,6 +920,13 @@ class BusinessAssistantRuntime:
                 text=human_text,
                 sender_id=owner_id,
             )
+            await self._notify_feishu_message(
+                conversation=conversation,
+                message=message,
+                sender=connection.get("user") or {},
+                settings=settings,
+                direction="human",
+            )
             await pause_conversation_for_human(
                 conversation["conversation_id"],
                 hours=int(settings.get("human_pause_hours") or 24),
@@ -1028,7 +1035,7 @@ class BusinessAssistantRuntime:
             sender_id=int(sender.get("id") or 0),
             metadata={"content_type": "text" if text else "unsupported"},
         )
-        await self._notify_feishu_customer_message(
+        await self._notify_feishu_message(
             conversation=conversation,
             message=message,
             sender=sender,
@@ -1137,33 +1144,55 @@ class BusinessAssistantRuntime:
                 "matched_entry_ids": decision["matched_entry_ids"],
             },
         )
+        await self._notify_feishu_message(
+            conversation=current,
+            message={
+                **response,
+                "text": decision["reply"],
+                "date": response.get("date") or int(utc_now().timestamp()),
+            },
+            sender={},
+            settings=settings,
+            direction="assistant",
+        )
 
-    async def _notify_feishu_customer_message(
+    async def _notify_feishu_message(
         self,
         *,
         conversation: dict[str, Any],
         message: dict[str, Any],
         sender: dict[str, Any],
         settings: dict[str, Any],
+        direction: str = "incoming",
     ) -> None:
         webhook_url = str(settings.get("feishu_webhook_url") or "")
         if not settings.get("feishu_webhook_enabled") or not webhook_url:
             return
+        if direction != "incoming" and not settings.get("feishu_outgoing_message_enabled"):
+            return
         conversation_id = str(conversation["conversation_id"])
         message_id = int(message.get("message_id") or 0)
-        if not await claim_feishu_message_notification(conversation_id, message_id):
+        if not message_id or not await claim_feishu_message_notification(
+            conversation_id, message_id, direction=direction
+        ):
             return
-        user_name = " ".join(
+        sender_name = " ".join(
             str(item).strip()
             for item in (sender.get("first_name"), sender.get("last_name"))
             if str(item or "").strip()
         )
-        if not user_name:
-            user_name = (
+        if not sender_name:
+            sender_name = (
                 f"@{sender.get('username')}"
                 if sender.get("username")
                 else str((message.get("chat") or {}).get("id") or "未知")
             )
+        if direction == "incoming":
+            title = sender_name
+        elif direction == "human":
+            title = f"{sender_name} · 托管账号"
+        else:
+            title = "智能接待 · 托管账号"
         preview_text = str(
             message.get("text") or message.get("caption") or ""
         ).strip()
@@ -1172,7 +1201,8 @@ class BusinessAssistantRuntime:
         else:
             message_content = "消息已隐藏"
         lines = [message_content]
-        telegram_username = str(sender.get("username") or "").strip().lstrip("@")
+        chat_user = sender if direction == "incoming" else conversation.get("customer") or {}
+        telegram_username = str(chat_user.get("username") or "").strip().lstrip("@")
         telegram_chat_url = (
             f"https://t.me/{telegram_username}"
             if re.fullmatch(r"[A-Za-z0-9_]{1,64}", telegram_username)
@@ -1188,14 +1218,14 @@ class BusinessAssistantRuntime:
         try:
             await self.feishu.send_card(
                 webhook_url,
-                title=user_name,
+                title=title,
                 lines=lines,
                 link_url=telegram_chat_url,
                 signing_secret=str(settings.get("feishu_webhook_signing_secret") or ""),
             )
         except FeishuWebhookError as exc:
             await mark_feishu_message_notification(
-                conversation_id, message_id, status="failed", error=str(exc)
+                conversation_id, message_id, status="failed", error=str(exc), direction=direction
             )
             await update_runtime_status(
                 {
@@ -1205,7 +1235,7 @@ class BusinessAssistantRuntime:
             )
             return
         await mark_feishu_message_notification(
-            conversation_id, message_id, status="sent"
+            conversation_id, message_id, status="sent", direction=direction
         )
         await update_runtime_status(
             {
@@ -1266,6 +1296,17 @@ class BusinessAssistantRuntime:
                     text=notice,
                     metadata={"action": "handoff", "reason": reason},
                 )
+                await self._notify_feishu_message(
+                    conversation=updated,
+                    message={
+                        **response,
+                        "text": notice,
+                        "date": response.get("date") or int(utc_now().timestamp()),
+                    },
+                    sender={},
+                    settings=settings,
+                    direction="assistant",
+                )
             except TelegramBotApiError as exc:
                 await update_runtime_status(
                     {

+ 13 - 3
wbb/utils/dbassistant.py

@@ -49,6 +49,7 @@ DEFAULT_ACCOUNT_SETTINGS: dict[str, Any] = {
     "feishu_webhook_url": "",
     "feishu_webhook_signing_secret": "",
     "feishu_message_preview_enabled": False,
+    "feishu_outgoing_message_enabled": False,
 }
 
 SECRET_ACCOUNT_SETTINGS = {
@@ -526,6 +527,10 @@ def normalize_account_settings(
         current["feishu_message_preview_enabled"] = bool(
             values.get("feishu_message_preview_enabled")
         )
+    if "feishu_outgoing_message_enabled" in values:
+        current["feishu_outgoing_message_enabled"] = bool(
+            values.get("feishu_outgoing_message_enabled")
+        )
     if current["feishu_webhook_enabled"] and not current["feishu_webhook_url"]:
         raise AssistantDataError(
             "invalid_setting", "开启飞书消息提醒前,请先填写群机器人 Webhook 地址。"
@@ -1383,9 +1388,11 @@ async def append_conversation_message(
 
 
 async def claim_feishu_message_notification(
-    conversation_id: str, telegram_message_id: int
+    conversation_id: str, telegram_message_id: int, *, direction: str = "incoming"
 ) -> bool:
-    message_key = f"{conversation_id}:incoming:{int(telegram_message_id)}"
+    if direction not in {"incoming", "human", "assistant"}:
+        raise AssistantDataError("invalid_direction", "会话消息方向无效。")
+    message_key = f"{conversation_id}:{direction}:{int(telegram_message_id)}"
     stale_before = utc_now() - timedelta(minutes=2)
     result = await messagesdb.find_one_and_update(
         _scope(
@@ -1423,10 +1430,13 @@ async def mark_feishu_message_notification(
     *,
     status: str,
     error: str = "",
+    direction: str = "incoming",
 ) -> None:
     if status not in {"sent", "failed"}:
         raise AssistantDataError("invalid_status", "飞书通知状态无效。")
-    message_key = f"{conversation_id}:incoming:{int(telegram_message_id)}"
+    if direction not in {"incoming", "human", "assistant"}:
+        raise AssistantDataError("invalid_direction", "会话消息方向无效。")
+    message_key = f"{conversation_id}:{direction}:{int(telegram_message_id)}"
     await messagesdb.update_one(
         _scope(message_key=message_key),
         {