Explorar el Código

feat: add Telegram Business assistant

AI-Co-Authored-By: Codex
chendeben hace 1 mes
padre
commit
42537f87f7

+ 34 - 0
LOCAL_SETUP.md

@@ -33,6 +33,40 @@ SUDOERS, log groups, media relay group, Pyrogram session, and worker process.
 Enabled profiles start automatically as soon as the shared API credentials are
 Enabled profiles start automatically as soon as the shared API credentials are
 complete. Token testing uses Telegram's official `getMe` endpoint.
 complete. Token testing uses Telegram's official `getMe` endpoint.
 
 
+### Telegram Business intelligent reception
+
+For each Bot that should handle account chats:
+
+1. Open BotFather, choose the Bot, enter **Bot Settings**, and enable
+   **Business Mode**. Test the Token again in **System settings**; the result
+   must report Business Connection support.
+2. Assign the built-in **Intelligent reception specialist** role, or grant
+   `business_assistant.manage` through a custom role.
+3. Open **Intelligent reception**, configure an OpenAI-compatible Base URL
+   ending at the API root (for example `https://models.example.com/v1`), API
+   Key, model, timeout, and output limit. Saving this configuration restarts
+   only the selected Bot worker.
+4. On the Telegram account that will be represented, open **Settings →
+   Telegram Business / Chat Automation → Chatbots**, connect the Bot, and
+   select the chats it may access. This account-side authorization cannot be
+   automated by the server.
+5. Return to **Intelligent reception**, select the connection, create its FAQ
+   entries, choose notification destinations, and enable automatic reception.
+
+The account must be eligible for Telegram Business/Chatbots in the official
+client. Each connection has independent settings, FAQ entries, conversations,
+daily limits, timezone, and digest schedule. Default limits are 200 AI calls
+per connection per day and 20 per customer per day. Original messages expire
+after 30 days; summaries and follow-up state remain until an administrator
+clears the conversation.
+
+The runtime refuses to poll when a webhook is configured, ignores Telegram's
+native greeting/away messages and messages sent by the Business Bot itself,
+and never sends outside the official 24-hour reply window. A manual reply by
+the connected account pauses automation for 24 hours by default. Explicit
+handoffs remain paused until an authorized owner, operations-group admin, or
+Web administrator resumes them.
+
 The admin panel defaults are:
 The admin panel defaults are:
 
 
 - Address: `http://127.0.0.1:8088/admin`
 - Address: `http://127.0.0.1:8088/admin`

+ 21 - 0
README.md

@@ -30,6 +30,27 @@
 
 
 旧 `/teacher_*`、`/my_services` 和 `/review_qr` 命令继续作为兼容入口。
 旧 `/teacher_*`、`/my_services` 和 `/review_qr` 命令继续作为兼容入口。
 
 
+## Telegram Business 智能接待
+
+后台“智能接待”可让同一个 Bot 接入多个 Telegram Business 账号,并按连接账号
+隔离知识库、客户会话、额度和接待策略。V1 支持结构化 FAQ 自动回答、无可靠
+知识时转人工、账号本人回复后暂停、会话摘要、30 天原始消息保留、人工恢复和
+可选每日运营简报。群管理仍由 Pyrogram 处理;Business 更新使用官方 Bot API
+长轮询,不会接管普通 Bot 私聊。
+
+启用前需要完成两步官方客户端操作:
+
+1. 在 BotFather 的 Bot Settings 中开启 Business Mode,并在后台重新测试 Token,
+   确认显示支持 Business Connection。
+2. 由账号本人在 Telegram“设置 → Telegram Business/聊天自动化 → Chatbots”中
+   连接 Bot,并选择允许接管的聊天范围。系统不能代替账号本人完成连接或范围授权。
+
+然后为 Bot 分配“智能接待专员”角色,在后台配置 OpenAI 兼容的 Base URL、
+API Key 和模型,再为每个连接账号分别配置知识库与通知目标。API Key 只保存在
+`0600` 的 Bot 配置文件中并注入对应工作进程,后台接口和日志不会返回明文。
+Telegram 原生欢迎语/离线消息继续在账号侧配置;智能接待会忽略这些消息以及
+Bot 自己发送的消息,并在官方 24 小时回复窗口外停止发送、通知人工。
+
 <p align="center">
 <p align="center">
     <a href="https://python.org">
     <a href="https://python.org">
         <img src="http://forthebadge.com/images/badges/made-with-python.svg" alt="made-with-python">
         <img src="http://forthebadge.com/images/badges/made-with-python.svg" alt="made-with-python">

+ 1 - 0
admin-web/.umirc.ts

@@ -31,6 +31,7 @@ export default defineConfig({
         { path: '/points', component: '@/pages/Points' },
         { path: '/points', component: '@/pages/Points' },
         { path: '/directory', component: '@/pages/Directory' },
         { path: '/directory', component: '@/pages/Directory' },
         { path: '/services', component: '@/pages/Services' },
         { path: '/services', component: '@/pages/Services' },
+        { path: '/business-assistant', component: '@/pages/BusinessAssistant' },
         { path: '/audit', component: '@/pages/AuditLogs' },
         { path: '/audit', component: '@/pages/AuditLogs' },
         { path: '/settings', component: '@/pages/Settings' },
         { path: '/settings', component: '@/pages/Settings' },
       ],
       ],

+ 7 - 0
admin-web/src/layouts/AdminLayout.tsx

@@ -3,6 +3,7 @@ import { Button, Dropdown, Select, Skeleton, message } from 'antd';
 import type { MenuProps } from 'antd';
 import type { MenuProps } from 'antd';
 import {
 import {
   Bot,
   Bot,
+  BotMessageSquare,
   ChevronDown,
   ChevronDown,
   ClipboardList,
   ClipboardList,
   Gift,
   Gift,
@@ -35,6 +36,7 @@ const menuItems = [
   { path: '/points', name: '积分管理', icon: <Star size={18} /> },
   { path: '/points', name: '积分管理', icon: <Star size={18} /> },
   { path: '/directory', name: '技师与附近', icon: <MapPinned size={18} /> },
   { path: '/directory', name: '技师与附近', icon: <MapPinned size={18} /> },
   { path: '/services', name: '咨询与评价', icon: <Wrench size={18} /> },
   { path: '/services', name: '咨询与评价', icon: <Wrench size={18} /> },
+  { path: '/business-assistant', name: '智能接待', icon: <BotMessageSquare size={18} /> },
   { path: '/audit', name: '操作日志', icon: <ClipboardList size={18} /> },
   { path: '/audit', name: '操作日志', icon: <ClipboardList size={18} /> },
   { path: '/settings', name: '系统设置', icon: <Settings size={18} /> },
   { path: '/settings', name: '系统设置', icon: <Settings size={18} /> },
 ];
 ];
@@ -104,6 +106,9 @@ export default function AdminLayout() {
     if (item.path === '/directory' || item.path === '/services') {
     if (item.path === '/directory' || item.path === '/services') {
       return hasPermission('teacher_directory.manage');
       return hasPermission('teacher_directory.manage');
     }
     }
+    if (item.path === '/business-assistant') {
+      return hasPermission('business_assistant.manage');
+    }
     return true;
     return true;
   });
   });
 
 
@@ -118,6 +123,8 @@ export default function AdminLayout() {
         !hasPermission('teacher_directory.manage')) ||
         !hasPermission('teacher_directory.manage')) ||
       (location.pathname.startsWith('/services') &&
       (location.pathname.startsWith('/services') &&
         !hasPermission('teacher_directory.manage')) ||
         !hasPermission('teacher_directory.manage')) ||
+      (location.pathname.startsWith('/business-assistant') &&
+        !hasPermission('business_assistant.manage')) ||
       (location.pathname.startsWith('/chats') &&
       (location.pathname.startsWith('/chats') &&
         !hasAnyPermission(chatPermissions));
         !hasAnyPermission(chatPermissions));
     if (denied) history.replace('/dashboard');
     if (denied) history.replace('/dashboard');

+ 757 - 0
admin-web/src/pages/BusinessAssistant.tsx

@@ -0,0 +1,757 @@
+import {
+  Alert,
+  Button,
+  Col,
+  Descriptions,
+  Drawer,
+  Empty,
+  Form,
+  Input,
+  InputNumber,
+  Modal,
+  Row,
+  Select,
+  Space,
+  Spin,
+  Switch,
+  Table,
+  Tabs,
+  Tag,
+  Typography,
+  message,
+} from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import dayjs from 'dayjs';
+import {
+  BotMessageSquare,
+  CircleCheck,
+  CirclePlus,
+  FlaskConical,
+  Pause,
+  Pencil,
+  Play,
+  RefreshCw,
+  Save,
+  Trash2,
+} from 'lucide-react';
+import { useEffect, useMemo, useState } from 'react';
+
+import { Metric, PageHeader, Surface } from '@/components/Page';
+import { useBotAccess } from '@/contexts/BotAccess';
+import { apiRequest, jsonOptions } from '@/services/api';
+import type {
+  AssistantAccountSettings,
+  AssistantConnection,
+  AssistantConversation,
+  AssistantConversationStatus,
+  AssistantKnowledgeEntry,
+  AssistantRuntimeStatus,
+  AssistantUsage,
+  Paged,
+} from '@/types';
+
+type ProviderForm = {
+  base_url: string;
+  api_key?: string;
+  model: string;
+  timeout_seconds: number;
+  max_output_tokens: number;
+};
+
+type KnowledgeForm = Pick<
+  AssistantKnowledgeEntry,
+  'question' | 'aliases' | 'keywords' | 'answer' | 'tags' | 'priority' | 'enabled'
+>;
+
+const emptyPage = <T,>(): Paged<T> => ({ items: [], total: 0, page: 1, page_size: 20 });
+
+const conversationLabels: Record<AssistantConversationStatus, string> = {
+  auto: '自动接待',
+  handoff: '待人工',
+  human_paused: '人工接管',
+  closed: '已关闭',
+};
+
+const conversationColors: Record<AssistantConversationStatus, string> = {
+  auto: 'success',
+  handoff: 'error',
+  human_paused: 'warning',
+  closed: 'default',
+};
+
+function connectionName(connection: AssistantConnection): string {
+  const name = [connection.user.first_name, connection.user.last_name].filter(Boolean).join(' ');
+  return name || (connection.user.username ? `@${connection.user.username}` : connection.user.id);
+}
+
+function customerName(conversation: AssistantConversation): string {
+  const customer = conversation.customer;
+  if (!customer) return conversation.chat_id;
+  const name = [customer.first_name, customer.last_name].filter(Boolean).join(' ');
+  return name || (customer.username ? `@${customer.username}` : customer.id);
+}
+
+export default function BusinessAssistantPage() {
+  const { bot } = useBotAccess();
+  const [loading, setLoading] = useState(false);
+  const [status, setStatus] = useState<AssistantRuntimeStatus | null>(null);
+  const [connections, setConnections] = useState<AssistantConnection[]>([]);
+  const [selectedConnection, setSelectedConnection] = useState('');
+  const [settings, setSettings] = useState<AssistantAccountSettings | null>(null);
+  const [usage, setUsage] = useState<AssistantUsage | null>(null);
+  const [knowledge, setKnowledge] = useState<Paged<AssistantKnowledgeEntry>>(emptyPage());
+  const [conversations, setConversations] = useState<Paged<AssistantConversation>>(emptyPage());
+  const [conversationStatus, setConversationStatus] = useState('');
+  const [knowledgeQuery, setKnowledgeQuery] = useState('');
+  const [knowledgeOpen, setKnowledgeOpen] = useState(false);
+  const [knowledgeTarget, setKnowledgeTarget] = useState<AssistantKnowledgeEntry | null>(null);
+  const [conversationDetail, setConversationDetail] = useState<AssistantConversation | null>(null);
+  const [detailLoading, setDetailLoading] = useState(false);
+  const [testQuestion, setTestQuestion] = useState('');
+  const [testResult, setTestResult] = useState<Record<string, unknown> | null>(null);
+  const [providerForm] = Form.useForm<ProviderForm>();
+  const [settingsForm] = Form.useForm<AssistantAccountSettings>();
+  const [knowledgeForm] = Form.useForm<KnowledgeForm>();
+
+  const provider = bot?.business_assistant;
+
+  const reloadTopLevel = async () => {
+    setLoading(true);
+    try {
+      const [statusValue, connectionPage] = await Promise.all([
+        apiRequest<AssistantRuntimeStatus>('/business-assistant/status'),
+        apiRequest<Paged<AssistantConnection>>('/business-assistant/connections?page=1&page_size=100'),
+      ]);
+      setStatus(statusValue);
+      setConnections(connectionPage.items);
+      setSelectedConnection((current) =>
+        current && connectionPage.items.some((item) => item.connection_id === current)
+          ? current
+          : connectionPage.items[0]?.connection_id || '',
+      );
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : '智能接待状态加载失败');
+      setConnections([]);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const loadConnectionData = async (
+    connectionId: string,
+    page = 1,
+    filters?: { knowledgeQuery?: string; conversationStatus?: string },
+  ) => {
+    if (!connectionId) {
+      setSettings(null);
+      setKnowledge(emptyPage());
+      setConversations(emptyPage());
+      setUsage(null);
+      return;
+    }
+    const params = new URLSearchParams({ connection_id: connectionId });
+    const knowledgeParams = new URLSearchParams({
+      connection_id: connectionId,
+      query: filters?.knowledgeQuery ?? knowledgeQuery,
+      page: String(page),
+      page_size: '20',
+    });
+    const conversationParams = new URLSearchParams({
+      connection_id: connectionId,
+      status: filters?.conversationStatus ?? conversationStatus,
+      page: String(page),
+      page_size: '20',
+    });
+    setLoading(true);
+    try {
+      const [settingsValue, knowledgeValue, conversationValue, usageValue] = await Promise.all([
+        apiRequest<AssistantAccountSettings>(`/business-assistant/settings?${params}`),
+        apiRequest<Paged<AssistantKnowledgeEntry>>(`/business-assistant/knowledge?${knowledgeParams}`),
+        apiRequest<Paged<AssistantConversation>>(
+          `/business-assistant/conversations?${conversationParams}`,
+        ),
+        apiRequest<AssistantUsage>(`/business-assistant/usage?${params}`),
+      ]);
+      setSettings(settingsValue);
+      settingsForm.setFieldsValue(settingsValue);
+      setKnowledge(knowledgeValue);
+      setConversations(conversationValue);
+      setUsage(usageValue);
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : '连接数据加载失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    providerForm.setFieldsValue({
+      base_url: provider?.base_url || '',
+      model: provider?.model || '',
+      timeout_seconds: provider?.timeout_seconds || 30,
+      max_output_tokens: provider?.max_output_tokens || 600,
+      api_key: '',
+    });
+    if (bot?.bot_id) void reloadTopLevel();
+  }, [bot?.bot_id]);
+
+  useEffect(() => {
+    void loadConnectionData(selectedConnection);
+  }, [selectedConnection, conversationStatus]);
+
+  const activeConnection = useMemo(
+    () => connections.find((item) => item.connection_id === selectedConnection) || null,
+    [connections, selectedConnection],
+  );
+
+  const saveProvider = (values: ProviderForm) => {
+    if (!bot) return;
+    Modal.confirm({
+      title: '更新模型配置并重启机器人?',
+      content: 'API Key 留空会保留现有密钥。工作进程重启期间会短暂暂停接待。',
+      okText: '确认保存',
+      cancelText: '取消',
+      onOk: async () => {
+        await apiRequest(
+          `/bots/${bot.bot_id}`,
+          jsonOptions('PUT', {
+            business_assistant: values,
+            confirm: true,
+          }),
+        );
+        message.success('模型配置已保存,机器人正在重启');
+        window.dispatchEvent(new Event('bot-profiles-changed'));
+        await reloadTopLevel();
+      },
+    });
+  };
+
+  const saveSettings = (values: AssistantAccountSettings) => {
+    Modal.confirm({
+      title: '保存该账号的接待设置?',
+      content: values.assistant_enabled ? '保存后自动回复将按新规则运行。' : '保存后将停止自动回复。',
+      okText: '确认保存',
+      cancelText: '取消',
+      onOk: async () => {
+        const saved = await apiRequest<AssistantAccountSettings>(
+          '/business-assistant/settings',
+          jsonOptions('PUT', { ...values, connection_id: selectedConnection, confirm: true }),
+        );
+        setSettings(saved);
+        settingsForm.setFieldsValue(saved);
+        message.success('接待设置已保存');
+        await reloadTopLevel();
+      },
+    });
+  };
+
+  const openKnowledge = (entry?: AssistantKnowledgeEntry) => {
+    setKnowledgeTarget(entry || null);
+    knowledgeForm.setFieldsValue(
+      entry || {
+        question: '',
+        aliases: [],
+        keywords: [],
+        answer: '',
+        tags: [],
+        priority: 0,
+        enabled: true,
+      },
+    );
+    setKnowledgeOpen(true);
+  };
+
+  const saveKnowledge = (values: KnowledgeForm) => {
+    Modal.confirm({
+      title: knowledgeTarget ? '保存知识条目?' : '创建知识条目?',
+      okText: '确认保存',
+      cancelText: '取消',
+      onOk: async () => {
+        const path = knowledgeTarget
+          ? `/business-assistant/knowledge/${knowledgeTarget.entry_id}`
+          : '/business-assistant/knowledge';
+        await apiRequest(
+          path,
+          jsonOptions(knowledgeTarget ? 'PUT' : 'POST', {
+            ...values,
+            connection_id: selectedConnection,
+            confirm: true,
+          }),
+        );
+        setKnowledgeOpen(false);
+        message.success(knowledgeTarget ? '知识条目已更新' : '知识条目已创建');
+        await loadConnectionData(selectedConnection);
+      },
+    });
+  };
+
+  const runKnowledgeTest = async () => {
+    if (!testQuestion.trim()) return;
+    try {
+      const result = await apiRequest<Record<string, unknown>>(
+        '/business-assistant/knowledge/test',
+        jsonOptions('POST', { connection_id: selectedConnection, text: testQuestion }),
+      );
+      setTestResult(result);
+    } catch (error) {
+      setTestResult(null);
+      message.error(error instanceof Error ? error.message : '测试失败');
+    }
+  };
+
+  const loadConversationDetail = async (conversation: AssistantConversation) => {
+    setDetailLoading(true);
+    setConversationDetail(conversation);
+    try {
+      setConversationDetail(
+        await apiRequest<AssistantConversation>(
+          `/business-assistant/conversations/${conversation.conversation_id}`,
+        ),
+      );
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : '会话详情加载失败');
+    } finally {
+      setDetailLoading(false);
+    }
+  };
+
+  const conversationAction = (
+    conversation: AssistantConversation,
+    action: 'pause' | 'resume' | 'close' | 'clear',
+  ) => {
+    const labels = { pause: '暂停自动回复', resume: '恢复自动回复', close: '关闭会话', clear: '清空会话' };
+    Modal.confirm({
+      title: `${labels[action]}?`,
+      content: action === 'clear' ? '原始消息和长期摘要都会被删除,无法恢复。' : undefined,
+      okText: '确认执行',
+      okButtonProps: action === 'clear' ? { danger: true } : undefined,
+      cancelText: '取消',
+      onOk: async () => {
+        await apiRequest(
+          `/business-assistant/conversations/${conversation.conversation_id}/actions`,
+          jsonOptions('POST', { action, confirm: true }),
+        );
+        message.success(`${labels[action]}成功`);
+        if (conversationDetail?.conversation_id === conversation.conversation_id) {
+          setConversationDetail(null);
+        }
+        await loadConnectionData(selectedConnection);
+      },
+    });
+  };
+
+  const knowledgeColumns: ColumnsType<AssistantKnowledgeEntry> = [
+    {
+      title: '标准问题',
+      dataIndex: 'question',
+      minWidth: 220,
+      render: (value: string, entry) => (
+        <div>
+          <Typography.Text strong>{value}</Typography.Text>
+          <div style={{ marginTop: 4 }}>
+            <Space size={[4, 4]} wrap>
+              {entry.keywords.map((keyword) => <Tag key={keyword}>{keyword}</Tag>)}
+            </Space>
+          </div>
+        </div>
+      ),
+    },
+    { title: '标准答案', dataIndex: 'answer', ellipsis: true, minWidth: 280 },
+    { title: '优先级', dataIndex: 'priority', width: 88 },
+    {
+      title: '状态',
+      dataIndex: 'enabled',
+      width: 88,
+      render: (enabled: boolean) => <Tag color={enabled ? 'success' : 'default'}>{enabled ? '启用' : '停用'}</Tag>,
+    },
+    {
+      title: '操作',
+      key: 'actions',
+      width: 112,
+      fixed: 'right',
+      render: (_, entry) => (
+        <Space size={4}>
+          <Button aria-label={`编辑知识 ${entry.question}`} icon={<Pencil size={16} />} onClick={() => openKnowledge(entry)} />
+          <Button
+            danger
+            aria-label={`删除知识 ${entry.question}`}
+            icon={<Trash2 size={16} />}
+            onClick={() =>
+              Modal.confirm({
+                title: `删除“${entry.question}”?`,
+                content: '删除后无法用于自动回答,操作不可撤销。',
+                okText: '确认删除',
+                okButtonProps: { danger: true },
+                cancelText: '取消',
+                onOk: async () => {
+                  await apiRequest(
+                    `/business-assistant/knowledge/${entry.entry_id}`,
+                    jsonOptions('DELETE', { confirm: true }),
+                  );
+                  message.success('知识条目已删除');
+                  await loadConnectionData(selectedConnection);
+                },
+              })
+            }
+          />
+        </Space>
+      ),
+    },
+  ];
+
+  const conversationColumns: ColumnsType<AssistantConversation> = [
+    {
+      title: '客户',
+      key: 'customer',
+      minWidth: 180,
+      render: (_, conversation) => (
+        <Button type="link" style={{ paddingInline: 0 }} onClick={() => void loadConversationDetail(conversation)}>
+          {customerName(conversation)}
+        </Button>
+      ),
+    },
+    {
+      title: '状态',
+      dataIndex: 'status',
+      width: 110,
+      render: (value: AssistantConversationStatus) => (
+        <Tag color={conversationColors[value]}>{conversationLabels[value]}</Tag>
+      ),
+    },
+    { title: '摘要', dataIndex: 'summary', ellipsis: true, minWidth: 280, render: (value) => value || '暂无摘要' },
+    { title: '转人工原因', dataIndex: 'handoff_reason', width: 180, render: (value) => value || '—' },
+    {
+      title: '最近消息',
+      dataIndex: 'last_message_at',
+      width: 160,
+      render: (value: string) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—'),
+    },
+    {
+      title: '操作',
+      key: 'actions',
+      fixed: 'right',
+      width: 112,
+      render: (_, conversation) => (
+        <Space size={4}>
+          {conversation.status === 'auto' ? (
+            <Button aria-label="暂停自动回复" icon={<Pause size={16} />} onClick={() => conversationAction(conversation, 'pause')} />
+          ) : (
+            <Button aria-label="恢复自动回复" icon={<Play size={16} />} onClick={() => conversationAction(conversation, 'resume')} />
+          )}
+          <Button aria-label="查看会话" icon={<BotMessageSquare size={16} />} onClick={() => void loadConversationDetail(conversation)} />
+        </Space>
+      ),
+    },
+  ];
+
+  return (
+    <Spin spinning={loading}>
+      <PageHeader
+        title="智能接待"
+        actions={<Button icon={<RefreshCw size={16} />} onClick={() => void reloadTopLevel()}>刷新状态</Button>}
+      />
+
+      <div className="metrics-grid">
+        <Metric label="轮询状态" value={status?.polling_state || '未知'} />
+        <Metric label="已连接账号" value={connections.length} />
+        <Metric label="AI 调用" value={status?.usage.ai_calls || 0} />
+        <Metric label="待人工" value={status?.usage.handoffs || 0} />
+      </div>
+
+      {status?.last_error ? (
+        <Alert type="warning" showIcon message="智能接待尚未正常运行" description={status.last_error} style={{ marginBottom: 16 }} />
+      ) : null}
+      {status?.webhook_conflict ? (
+        <Alert type="error" showIcon message="检测到 Webhook 冲突" description={`当前 URL:${status.webhook_url}。系统不会自动删除外部 Webhook。`} style={{ marginBottom: 16 }} />
+      ) : null}
+
+      <Surface title="OpenAI 兼容模型">
+        <Form form={providerForm} layout="vertical" onFinish={saveProvider}>
+          <Row gutter={16} align="bottom">
+            <Col xs={24} lg={8}>
+              <Form.Item name="base_url" label="Base URL" rules={[{ required: true, message: '请输入 Base URL' }, { type: 'url', message: '请输入有效 URL' }]}>
+                <Input placeholder="https://api.example.com/v1" />
+              </Form.Item>
+            </Col>
+            <Col xs={24} lg={6}>
+              <Form.Item name="api_key" label="API Key" extra={provider?.api_key_configured ? '已配置,留空保持不变' : '尚未配置'}>
+                <Input.Password autoComplete="new-password" />
+              </Form.Item>
+            </Col>
+            <Col xs={24} md={8} lg={4}>
+              <Form.Item name="model" label="模型" rules={[{ required: true, message: '请输入模型名称' }]}>
+                <Input placeholder="gpt-4.1-mini" />
+              </Form.Item>
+            </Col>
+            <Col xs={12} md={4} lg={2}>
+              <Form.Item name="timeout_seconds" label="超时秒数">
+                <InputNumber min={5} max={120} style={{ width: '100%' }} />
+              </Form.Item>
+            </Col>
+            <Col xs={12} md={4} lg={2}>
+              <Form.Item name="max_output_tokens" label="输出上限">
+                <InputNumber min={100} max={2000} style={{ width: '100%' }} />
+              </Form.Item>
+            </Col>
+            <Col xs={24} lg={2}>
+              <Form.Item>
+                <Button type="primary" htmlType="submit" icon={<Save size={16} />} block>保存</Button>
+              </Form.Item>
+            </Col>
+          </Row>
+        </Form>
+        <Space wrap>
+          <Tag color={provider?.configured ? 'success' : 'warning'}>{provider?.configured ? '模型已配置' : '模型未配置'}</Tag>
+          <Tag color={bot?.identity?.can_connect_to_business ? 'success' : 'warning'}>
+            {bot?.identity?.can_connect_to_business ? 'BotFather Business Mode 已开启' : '需要在 BotFather 开启 Business Mode'}
+          </Tag>
+          <Button
+            icon={<FlaskConical size={16} />}
+            disabled={!provider?.configured}
+            onClick={() =>
+              Modal.confirm({
+                title: '测试模型连接?',
+                okText: '开始测试',
+                cancelText: '取消',
+                onOk: async () => {
+                  const result = await apiRequest<{ model: string }>(
+                    '/business-assistant/model/test',
+                    jsonOptions('POST', { confirm: true }),
+                  );
+                  message.success(`模型连接正常:${result.model}`);
+                },
+              })
+            }
+          >
+            测试模型
+          </Button>
+        </Space>
+      </Surface>
+
+      <div style={{ height: 16 }} />
+      <Surface
+        title="Business 连接账号"
+        actions={connections.length ? (
+          <Select
+            aria-label="当前 Business 连接"
+            value={selectedConnection || undefined}
+            style={{ minWidth: 240 }}
+            options={connections.map((connection) => ({
+              value: connection.connection_id,
+              label: connectionName(connection),
+            }))}
+            onChange={setSelectedConnection}
+          />
+        ) : undefined}
+      >
+        {!connections.length ? (
+          <Empty
+            description={
+              <span>
+                尚无连接。请先在 BotFather 开启 Business Mode,再由账号本人进入
+                “设置 → Telegram Business/聊天自动化 → Chatbots”连接当前 Bot。
+              </span>
+            }
+          />
+        ) : activeConnection ? (
+          <Descriptions column={{ xs: 1, md: 2, lg: 4 }} size="small" bordered>
+            <Descriptions.Item label="账号">{connectionName(activeConnection)}</Descriptions.Item>
+            <Descriptions.Item label="连接状态">
+              <Tag color={activeConnection.is_enabled ? 'success' : 'default'}>{activeConnection.is_enabled ? '已连接' : '已停用'}</Tag>
+            </Descriptions.Item>
+            <Descriptions.Item label="回复权限">
+              <Tag color={activeConnection.rights.can_reply ? 'success' : 'error'}>{activeConnection.rights.can_reply ? '允许回复' : '未授权'}</Tag>
+            </Descriptions.Item>
+            <Descriptions.Item label="读取权限">
+              <Tag color={activeConnection.rights.can_read_messages ? 'success' : 'warning'}>
+                {activeConnection.rights.can_read_messages ? '允许标记已读' : '未授权'}
+              </Tag>
+            </Descriptions.Item>
+            <Descriptions.Item label="最后事件">
+              {activeConnection.last_event_at
+                ? dayjs(activeConnection.last_event_at).format('YYYY-MM-DD HH:mm:ss')
+                : '—'}
+            </Descriptions.Item>
+          </Descriptions>
+        ) : null}
+      </Surface>
+
+      {activeConnection && settings ? (
+        <>
+          <div style={{ height: 16 }} />
+          <Tabs
+            items={[
+              {
+                key: 'settings',
+                label: '接待设置',
+                children: (
+                  <Surface>
+                    <Form form={settingsForm} layout="vertical" onFinish={saveSettings}>
+                      <Row gutter={16}>
+                        <Col xs={24} md={8}>
+                          <Form.Item name="assistant_enabled" label="自动接待" valuePropName="checked">
+                            <Switch checkedChildren="开启" unCheckedChildren="关闭" />
+                          </Form.Item>
+                        </Col>
+                        <Col xs={12} md={4}>
+                          <Form.Item name="account_daily_limit" label="账号每日额度"><InputNumber min={1} max={100000} style={{ width: '100%' }} /></Form.Item>
+                        </Col>
+                        <Col xs={12} md={4}>
+                          <Form.Item name="customer_daily_limit" label="客户每日额度"><InputNumber min={1} max={1000} style={{ width: '100%' }} /></Form.Item>
+                        </Col>
+                        <Col xs={12} md={4}>
+                          <Form.Item name="human_pause_hours" label="人工暂停小时"><InputNumber min={1} max={168} style={{ width: '100%' }} /></Form.Item>
+                        </Col>
+                        <Col xs={12} md={4}>
+                          <Form.Item name="tone" label="接待语气">
+                            <Select options={[{ value: 'professional', label: '专业' }, { value: 'friendly', label: '友好' }, { value: 'concise', label: '简洁' }]} />
+                          </Form.Item>
+                        </Col>
+                        <Col xs={24}>
+                          <Form.Item name="system_prompt" label="接待规则" rules={[{ required: true }]}>
+                            <Input.TextArea rows={4} maxLength={4000} showCount />
+                          </Form.Item>
+                        </Col>
+                        <Col xs={24} md={8}>
+                          <Form.Item name="notification_destination" label="人工通知目的地">
+                            <Select options={[{ value: 'owner', label: '账号本人' }, { value: 'ops', label: '运营群' }, { value: 'both', label: '本人和运营群' }]} />
+                          </Form.Item>
+                        </Col>
+                        <Col xs={24} md={8}>
+                          <Form.Item name="ops_group_id" label="运营群 ID" extra="选择运营群或双通知时填写。"><Input /></Form.Item>
+                        </Col>
+                        <Col xs={24} md={8}>
+                          <Form.Item name="timezone" label="时区"><Input placeholder="Asia/Shanghai" /></Form.Item>
+                        </Col>
+                        <Col xs={24} md={8}>
+                          <Form.Item name="digest_enabled" label="每日运营简报" valuePropName="checked"><Switch /></Form.Item>
+                        </Col>
+                        <Col xs={24} md={8}>
+                          <Form.Item name="digest_time" label="简报时间" extra="24 小时制 HH:MM"><Input placeholder="09:00" /></Form.Item>
+                        </Col>
+                        <Col xs={24}>
+                          <Form.Item name="handoff_message" label="转人工提示"><Input.TextArea rows={2} maxLength={1000} /></Form.Item>
+                        </Col>
+                        <Col xs={24}>
+                          <Form.Item name="unsupported_message" label="非文本提示"><Input.TextArea rows={2} maxLength={1000} /></Form.Item>
+                        </Col>
+                      </Row>
+                      <Button type="primary" htmlType="submit" icon={<Save size={16} />}>保存接待设置</Button>
+                    </Form>
+                  </Surface>
+                ),
+              },
+              {
+                key: 'knowledge',
+                label: `知识库 (${knowledge.total})`,
+                children: (
+                  <Surface
+                    actions={
+                      <Space wrap>
+                        <Input.Search
+                          aria-label="搜索知识库"
+                          allowClear
+                          placeholder="问题、关键词或标签"
+                          onSearch={(value) => {
+                            setKnowledgeQuery(value);
+                            void loadConnectionData(selectedConnection, 1, { knowledgeQuery: value });
+                          }}
+                        />
+                        <Button type="primary" icon={<CirclePlus size={16} />} onClick={() => openKnowledge()}>新建知识</Button>
+                      </Space>
+                    }
+                  >
+                    <div className="table-wrap">
+                      <Table rowKey="entry_id" columns={knowledgeColumns} dataSource={knowledge.items} pagination={{ current: knowledge.page, pageSize: knowledge.page_size, total: knowledge.total, onChange: (page) => void loadConnectionData(selectedConnection, page) }} scroll={{ x: 900 }} locale={{ emptyText: '尚无知识条目' }} />
+                    </div>
+                    <div style={{ marginTop: 20 }}>
+                      <Typography.Title level={5}>测试问答</Typography.Title>
+                      <Space.Compact style={{ width: '100%' }}>
+                        <Input value={testQuestion} onChange={(event) => setTestQuestion(event.target.value)} placeholder="输入客户可能提出的问题" onPressEnter={() => void runKnowledgeTest()} />
+                        <Button type="primary" icon={<FlaskConical size={16} />} onClick={() => void runKnowledgeTest()}>测试</Button>
+                      </Space.Compact>
+                      {testResult ? (
+                        <Alert type={testResult.action === 'handoff' ? 'warning' : 'success'} showIcon message={`处理结果:${String(testResult.action || 'unknown')}`} description={String(testResult.reply || testResult.reason || '')} style={{ marginTop: 12 }} />
+                      ) : null}
+                    </div>
+                  </Surface>
+                ),
+              },
+              {
+                key: 'conversations',
+                label: `会话台 (${conversations.total})`,
+                children: (
+                  <Surface
+                    actions={
+                      <Select
+                        aria-label="会话状态"
+                        allowClear
+                        placeholder="全部状态"
+                        value={conversationStatus || undefined}
+                        style={{ width: 160 }}
+                        options={Object.entries(conversationLabels).map(([value, label]) => ({ value, label }))}
+                        onChange={(value) => setConversationStatus(value || '')}
+                      />
+                    }
+                  >
+                    <div className="metrics-grid" style={{ marginBottom: 16 }}>
+                      <Metric label="客户" value={usage?.customers || 0} />
+                      <Metric label="AI 调用" value={usage?.ai_calls || 0} />
+                      <Metric label="待人工" value={usage?.handoffs || 0} />
+                      <Metric label="人工接管" value={usage?.human_paused || 0} />
+                    </div>
+                    <div className="table-wrap">
+                      <Table rowKey="conversation_id" columns={conversationColumns} dataSource={conversations.items} pagination={{ current: conversations.page, pageSize: conversations.page_size, total: conversations.total, onChange: (page) => void loadConnectionData(selectedConnection, page) }} scroll={{ x: 980 }} locale={{ emptyText: '暂无客户会话' }} />
+                    </div>
+                  </Surface>
+                ),
+              },
+            ]}
+          />
+        </>
+      ) : null}
+
+      <Modal title={knowledgeTarget ? '编辑知识条目' : '新建知识条目'} open={knowledgeOpen} onCancel={() => setKnowledgeOpen(false)} onOk={() => knowledgeForm.submit()} okText="继续" cancelText="取消" width={720} destroyOnHidden>
+        <Form form={knowledgeForm} layout="vertical" onFinish={saveKnowledge} preserve={false}>
+          <Form.Item name="question" label="标准问题" rules={[{ required: true, message: '请输入标准问题' }]}><Input maxLength={300} /></Form.Item>
+          <Form.Item name="aliases" label="相似问法"><Select mode="tags" tokenSeparators={[',', ',']} placeholder="输入后按回车" /></Form.Item>
+          <Form.Item name="keywords" label="匹配关键词"><Select mode="tags" tokenSeparators={[',', ',']} placeholder="建议填写客户消息中会出现的核心词" /></Form.Item>
+          <Form.Item name="answer" label="标准答案" rules={[{ required: true, message: '请输入标准答案' }]}><Input.TextArea rows={6} maxLength={4000} showCount /></Form.Item>
+          <Form.Item name="tags" label="标签"><Select mode="tags" tokenSeparators={[',', ',']} /></Form.Item>
+          <Row gutter={16}>
+            <Col xs={12}><Form.Item name="priority" label="优先级"><InputNumber min={-1000} max={1000} style={{ width: '100%' }} /></Form.Item></Col>
+            <Col xs={12}><Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item></Col>
+          </Row>
+        </Form>
+      </Modal>
+
+      <Drawer title={conversationDetail ? `客户会话 · ${customerName(conversationDetail)}` : '客户会话'} open={Boolean(conversationDetail)} onClose={() => setConversationDetail(null)} width={680} extra={conversationDetail ? <Space wrap><Button icon={conversationDetail.status === 'auto' ? <Pause size={16} /> : <Play size={16} />} onClick={() => conversationAction(conversationDetail, conversationDetail.status === 'auto' ? 'pause' : 'resume')}>{conversationDetail.status === 'auto' ? '暂停' : '恢复'}</Button>{conversationDetail.status !== 'closed' ? <Button icon={<CircleCheck size={16} />} onClick={() => conversationAction(conversationDetail, 'close')}>关闭</Button> : null}<Button danger icon={<Trash2 size={16} />} onClick={() => conversationAction(conversationDetail, 'clear')}>清空</Button></Space> : null}>
+        <Spin spinning={detailLoading}>
+          {conversationDetail ? (
+            <>
+              <Descriptions column={1} size="small" bordered>
+                <Descriptions.Item label="状态"><Tag color={conversationColors[conversationDetail.status]}>{conversationLabels[conversationDetail.status]}</Tag></Descriptions.Item>
+                <Descriptions.Item label="摘要">{conversationDetail.summary || '暂无摘要'}</Descriptions.Item>
+                <Descriptions.Item label="转人工原因">{conversationDetail.handoff_reason || '—'}</Descriptions.Item>
+              </Descriptions>
+              <div style={{ marginTop: 16 }}>
+                <Space direction="vertical" size={10} style={{ width: '100%' }}>
+                  {(conversationDetail.messages || []).map((item) => (
+                    <div key={item.message_key} style={{ padding: 12, border: '1px solid #e2e8f0', borderRadius: 6, background: item.direction === 'incoming' ? '#fff' : '#f6f8fa' }}>
+                      <Space size={8}>
+                        <Tag>{item.direction === 'incoming' ? '客户' : item.direction === 'human' ? '人工' : 'AI'}</Tag>
+                        <Typography.Text type="secondary">{dayjs(item.created_at).format('YYYY-MM-DD HH:mm:ss')}</Typography.Text>
+                      </Space>
+                      <Typography.Paragraph style={{ margin: '8px 0 0', whiteSpace: 'pre-wrap' }}>{item.text}</Typography.Paragraph>
+                    </div>
+                  ))}
+                  {!conversationDetail.messages?.length ? <Empty description="原始消息已清理或尚无消息" /> : null}
+                </Space>
+              </div>
+            </>
+          ) : null}
+        </Spin>
+      </Drawer>
+    </Spin>
+  );
+}

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

@@ -42,13 +42,128 @@ export interface BotProfile {
   message_dump_chat: string;
   message_dump_chat: string;
   role_ids: string[];
   role_ids: string[];
   permissions: string[];
   permissions: string[];
-  identity?: { id: string; username: string; name: string } | null;
+  identity?: {
+    id: string;
+    username: string;
+    name: string;
+    can_connect_to_business?: boolean;
+  } | null;
+  business_assistant?: AssistantProviderConfig;
   ready_to_connect: boolean;
   ready_to_connect: boolean;
   created_at?: string;
   created_at?: string;
   updated_at?: string;
   updated_at?: string;
   runtime: BotRuntime;
   runtime: BotRuntime;
 }
 }
 
 
+export interface AssistantProviderConfig {
+  base_url: string;
+  api_key_configured: boolean;
+  model: string;
+  timeout_seconds: number;
+  max_output_tokens: number;
+  configured: boolean;
+}
+
+export interface AssistantRuntimeStatus {
+  polling_state: string;
+  business_mode_supported: boolean;
+  webhook_conflict: boolean;
+  webhook_url: string;
+  model_configured: boolean;
+  last_error?: string;
+  last_poll_at?: string;
+  usage: AssistantUsage;
+}
+
+export interface AssistantUsage {
+  ai_calls: number;
+  customers: number;
+  conversations: number;
+  handoffs: number;
+  human_paused: number;
+}
+
+export interface AssistantAccountSettings {
+  connection_id: string;
+  assistant_enabled: boolean;
+  system_prompt: string;
+  language: string;
+  tone: 'professional' | 'friendly' | 'concise';
+  account_daily_limit: number;
+  customer_daily_limit: number;
+  human_pause_hours: number;
+  notification_destination: 'owner' | 'ops' | 'both';
+  ops_group_id: string;
+  timezone: string;
+  digest_enabled: boolean;
+  digest_time: string;
+  handoff_message: string;
+  unsupported_message: string;
+}
+
+export interface AssistantConnection {
+  connection_id: string;
+  user: {
+    id: string;
+    username?: string;
+    first_name?: string;
+    last_name?: string;
+  };
+  user_chat_id: string;
+  rights: Record<string, boolean>;
+  is_enabled: boolean;
+  connected_at?: string;
+  last_event_at?: string;
+  settings: AssistantAccountSettings;
+}
+
+export interface AssistantKnowledgeEntry {
+  entry_id: string;
+  connection_id: string;
+  question: string;
+  aliases: string[];
+  keywords: string[];
+  answer: string;
+  tags: string[];
+  priority: number;
+  enabled: boolean;
+  updated_at?: string;
+}
+
+export type AssistantConversationStatus =
+  | 'auto'
+  | 'handoff'
+  | 'human_paused'
+  | 'closed';
+
+export interface AssistantConversationMessage {
+  message_key: string;
+  direction: 'incoming' | 'assistant' | 'human';
+  telegram_message_id: string;
+  text: string;
+  created_at: string;
+  metadata?: Record<string, unknown>;
+}
+
+export interface AssistantConversation {
+  conversation_id: string;
+  connection_id: string;
+  chat_id: string;
+  customer?: {
+    id: string;
+    username?: string;
+    first_name?: string;
+    last_name?: string;
+  };
+  status: AssistantConversationStatus;
+  summary?: string;
+  handoff_reason?: string;
+  paused_until?: string | null;
+  last_message_at?: string;
+  updated_at?: string;
+  messages?: AssistantConversationMessage[];
+}
+
 export interface TelegramSettings {
 export interface TelegramSettings {
   api_id?: number | null;
   api_id?: number | null;
   api_hash_configured: boolean;
   api_hash_configured: boolean;

+ 63 - 1
admin-web/tests/e2e/admin.spec.ts

@@ -38,6 +38,7 @@ const permissions = [
   'giveaways.manage',
   'giveaways.manage',
   'karma.manage',
   'karma.manage',
   'teacher_directory.manage',
   'teacher_directory.manage',
+  'business_assistant.manage',
   'media.upload',
   'media.upload',
 ];
 ];
 
 
@@ -52,7 +53,15 @@ const bot = {
   message_dump_chat: '-1001234567890',
   message_dump_chat: '-1001234567890',
   role_ids: ['full_access'],
   role_ids: ['full_access'],
   permissions,
   permissions,
-  identity: { id: '999', username: 'test_bot', name: 'Test Bot' },
+  identity: { id: '999', username: 'test_bot', name: 'Test Bot', can_connect_to_business: true },
+  business_assistant: {
+    base_url: 'https://models.example.com/v1',
+    api_key_configured: true,
+    model: 'support-model',
+    timeout_seconds: 30,
+    max_output_tokens: 600,
+    configured: true,
+  },
   ready_to_connect: true,
   ready_to_connect: true,
   runtime: { state: 'running' },
   runtime: { state: 'running' },
 };
 };
@@ -264,6 +273,53 @@ test.beforeEach(async ({ page }) => {
           },
           },
         ],
         ],
       };
       };
+    } else if (path.endsWith('/business-assistant/status')) {
+      data = {
+        polling_state: 'running',
+        business_mode_supported: true,
+        webhook_conflict: false,
+        webhook_url: '',
+        model_configured: true,
+        usage: { ai_calls: 8, customers: 3, conversations: 3, handoffs: 1, human_paused: 1 },
+      };
+    } else if (path.endsWith('/business-assistant/connections')) {
+      data = {
+        items: [{
+          connection_id: 'conn-a',
+          user: { id: '900', username: 'owner', first_name: '店主' },
+          user_chat_id: '900',
+          rights: { can_reply: true, can_read_messages: true },
+          is_enabled: true,
+          last_event_at: '2026-08-14T08:00:00Z',
+        }],
+        total: 1,
+        page: 1,
+        page_size: 100,
+      };
+    } else if (path.endsWith('/business-assistant/settings')) {
+      data = {
+        connection_id: 'conn-a',
+        assistant_enabled: true,
+        system_prompt: '只依据知识库回答。',
+        language: 'zh-CN',
+        tone: 'professional',
+        account_daily_limit: 200,
+        customer_daily_limit: 20,
+        human_pause_hours: 24,
+        notification_destination: 'both',
+        ops_group_id: '-100123',
+        timezone: 'Asia/Shanghai',
+        digest_enabled: false,
+        digest_time: '09:00',
+        handoff_message: '请稍候,已转人工。',
+        unsupported_message: '该消息需要人工处理。',
+      };
+    } else if (path.endsWith('/business-assistant/knowledge')) {
+      data = { items: [], total: 0, page: 1, page_size: 20 };
+    } else if (path.endsWith('/business-assistant/conversations')) {
+      data = { items: [], total: 0, page: 1, page_size: 20 };
+    } else if (path.endsWith('/business-assistant/usage')) {
+      data = { ai_calls: 8, customers: 3, conversations: 3, handoffs: 1, human_paused: 1 };
     } else if (path.endsWith('/settings')) {
     } else if (path.endsWith('/settings')) {
       data = {
       data = {
         web_address: 'http://127.0.0.1:8088/admin',
         web_address: 'http://127.0.0.1:8088/admin',
@@ -314,6 +370,7 @@ test('登录与主要管理视图在不同视口无页面级横向滚动', async
     ['/admin/points', '积分管理'],
     ['/admin/points', '积分管理'],
     ['/admin/directory', '技师与附近'],
     ['/admin/directory', '技师与附近'],
     ['/admin/services', '技师咨询与评价'],
     ['/admin/services', '技师咨询与评价'],
+    ['/admin/business-assistant', '智能接待'],
     ['/admin/audit', '操作日志'],
     ['/admin/audit', '操作日志'],
     ['/admin/settings', '系统设置'],
     ['/admin/settings', '系统设置'],
   ] as const) {
   ] as const) {
@@ -332,6 +389,11 @@ test('登录与主要管理视图在不同视口无页面级横向滚动', async
       await expect(page.getByText('咨询平台模式')).toBeVisible();
       await expect(page.getByText('咨询平台模式')).toBeVisible();
       await page.screenshot({ path: testInfo.outputPath('services.png'), fullPage: true });
       await page.screenshot({ path: testInfo.outputPath('services.png'), fullPage: true });
     }
     }
+    if (path === '/admin/business-assistant') {
+      await expect(page.getByText('允许回复')).toBeVisible();
+      await expect(page.getByText('允许标记已读')).toBeVisible();
+      await page.screenshot({ path: testInfo.outputPath('business-assistant.png'), fullPage: true });
+    }
     expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
     expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
   }
   }
 });
 });

+ 162 - 0
admin-web/tests/unit/BusinessAssistant.test.tsx

@@ -0,0 +1,162 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, expect, test, vi } from 'vitest';
+
+import BusinessAssistantPage from '@/pages/BusinessAssistant';
+import { apiRequest } from '@/services/api';
+
+vi.mock('@/services/api', () => ({
+  apiRequest: vi.fn(),
+  jsonOptions: vi.fn((method, body) => ({ method, body: JSON.stringify(body) })),
+}));
+
+vi.mock('@/contexts/BotAccess', () => ({
+  useBotAccess: () => ({
+    bot: {
+      bot_id: 'primary',
+      label: 'Test Bot',
+      enabled: true,
+      bot_token_configured: true,
+      sudo_users_id: [],
+      log_group_id: '0',
+      gban_log_group_id: '0',
+      message_dump_chat: '0',
+      role_ids: ['business_assistant_manager'],
+      permissions: ['business_assistant.manage'],
+      identity: {
+        id: '999',
+        username: 'business_bot',
+        name: 'Business Bot',
+        can_connect_to_business: true,
+      },
+      business_assistant: {
+        base_url: 'https://models.example.com/v1',
+        api_key_configured: true,
+        model: 'support-model',
+        timeout_seconds: 30,
+        max_output_tokens: 600,
+        configured: true,
+      },
+      ready_to_connect: true,
+      runtime: { state: 'running' },
+    },
+  }),
+}));
+
+const settings = {
+  connection_id: 'conn-a',
+  assistant_enabled: true,
+  system_prompt: '只依据知识库回答。',
+  language: 'zh-CN',
+  tone: 'professional',
+  account_daily_limit: 200,
+  customer_daily_limit: 20,
+  human_pause_hours: 24,
+  notification_destination: 'both',
+  ops_group_id: '-100123',
+  timezone: 'Asia/Shanghai',
+  digest_enabled: false,
+  digest_time: '09:00',
+  handoff_message: '请稍候,已转人工。',
+  unsupported_message: '该消息需要人工处理。',
+};
+
+beforeEach(() => {
+  vi.mocked(apiRequest).mockClear();
+  vi.mocked(apiRequest).mockImplementation(async (path) => {
+    if (path === '/business-assistant/status') {
+      return {
+        polling_state: 'running',
+        business_mode_supported: true,
+        webhook_conflict: false,
+        webhook_url: '',
+        model_configured: true,
+        usage: { ai_calls: 8, customers: 3, conversations: 3, handoffs: 1, human_paused: 1 },
+      };
+    }
+    if (path.startsWith('/business-assistant/connections')) {
+      return {
+        items: [
+          {
+            connection_id: 'conn-a',
+            user: { id: '900', username: 'owner', first_name: '店主' },
+            user_chat_id: '900',
+            rights: { can_reply: true, can_read_messages: true },
+            is_enabled: true,
+            last_event_at: '2026-08-14T08:00:00Z',
+            settings,
+          },
+        ],
+        total: 1,
+        page: 1,
+        page_size: 100,
+      };
+    }
+    if (path.startsWith('/business-assistant/settings')) return settings;
+    if (path.startsWith('/business-assistant/knowledge')) {
+      return {
+        items: [
+          {
+            entry_id: 'entry-1',
+            connection_id: 'conn-a',
+            question: '营业时间是什么?',
+            aliases: ['几点营业'],
+            keywords: ['营业时间'],
+            answer: '每天 09:00 至 18:00 营业。',
+            tags: ['营业'],
+            priority: 10,
+            enabled: true,
+          },
+        ],
+        total: 1,
+        page: 1,
+        page_size: 20,
+      };
+    }
+    if (path.startsWith('/business-assistant/conversations')) {
+      return {
+        items: [
+          {
+            conversation_id: 'conversation-1',
+            connection_id: 'conn-a',
+            chat_id: '501',
+            customer: { id: '501', first_name: '客户甲' },
+            status: 'handoff',
+            summary: '客户询问退款。',
+            handoff_reason: 'sensitive_request',
+            last_message_at: '2026-08-14T08:01:00Z',
+          },
+        ],
+        total: 1,
+        page: 1,
+        page_size: 20,
+      };
+    }
+    if (path.startsWith('/business-assistant/usage')) {
+      return { ai_calls: 8, customers: 3, conversations: 3, handoffs: 1, human_paused: 1 };
+    }
+    throw new Error(`Unexpected request: ${path}`);
+  });
+});
+
+test('展示运行状态、连接权限,并用最新搜索词查询知识库', async () => {
+  render(<BusinessAssistantPage />);
+
+  expect(screen.getByRole('heading', { name: '智能接待' })).toBeInTheDocument();
+  expect((await screen.findAllByText('店主')).length).toBeGreaterThan(0);
+  expect(screen.getByText('允许回复')).toBeInTheDocument();
+  expect(screen.getByText('允许标记已读')).toBeInTheDocument();
+
+  fireEvent.click(await screen.findByRole('tab', { name: /知识库/ }));
+  expect(await screen.findByText('营业时间是什么?')).toBeInTheDocument();
+  const search = screen.getByLabelText('搜索知识库');
+  fireEvent.change(search, { target: { value: '配送' } });
+  fireEvent.keyDown(search, { key: 'Enter', code: 'Enter' });
+
+  await waitFor(() => {
+    expect(
+      vi.mocked(apiRequest).mock.calls.some(([path]) =>
+        String(path).includes('query=%E9%85%8D%E9%80%81'),
+      ),
+    ).toBe(true);
+  });
+});

+ 7 - 0
config.env.template

@@ -21,6 +21,13 @@ MONGO_URL=mongodb://127.0.0.1:27017/wbb
 
 
 ARQ_API_URL=https://arq.hamker.dev
 ARQ_API_URL=https://arq.hamker.dev
 ARQ_API_KEY=
 ARQ_API_KEY=
+
+# OpenAI 兼容配置由后台按 Bot 保存;以下变量仅供单工作进程本地调试。
+BUSINESS_ASSISTANT_OPENAI_BASE_URL=
+BUSINESS_ASSISTANT_OPENAI_API_KEY=
+BUSINESS_ASSISTANT_OPENAI_MODEL=
+BUSINESS_ASSISTANT_OPENAI_TIMEOUT_SECONDS=30
+BUSINESS_ASSISTANT_OPENAI_MAX_OUTPUT_TOKENS=600
 LOG_MENTIONS=0
 LOG_MENTIONS=0
 RSS_DELAY=300
 RSS_DELAY=300
 PM_PERMIT=1
 PM_PERMIT=1

+ 2 - 0
pyproject.toml

@@ -61,6 +61,7 @@ include = [
     "wbb/services/**/*.py",
     "wbb/services/**/*.py",
     "wbb/modules/admin_panel.py",
     "wbb/modules/admin_panel.py",
     "wbb/modules/admin_web.py",
     "wbb/modules/admin_web.py",
+    "wbb/modules/business_assistant.py",
     "wbb/modules/chat_watcher.py",
     "wbb/modules/chat_watcher.py",
     "wbb/modules/giveaway.py",
     "wbb/modules/giveaway.py",
     "wbb/modules/karma.py",
     "wbb/modules/karma.py",
@@ -68,6 +69,7 @@ include = [
     "wbb/modules/teacher_directory.py",
     "wbb/modules/teacher_directory.py",
     "wbb/modules/technician_services.py",
     "wbb/modules/technician_services.py",
     "wbb/utils/dbadmin.py",
     "wbb/utils/dbadmin.py",
+    "wbb/utils/dbassistant.py",
     "wbb/utils/dbdirectory.py",
     "wbb/utils/dbdirectory.py",
     "wbb/utils/dbgiveaway.py",
     "wbb/utils/dbgiveaway.py",
     "wbb/utils/dbpoints.py",
     "wbb/utils/dbpoints.py",

+ 16 - 0
sample_config.py

@@ -76,6 +76,22 @@ SERVICE_MINI_APP_AUTH_MAX_AGE_SECONDS = _env_int(
     3600,
     3600,
 )
 )
 
 
+BUSINESS_ASSISTANT_OPENAI_BASE_URL = os.environ.get(
+    "BUSINESS_ASSISTANT_OPENAI_BASE_URL", ""
+)
+BUSINESS_ASSISTANT_OPENAI_API_KEY = os.environ.get(
+    "BUSINESS_ASSISTANT_OPENAI_API_KEY", ""
+)
+BUSINESS_ASSISTANT_OPENAI_MODEL = os.environ.get(
+    "BUSINESS_ASSISTANT_OPENAI_MODEL", ""
+)
+BUSINESS_ASSISTANT_OPENAI_TIMEOUT_SECONDS = _env_int(
+    "BUSINESS_ASSISTANT_OPENAI_TIMEOUT_SECONDS", 30
+)
+BUSINESS_ASSISTANT_OPENAI_MAX_OUTPUT_TOKENS = _env_int(
+    "BUSINESS_ASSISTANT_OPENAI_MAX_OUTPUT_TOKENS", 600
+)
+
 USERBOT_ENABLED = os.environ.get("USERBOT_ENABLED", "False").lower() in ["true", "1"]
 USERBOT_ENABLED = os.environ.get("USERBOT_ENABLED", "False").lower() in ["true", "1"]
 ADMIN_BOOTSTRAP_MODE = os.environ.get("WBB_ADMIN_BOOTSTRAP", "0").lower() in ["true", "1"]
 ADMIN_BOOTSTRAP_MODE = os.environ.get("WBB_ADMIN_BOOTSTRAP", "0").lower() in ["true", "1"]
 SUPERVISOR_MODE = os.environ.get("WBB_SUPERVISOR_MODE", "0").lower() in ["true", "1"]
 SUPERVISOR_MODE = os.environ.get("WBB_SUPERVISOR_MODE", "0").lower() in ["true", "1"]

+ 28 - 1
tests/test_bot_config.py

@@ -21,6 +21,11 @@ def test_multiple_bot_profiles_are_atomic_and_redacted(app_modules, tmp_path):
             "bot_token": first_token,
             "bot_token": first_token,
             "sudo_users_id": "100 200 100",
             "sudo_users_id": "100 200 100",
             "message_dump_chat": -100123,
             "message_dump_chat": -100123,
+            "business_assistant": {
+                "base_url": "https://models.example.com/v1",
+                "api_key": "secret-assistant-key",
+                "model": "support-model",
+            },
         },
         },
     )
     )
     second = config.create_bot_profile(
     second = config.create_bot_profile(
@@ -35,10 +40,19 @@ def test_multiple_bot_profiles_are_atomic_and_redacted(app_modules, tmp_path):
     assert status["bots"][0]["role_ids"] == ["full_access"]
     assert status["bots"][0]["role_ids"] == ["full_access"]
     assert status["bots"][0]["permissions"]
     assert status["bots"][0]["permissions"]
     assert "teacher_directory.manage" in status["bots"][0]["permissions"]
     assert "teacher_directory.manage" in status["bots"][0]["permissions"]
+    assert status["bots"][0]["business_assistant"] == {
+        "base_url": "https://models.example.com/v1",
+        "api_key_configured": True,
+        "model": "support-model",
+        "timeout_seconds": 30,
+        "max_output_tokens": 600,
+        "configured": True,
+    }
     serialized = json.dumps(status)
     serialized = json.dumps(status)
     assert first_token not in serialized
     assert first_token not in serialized
     assert second_token not in serialized
     assert second_token not in serialized
     assert "a" * 32 not in serialized
     assert "a" * 32 not in serialized
+    assert "secret-assistant-key" not in serialized
     assert path.stat().st_mode & 0o777 == 0o600
     assert path.stat().st_mode & 0o777 == 0o600
 
 
     config.update_bot_profile(path, first["bot_id"], {"label": "Renamed", "bot_token": ""})
     config.update_bot_profile(path, first["bot_id"], {"label": "Renamed", "bot_token": ""})
@@ -141,6 +155,11 @@ def test_supervisor_assigns_each_worker_an_isolated_database(app_modules, tmp_pa
         "gban_log_group_id": 0,
         "gban_log_group_id": 0,
         "message_dump_chat": 0,
         "message_dump_chat": 0,
         "permissions": ["points.manage", "giveaways.manage"],
         "permissions": ["points.manage", "giveaways.manage"],
+        "business_assistant": {
+            "base_url": "https://models.example.com/v1",
+            "api_key": "worker-only-key",
+            "model": "support-model",
+        },
     }
     }
 
 
     environment = supervisor._worker_environment(profile, 18088)
     environment = supervisor._worker_environment(profile, 18088)
@@ -148,6 +167,8 @@ def test_supervisor_assigns_each_worker_an_isolated_database(app_modules, tmp_pa
     assert environment["WBB_BOT_PROFILE_ID"] == "bot.one/secondary"
     assert environment["WBB_BOT_PROFILE_ID"] == "bot.one/secondary"
     assert environment["WBB_BOT_DATABASE"] == "wbb_bot_bot_one_secondary"
     assert environment["WBB_BOT_DATABASE"] == "wbb_bot_bot_one_secondary"
     assert environment["WBB_BOT_PERMISSIONS"] == "points.manage,giveaways.manage"
     assert environment["WBB_BOT_PERMISSIONS"] == "points.manage,giveaways.manage"
+    assert environment["BUSINESS_ASSISTANT_OPENAI_API_KEY"] == "worker-only-key"
+    assert environment["BUSINESS_ASSISTANT_OPENAI_MODEL"] == "support-model"
 
 
 
 
 async def test_bot_admin_api_crud_and_token_test_are_redacted(app_modules, monkeypatch):
 async def test_bot_admin_api_crud_and_token_test_are_redacted(app_modules, monkeypatch):
@@ -215,7 +236,12 @@ async def test_bot_admin_api_crud_and_token_test_are_redacted(app_modules, monke
 
 
         async def fake_test_bot_token(_token: str):
         async def fake_test_bot_token(_token: str):
             assert _token == token
             assert _token == token
-            return {"id": "123456", "username": "test_bot", "name": "Test Bot"}
+            return {
+                "id": "123456",
+                "username": "test_bot",
+                "name": "Test Bot",
+                "can_connect_to_business": True,
+            }
 
 
         monkeypatch.setattr(admin_api, "test_bot_token", fake_test_bot_token)
         monkeypatch.setattr(admin_api, "test_bot_token", fake_test_bot_token)
         tested = await client.post(
         tested = await client.post(
@@ -225,6 +251,7 @@ async def test_bot_admin_api_crud_and_token_test_are_redacted(app_modules, monke
         )
         )
         assert tested.status == 200
         assert tested.status == 200
         assert (await tested.json())["data"]["username"] == "test_bot"
         assert (await tested.json())["data"]["username"] == "test_bot"
+        assert (await tested.json())["data"]["can_connect_to_business"] is True
 
 
         listed = await client.get("/api/admin/v1/bots")
         listed = await client.get("/api/admin/v1/bots")
         listed_payload = await listed.json()
         listed_payload = await listed.json()

+ 616 - 0
tests/test_business_assistant.py

@@ -0,0 +1,616 @@
+from __future__ import annotations
+
+import json
+from datetime import UTC, datetime, timedelta
+from types import SimpleNamespace
+
+import pytest
+from aiohttp import CookieJar
+from aiohttp.test_utils import TestClient, TestServer
+
+
+def connection_payload(connection_id: str = "conn-a", *, can_reply: bool = True) -> dict:
+    return {
+        "id": connection_id,
+        "user": {"id": 900, "username": "owner", "first_name": "店主"},
+        "user_chat_id": 900,
+        "date": int(datetime.now(UTC).timestamp()),
+        "rights": {"can_reply": can_reply, "can_read_messages": True},
+        "is_enabled": True,
+    }
+
+
+def customer_message(
+    *,
+    message_id: int,
+    text: str | None = "营业时间是什么?",
+    chat_id: int = 501,
+    sender_id: int = 501,
+    sent_at: datetime | None = None,
+    **values,
+) -> dict:
+    message = {
+        "business_connection_id": "conn-a",
+        "message_id": message_id,
+        "date": int((sent_at or datetime.now(UTC)).timestamp()),
+        "chat": {"id": chat_id, "type": "private"},
+        "from": {"id": sender_id, "username": f"user{sender_id}", "first_name": "客户"},
+        **values,
+    }
+    if text is not None:
+        message["text"] = text
+    return message
+
+
+class FakeProvider:
+    configured = True
+
+    def __init__(self, *, error: Exception | None = None) -> None:
+        self.error = error
+        self.calls: list[dict] = []
+
+    async def decide(self, **values):
+        self.calls.append(values)
+        if self.error:
+            raise self.error
+        entry_id = str(values["knowledge"][0]["entry_id"])
+        return {
+            "action": "answer",
+            "reply": "每天 09:00 至 18:00 营业。",
+            "handoff_reason": "",
+            "matched_entry_ids": [entry_id],
+            "summary": "客户询问营业时间。",
+        }
+
+    async def test_connection(self):
+        return {"ok": True, "model": "test-model", "response_id": "response-1"}
+
+
+class FakeBusinessApi:
+    def __init__(self) -> None:
+        self.sent: list[dict] = []
+        self.read: list[tuple[str, int, int]] = []
+
+    async def send_message(
+        self,
+        chat_id: int,
+        text: str,
+        *,
+        business_connection_id: str = "",
+        reply_markup: dict | None = None,
+    ) -> dict:
+        self.sent.append(
+            {
+                "chat_id": chat_id,
+                "text": text,
+                "business_connection_id": business_connection_id,
+                "reply_markup": reply_markup,
+            }
+        )
+        return {
+            "message_id": 1000 + len(self.sent),
+            "sender_business_bot": {"id": 999},
+        }
+
+    async def read_business_message(
+        self, connection_id: str, chat_id: int, message_id: int
+    ) -> None:
+        self.read.append((connection_id, chat_id, message_id))
+
+
+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")
+    await dbassistant.upsert_business_connection(connection_payload())
+    await dbassistant.update_account_settings("conn-a", {"assistant_enabled": True})
+    knowledge = await dbassistant.create_knowledge_entry(
+        "conn-a",
+        {
+            "question": "营业时间是什么?",
+            "aliases": ["几点营业"],
+            "keywords": ["营业时间", "营业"],
+            "answer": "每天 09:00 至 18:00 营业。",
+            "priority": 10,
+        },
+    )
+    selected_provider = provider or FakeProvider()
+    runtime = service.BusinessAssistantRuntime(
+        token="123456:" + "A" * 30,
+        session=SimpleNamespace(),
+        provider=selected_provider,
+    )
+    runtime.api = FakeBusinessApi()
+    return dbassistant, service, runtime, knowledge
+
+
+async def test_connection_settings_knowledge_quota_and_bot_scope(app_modules):
+    dbassistant = app_modules.load("wbb.utils.dbassistant")
+    await dbassistant.upsert_business_connection(connection_payload("conn-a"))
+    await dbassistant.upsert_business_connection(connection_payload("conn-b"))
+    settings = await dbassistant.update_account_settings(
+        "conn-a",
+        {
+            "assistant_enabled": True,
+            "account_daily_limit": 2,
+            "customer_daily_limit": 1,
+            "timezone": "Asia/Shanghai",
+        },
+    )
+    first = await dbassistant.create_knowledge_entry(
+        "conn-a",
+        {
+            "question": "营业时间",
+            "aliases": ["几点开门"],
+            "keywords": ["开门"],
+            "answer": "九点开门。",
+            "priority": 20,
+        },
+    )
+    await dbassistant.create_knowledge_entry(
+        "conn-b",
+        {"question": "营业时间", "keywords": ["开门"], "answer": "十点开门。"},
+    )
+    await app_modules.wbb.db.business_assistant_knowledge.insert_one(
+        {
+            "bot_id": "foreign-bot",
+            "entry_id": "foreign-entry",
+            "connection_id": "conn-a",
+            "question": "营业时间",
+            "aliases": [],
+            "keywords": ["开门"],
+            "answer": "不应跨 Bot 返回。",
+            "priority": 1000,
+            "enabled": True,
+            "updated_at": datetime.now(UTC),
+        }
+    )
+
+    matched = await dbassistant.match_knowledge("conn-a", "你们几点开门?")
+    assert [item["entry_id"] for item in matched] == [first["entry_id"]]
+    items, total = await dbassistant.list_knowledge_entries("conn-a")
+    assert total == 1
+    assert items[0]["answer"] == "九点开门。"
+
+    await app_modules.wbb.db.business_assistant_connections.insert_one(
+        {
+            "bot_id": "foreign-bot",
+            "connection_id": "foreign-connection",
+            "updated_at": datetime.now(UTC),
+        }
+    )
+    connections, total = await dbassistant.list_business_connections(page_size=100)
+    assert total == 2
+    assert {item["connection_id"] for item in connections} == {"conn-a", "conn-b"}
+
+    assert await dbassistant.reserve_ai_usage("conn-a", 101, settings) == (True, "")
+    assert await dbassistant.reserve_ai_usage("conn-a", 101, settings) == (
+        False,
+        "customer_daily_limit",
+    )
+    assert await dbassistant.reserve_ai_usage("conn-a", 102, settings) == (True, "")
+    assert await dbassistant.reserve_ai_usage("conn-a", 103, settings) == (
+        False,
+        "account_daily_limit",
+    )
+    usage = await dbassistant.usage_metrics(connection_id="conn-a")
+    assert usage["ai_calls"] == 2
+    assert usage["customers"] == 2
+
+    with pytest.raises(dbassistant.AssistantDataError) as error:
+        dbassistant.normalize_account_settings({"timezone": "invalid/timezone"})
+    assert error.value.code == "invalid_setting"
+
+
+def test_ai_contract_and_reply_window_validation(app_modules):
+    service = app_modules.load("wbb.services.business_assistant")
+    parsed = service.parse_ai_decision(
+        json.dumps(
+            {
+                "action": "answer",
+                "reply": "标准答案",
+                "matched_entry_ids": ["entry-1", "foreign"],
+                "summary": "摘要",
+            }
+        ),
+        allowed_entry_ids={"entry-1"},
+    )
+    assert parsed["matched_entry_ids"] == ["entry-1"]
+
+    with pytest.raises(service.AssistantProviderError):
+        service.parse_ai_decision(
+            '{"action":"answer","reply":"没有引用"}', allowed_entry_ids={"entry-1"}
+        )
+    now = datetime.now(UTC)
+    assert service.business_reply_window_open(
+        {"date": int((now - timedelta(hours=23)).timestamp())}, now=now
+    )
+    assert not service.business_reply_window_open(
+        {"date": int((now - timedelta(hours=25)).timestamp())}, now=now
+    )
+    assert not service.business_reply_window_open({}, now=now)
+
+
+async def test_business_updates_are_idempotent_and_manual_reply_pauses(app_modules):
+    dbassistant, _service, runtime, knowledge = await prepare_runtime(app_modules)
+    update = {
+        "update_id": 11,
+        "business_message": customer_message(message_id=1),
+    }
+    await runtime.process_update(update)
+    await runtime.process_update(update)
+
+    assert len(runtime.api.sent) == 1
+    assert runtime.api.sent[0]["business_connection_id"] == "conn-a"
+    assert runtime.api.read == [("conn-a", 501, 1)]
+    assert len(runtime.provider.calls) == 1
+    assert runtime.provider.calls[0]["knowledge"][0]["entry_id"] == knowledge["entry_id"]
+    conversation = await dbassistant.get_conversation_by_chat("conn-a", 501)
+    assert conversation["summary"] == "客户询问营业时间。"
+    messages = await dbassistant.recent_conversation_messages(
+        conversation["conversation_id"], limit=10
+    )
+    assert [item["direction"] for item in messages] == ["incoming", "assistant"]
+    assert messages[0]["expires_at"] - messages[0]["created_at"] == timedelta(days=30)
+    message_indexes = await app_modules.wbb.db.business_assistant_messages.index_information()
+    assert message_indexes["expires_at_1"]["expireAfterSeconds"] == 0
+
+    await runtime.process_update(
+        {
+            "update_id": 12,
+            "business_message": customer_message(
+                message_id=2, text="账号本人回复", sender_id=900
+            ),
+        }
+    )
+    paused = await dbassistant.get_conversation(conversation["conversation_id"])
+    assert paused["status"] == "human_paused"
+    assert paused["handoff_reason"] == "human_reply_detected"
+    assert paused["customer"]["id"] == 501
+
+    await runtime.process_update(
+        {
+            "update_id": 13,
+            "business_message": customer_message(
+                message_id=3,
+                sender_business_bot={"id": 999},
+            ),
+        }
+    )
+    await runtime.process_update(
+        {
+            "update_id": 14,
+            "business_message": customer_message(
+                message_id=4,
+                is_from_offline=True,
+            ),
+        }
+    )
+    assert len(runtime.api.sent) == 1
+
+
+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(
+        "conn-a",
+        {
+            "notification_destination": "both",
+            "ops_group_id": -100123,
+            "account_daily_limit": 1,
+            "customer_daily_limit": 1,
+        },
+    )
+
+    await runtime.process_update(
+        {
+            "update_id": 21,
+            "business_message": customer_message(
+                message_id=1, text="我要退款", chat_id=601, sender_id=601
+            ),
+        }
+    )
+    sensitive = await dbassistant.get_conversation_by_chat("conn-a", 601)
+    assert sensitive["status"] == "handoff"
+    assert sensitive["handoff_reason"] == "sensitive_request"
+    assert runtime.api.sent[-1]["business_connection_id"] == ""
+    assert "message_id=1" in runtime.api.sent[-1]["text"]
+    assert {item["chat_id"] for item in runtime.api.sent[-2:]} == {900, -100123}
+
+    await runtime.process_update(
+        {
+            "update_id": 22,
+            "business_message": customer_message(
+                message_id=2, text=None, chat_id=602, sender_id=602, photo=[{"file_id": "x"}]
+            ),
+        }
+    )
+    unsupported = await dbassistant.get_conversation_by_chat("conn-a", 602)
+    assert unsupported["handoff_reason"] == "unsupported_message"
+
+    runtime.provider = FakeProvider(error=service.AssistantProviderError("模型超时"))
+    await runtime.process_update(
+        {
+            "update_id": 23,
+            "business_message": customer_message(
+                message_id=3, chat_id=603, sender_id=603
+            ),
+        }
+    )
+    provider_failed = await dbassistant.get_conversation_by_chat("conn-a", 603)
+    assert provider_failed["handoff_reason"] == "provider_error"
+
+    await runtime.process_update(
+        {
+            "update_id": 230,
+            "business_message": customer_message(
+                message_id=30, chat_id=605, sender_id=605
+            ),
+        }
+    )
+    quota_handoff = await dbassistant.get_conversation_by_chat("conn-a", 605)
+    assert quota_handoff["handoff_reason"] == "account_daily_limit"
+
+    sent_before = len(runtime.api.sent)
+    await runtime.process_update(
+        {
+            "update_id": 24,
+            "business_message": customer_message(
+                message_id=4,
+                chat_id=604,
+                sender_id=604,
+                sent_at=datetime.now(UTC) - timedelta(hours=25),
+            ),
+        }
+    )
+    expired = await dbassistant.get_conversation_by_chat("conn-a", 604)
+    assert expired["handoff_reason"] == "reply_window_expired"
+    new_sends = runtime.api.sent[sent_before:]
+    assert len(new_sends) == 2
+    assert all(item["business_connection_id"] == "" for item in new_sends)
+
+
+async def test_failed_update_goes_to_dead_letter_without_blocking(app_modules, monkeypatch):
+    dbassistant, _service, runtime, _knowledge = await prepare_runtime(app_modules)
+
+    async def fail(_message):
+        raise RuntimeError("persistent failure")
+
+    async def no_sleep(_seconds):
+        return None
+
+    runtime._handle_business_message = fail
+    monkeypatch.setattr("wbb.services.business_assistant.asyncio.sleep", no_sleep)
+    await runtime.process_update(
+        {"update_id": 31, "business_message": customer_message(message_id=1)}
+    )
+
+    dead_letter = await app_modules.wbb.db.business_assistant_dead_letters.find_one(
+        {"bot_id": "primary", "update_id": 31}
+    )
+    update = await app_modules.wbb.db.business_assistant_updates.find_one(
+        {"bot_id": "primary", "update_id": 31}
+    )
+    assert dead_letter["error"] == "persistent failure"
+    assert update["status"] == "done"
+    assert await dbassistant.claim_update(31) is False
+
+
+async def test_connection_updates_revoke_permissions_and_persist_offset(app_modules):
+    service = app_modules.load("wbb.services.business_assistant")
+    dbassistant = app_modules.load("wbb.utils.dbassistant")
+    runtime = service.BusinessAssistantRuntime(
+        token="123456:" + "A" * 30,
+        session=SimpleNamespace(),
+        provider=FakeProvider(),
+    )
+    runtime.api = FakeBusinessApi()
+    await runtime.process_update(
+        {"update_id": 41, "business_connection": connection_payload(can_reply=True)}
+    )
+    connected = await dbassistant.get_business_connection("conn-a")
+    assert connected["is_enabled"] is True
+    assert connected["rights"]["can_reply"] is True
+    await dbassistant.update_account_settings("conn-a", {"assistant_enabled": True})
+
+    revoked = connection_payload(can_reply=False)
+    revoked["is_enabled"] = False
+    await runtime.process_update({"update_id": 42, "business_connection": revoked})
+    disconnected = await dbassistant.get_business_connection("conn-a")
+    assert disconnected["is_enabled"] is False
+    assert "can_reply" not in disconnected["rights"]
+    await runtime.process_update(
+        {"update_id": 420, "business_message": customer_message(message_id=20)}
+    )
+    assert runtime.api.sent == []
+
+    await dbassistant.save_update_offset(43)
+    assert await dbassistant.load_update_offset() == 43
+    await dbassistant.save_update_offset(44)
+    assert await dbassistant.load_update_offset() == 44
+
+
+class StartupApi:
+    def __init__(self, *, supported: bool, webhook_url: str = "") -> None:
+        self.supported = supported
+        self.webhook_url = webhook_url
+
+    async def get_me(self):
+        return {"username": "business_bot", "can_connect_to_business": self.supported}
+
+    async def get_webhook_info(self):
+        return {"url": self.webhook_url}
+
+
+async def test_runtime_blocks_webhook_conflict_and_disabled_business_mode(app_modules):
+    service = app_modules.load("wbb.services.business_assistant")
+    runtime = service.BusinessAssistantRuntime(
+        token="123456:" + "A" * 30,
+        session=SimpleNamespace(),
+        provider=FakeProvider(),
+    )
+    runtime.api = StartupApi(supported=True, webhook_url="https://hooks.example.com/tg")
+    webhook_status = await runtime.start()
+    assert webhook_status["polling_state"] == "blocked"
+    assert webhook_status["webhook_conflict"] is True
+    assert runtime._poll_task is None
+
+    runtime.api = StartupApi(supported=False)
+    business_status = await runtime.start()
+    assert business_status["polling_state"] == "blocked"
+    assert business_status["business_mode_supported"] is False
+    assert "BotFather" in business_status["last_error"]
+
+
+class FakeResponse:
+    def __init__(self, status: int, payload: dict) -> None:
+        self.status = status
+        self.payload = payload
+
+    async def __aenter__(self):
+        return self
+
+    async def __aexit__(self, *_args):
+        return None
+
+    async def json(self, **_kwargs):
+        return self.payload
+
+
+class RetrySession:
+    def __init__(self, responses: list[FakeResponse]) -> None:
+        self.responses = responses
+        self.calls = 0
+
+    def post(self, *_args, **_kwargs):
+        response = self.responses[self.calls]
+        self.calls += 1
+        return response
+
+
+async def test_telegram_api_retries_429_and_5xx(app_modules, monkeypatch):
+    service = app_modules.load("wbb.services.business_assistant")
+    session = RetrySession(
+        [
+            FakeResponse(
+                429,
+                {
+                    "ok": False,
+                    "error_code": 429,
+                    "description": "Too Many Requests",
+                    "parameters": {"retry_after": 3},
+                },
+            ),
+            FakeResponse(
+                502,
+                {"ok": False, "error_code": 502, "description": "Bad Gateway"},
+            ),
+            FakeResponse(200, {"ok": True, "result": {"id": 999}}),
+        ]
+    )
+    sleeps: list[int] = []
+
+    async def record_sleep(seconds):
+        sleeps.append(seconds)
+
+    monkeypatch.setattr("wbb.services.business_assistant.asyncio.sleep", record_sleep)
+    api = service.TelegramBusinessApi("123456:" + "A" * 30, session)
+    assert await api.get_me() == {"id": 999}
+    assert session.calls == 3
+    assert sleeps == [3, 2]
+
+
+async def _login_and_change_password(client: TestClient) -> str:
+    login = await client.post(
+        "/api/admin/v1/auth/login",
+        json={"username": "admin", "password": "qwe0.123456"},
+    )
+    login_data = (await login.json())["data"]
+    changed = await client.put(
+        "/api/admin/v1/auth/password",
+        headers={"X-CSRF-Token": login_data["csrf_token"]},
+        json={
+            "current_password": "qwe0.123456",
+            "new_password": "changed-pass-123",
+        },
+    )
+    return (await changed.json())["data"]["csrf_token"]
+
+
+async def test_business_assistant_api_permission_csrf_audit_and_clear(app_modules):
+    dbassistant = app_modules.load("wbb.utils.dbassistant")
+    await dbassistant.upsert_business_connection(connection_payload())
+    conversation = await dbassistant.get_or_create_conversation(
+        "conn-a", 701, customer={"id": 701, "first_name": "待清理客户"}
+    )
+    await dbassistant.append_conversation_message(
+        conversation["conversation_id"],
+        direction="incoming",
+        telegram_message_id=1,
+        text="请清理",
+    )
+    await dbassistant.update_conversation_summary(conversation["conversation_id"], "待清理摘要")
+
+    admin_api = app_modules.load("wbb.admin.api")
+    application = admin_api.build_admin_application()
+    await application["admin_api"].initialize()
+    client = TestClient(TestServer(application), cookie_jar=CookieJar(unsafe=True))
+    await client.start_server()
+    try:
+        csrf = await _login_and_change_password(client)
+        app_modules.wbb.BOT_PERMISSIONS = set()
+        denied = await client.get("/api/admin/v1/business-assistant/status")
+        assert denied.status == 403
+
+        app_modules.wbb.BOT_PERMISSIONS = {"business_assistant.manage"}
+        no_csrf = await client.put(
+            "/api/admin/v1/business-assistant/settings",
+            json={"connection_id": "conn-a", "assistant_enabled": True, "confirm": True},
+        )
+        assert no_csrf.status == 403
+        unconfirmed = await client.put(
+            "/api/admin/v1/business-assistant/settings",
+            headers={"X-CSRF-Token": csrf},
+            json={"connection_id": "conn-a", "assistant_enabled": True},
+        )
+        assert unconfirmed.status == 409
+
+        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},
+        )
+        assert saved.status == 200
+        assert (await saved.json())["data"]["assistant_enabled"] is True
+
+        created = await client.post(
+            "/api/admin/v1/business-assistant/knowledge",
+            headers={"X-CSRF-Token": csrf},
+            json={
+                "connection_id": "conn-a",
+                "question": "配送范围",
+                "keywords": ["配送"],
+                "answer": "仅限市区。",
+                "confirm": True,
+            },
+        )
+        assert created.status == 201
+        listed = await client.get(
+            "/api/admin/v1/business-assistant/knowledge?connection_id=conn-a"
+        )
+        assert (await listed.json())["data"]["total"] == 1
+
+        cleared = await client.post(
+            f"/api/admin/v1/business-assistant/conversations/{conversation['conversation_id']}/actions",
+            headers={"X-CSRF-Token": csrf},
+            json={"action": "clear", "confirm": True},
+        )
+        assert cleared.status == 200
+        detail = await dbassistant.conversation_detail(conversation["conversation_id"])
+        assert detail["summary"] == ""
+        assert detail["messages"] == []
+
+        audit = await app_modules.wbb.db.admin_audit_logs.find_one(
+            {"action": "business_assistant.conversation.clear"}
+        )
+        assert audit["success"] is True
+    finally:
+        await client.close()

+ 1 - 0
wbb/__init__.py

@@ -61,6 +61,7 @@ MOD_LOAD = [
     "antiservice",
     "antiservice",
     "blacklist",
     "blacklist",
     "blacklist_chat",
     "blacklist_chat",
+    "business_assistant",
     "chat_watcher",
     "chat_watcher",
     "chatbot",
     "chatbot",
     "filters",
     "filters",

+ 18 - 0
wbb/__main__.py

@@ -203,8 +203,10 @@ async def bot_sys_stats() -> str:
 async def start_bot():
 async def start_bot():
     global HELPABLE
     global HELPABLE
 
 
+    loaded_modules = {}
     for module in ALL_MODULES:
     for module in ALL_MODULES:
         imported_module = importlib.import_module("wbb.modules." + module)
         imported_module = importlib.import_module("wbb.modules." + module)
+        loaded_modules[module] = imported_module
         if (
         if (
             module in HELP_MODULE_KEYS
             module in HELP_MODULE_KEYS
             and hasattr(imported_module, "__MODULE__")
             and hasattr(imported_module, "__MODULE__")
@@ -239,6 +241,15 @@ async def start_bot():
     except Exception as exc:
     except Exception as exc:
         log.error(f"注册 Telegram 命令菜单失败:{exc}")
         log.error(f"注册 Telegram 命令菜单失败:{exc}")
 
 
+    business_assistant_module = loaded_modules.get("business_assistant")
+    if business_assistant_module and hasattr(
+        business_assistant_module, "start_business_assistant_runtime"
+    ):
+        try:
+            await business_assistant_module.start_business_assistant_runtime()
+        except Exception as exc:
+            log.error(f"启动 Telegram Business 智能接待失败:{exc}")
+
     restart_data = await clean_restart_stage()
     restart_data = await clean_restart_stage()
 
 
     try:
     try:
@@ -257,6 +268,12 @@ async def start_bot():
 
 
     await idle()
     await idle()
 
 
+    if business_assistant_module and hasattr(
+        business_assistant_module, "stop_business_assistant_runtime"
+    ):
+        with suppress(Exception):
+            await business_assistant_module.stop_business_assistant_runtime()
+
     await aiohttpsession.close()
     await aiohttpsession.close()
     log.info("正在停止客户端")
     log.info("正在停止客户端")
     await app.stop()
     await app.stop()
@@ -316,6 +333,7 @@ assigned_features = [
         ("giveaways.manage", "抽奖"),
         ("giveaways.manage", "抽奖"),
         ("karma.manage", "声望"),
         ("karma.manage", "声望"),
         ("teacher_directory.manage", "技师咨询与评价"),
         ("teacher_directory.manage", "技师咨询与评价"),
+        ("business_assistant.manage", "Telegram Business 智能接待"),
     )
     )
     if has_permission(permission, BOT_PERMISSIONS)
     if has_permission(permission, BOT_PERMISSIONS)
 ]
 ]

+ 281 - 0
wbb/admin/api.py

@@ -42,6 +42,10 @@ from wbb.admin.telegram_webapp import (
     verify_telegram_webapp_init_data,
     verify_telegram_webapp_init_data,
 )
 )
 from wbb.services.bot_permissions import api_permission, has_permission
 from wbb.services.bot_permissions import api_permission, has_permission
+from wbb.services.business_assistant import (
+    AssistantProviderError,
+    runtime_overview,
+)
 from wbb.services.chat_management import (
 from wbb.services.chat_management import (
     ChatManagementError,
     ChatManagementError,
     apply_automation_settings,
     apply_automation_settings,
@@ -88,6 +92,25 @@ from wbb.utils.dbadmin import (
     revoke_admin_session,
     revoke_admin_session,
     update_admin_password,
     update_admin_password,
 )
 )
+from wbb.utils.dbassistant import (
+    AssistantDataError,
+    clear_conversation,
+    close_conversation,
+    conversation_detail,
+    create_knowledge_entry,
+    delete_knowledge_entry,
+    ensure_assistant_indexes,
+    get_account_settings,
+    get_business_connection,
+    list_business_connections,
+    list_conversations,
+    list_knowledge_entries,
+    pause_conversation,
+    resume_conversation,
+    update_account_settings,
+    update_knowledge_entry,
+    usage_metrics,
+)
 from wbb.utils.dbdirectory import (
 from wbb.utils.dbdirectory import (
     APPLICATION_APPROVED,
     APPLICATION_APPROVED,
     DirectoryDataError,
     DirectoryDataError,
@@ -161,6 +184,7 @@ SESSION_COOKIE = "wbb_admin_session"
 UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
 UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
 PUBLIC_API_PATHS = {f"{API_PREFIX}/auth/login", f"{API_PREFIX}/health"}
 PUBLIC_API_PATHS = {f"{API_PREFIX}/auth/login", f"{API_PREFIX}/health"}
 BOT_SCOPED_PREFIXES = (
 BOT_SCOPED_PREFIXES = (
+    f"{API_PREFIX}/business-assistant",
     f"{API_PREFIX}/chats",
     f"{API_PREFIX}/chats",
     f"{API_PREFIX}/giveaways",
     f"{API_PREFIX}/giveaways",
     f"{API_PREFIX}/points",
     f"{API_PREFIX}/points",
@@ -179,6 +203,7 @@ TELEGRAM_ID_KEYS = {
     "message_id",
     "message_id",
     "removed_by",
     "removed_by",
     "authorization_chat_id",
     "authorization_chat_id",
+    "ops_group_id",
 }
 }
 
 
 
 
@@ -321,6 +346,11 @@ async def api_error_middleware(request: web.Request, handler):
         problem = ApiProblem(exc.code, str(exc), status=409)
         problem = ApiProblem(exc.code, str(exc), status=409)
         await _record_request_audit(request, success_state=False, error=str(exc))
         await _record_request_audit(request, success_state=False, error=str(exc))
         return error_response(problem)
         return error_response(problem)
+    except AssistantDataError as exc:
+        status = 404 if exc.code.endswith("_not_found") else 409
+        problem = ApiProblem(exc.code, str(exc), status=status)
+        await _record_request_audit(request, success_state=False, error=str(exc))
+        return error_response(problem)
     except (PointsError, InsufficientPoints) as exc:
     except (PointsError, InsufficientPoints) as exc:
         problem = ApiProblem("points_error", str(exc), status=409)
         problem = ApiProblem("points_error", str(exc), status=409)
         await _record_request_audit(request, success_state=False, error=str(exc))
         await _record_request_audit(request, success_state=False, error=str(exc))
@@ -630,6 +660,7 @@ class AdminApi:
         )
         )
         await ensure_directory_indexes()
         await ensure_directory_indexes()
         await ensure_builtin_package_templates()
         await ensure_builtin_package_templates()
+        await ensure_assistant_indexes()
 
 
     def register(self, application: web.Application) -> None:
     def register(self, application: web.Application) -> None:
         router = application.router
         router = application.router
@@ -679,6 +710,63 @@ class AdminApi:
         router.add_get(f"{API_PREFIX}/audit-logs", self.audit_logs)
         router.add_get(f"{API_PREFIX}/audit-logs", self.audit_logs)
         router.add_post(f"{API_PREFIX}/media", self.upload_media)
         router.add_post(f"{API_PREFIX}/media", self.upload_media)
 
 
+        router.add_get(
+            f"{API_PREFIX}/business-assistant/status",
+            self.business_assistant_status,
+        )
+        router.add_get(
+            f"{API_PREFIX}/business-assistant/connections",
+            self.business_assistant_connections,
+        )
+        router.add_get(
+            f"{API_PREFIX}/business-assistant/settings",
+            self.business_assistant_settings,
+        )
+        router.add_put(
+            f"{API_PREFIX}/business-assistant/settings",
+            self.business_assistant_settings_update,
+        )
+        router.add_get(
+            f"{API_PREFIX}/business-assistant/knowledge",
+            self.business_assistant_knowledge,
+        )
+        router.add_post(
+            f"{API_PREFIX}/business-assistant/knowledge",
+            self.business_assistant_knowledge_create,
+        )
+        router.add_put(
+            f"{API_PREFIX}/business-assistant/knowledge/{{entry_id}}",
+            self.business_assistant_knowledge_update,
+        )
+        router.add_delete(
+            f"{API_PREFIX}/business-assistant/knowledge/{{entry_id}}",
+            self.business_assistant_knowledge_delete,
+        )
+        router.add_post(
+            f"{API_PREFIX}/business-assistant/knowledge/test",
+            self.business_assistant_knowledge_test,
+        )
+        router.add_get(
+            f"{API_PREFIX}/business-assistant/conversations",
+            self.business_assistant_conversations,
+        )
+        router.add_get(
+            f"{API_PREFIX}/business-assistant/conversations/{{conversation_id}}",
+            self.business_assistant_conversation_detail,
+        )
+        router.add_post(
+            f"{API_PREFIX}/business-assistant/conversations/{{conversation_id}}/actions",
+            self.business_assistant_conversation_action,
+        )
+        router.add_get(
+            f"{API_PREFIX}/business-assistant/usage",
+            self.business_assistant_usage,
+        )
+        router.add_post(
+            f"{API_PREFIX}/business-assistant/model/test",
+            self.business_assistant_model_test,
+        )
+
         router.add_get(f"{API_PREFIX}/chats", self.chats)
         router.add_get(f"{API_PREFIX}/chats", self.chats)
         router.add_get(f"{API_PREFIX}/chats/{{chat_id}}", self.chat)
         router.add_get(f"{API_PREFIX}/chats/{{chat_id}}", self.chat)
         router.add_patch(f"{API_PREFIX}/chats/{{chat_id}}/profile", self.chat_profile)
         router.add_patch(f"{API_PREFIX}/chats/{{chat_id}}/profile", self.chat_profile)
@@ -2289,6 +2377,199 @@ class AdminApi:
             headers={"Content-Disposition": f'attachment; filename="points-{chat_id}-{kind}.csv"'},
             headers={"Content-Disposition": f'attachment; filename="points-{chat_id}-{kind}.csv"'},
         )
         )
 
 
+    async def business_assistant_status(self, _: web.Request) -> web.Response:
+        return success(await runtime_overview())
+
+    async def business_assistant_connections(self, request: web.Request) -> web.Response:
+        page, page_size = page_params(request)
+        items, total = await list_business_connections(page=page, page_size=page_size)
+        return success(
+            {"items": items, "total": total, "page": page, "page_size": page_size}
+        )
+
+    async def business_assistant_settings(self, request: web.Request) -> web.Response:
+        connection_id = str(request.query.get("connection_id") or "").strip()
+        if not connection_id:
+            raise ApiProblem("connection_required", "请先选择一个 Business 连接。")
+        if not await get_business_connection(connection_id):
+            raise AssistantDataError("connection_not_found", "未找到 Business 连接。")
+        return success(await get_account_settings(connection_id))
+
+    async def business_assistant_settings_update(
+        self, request: web.Request
+    ) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        connection_id = str(body.pop("connection_id", "")).strip()
+        body.pop("confirm", None)
+        if not connection_id:
+            raise ApiProblem("connection_required", "请先选择一个 Business 连接。")
+        set_audit(
+            request,
+            "business_assistant.settings.update",
+            target_id=connection_id,
+            summary="更新智能接待设置",
+        )
+        return success(await update_account_settings(connection_id, body))
+
+    async def business_assistant_knowledge(self, request: web.Request) -> web.Response:
+        connection_id = str(request.query.get("connection_id") or "").strip()
+        if not connection_id:
+            raise ApiProblem("connection_required", "请先选择一个 Business 连接。")
+        page, page_size = page_params(request)
+        items, total = await list_knowledge_entries(
+            connection_id,
+            query=str(request.query.get("query") or ""),
+            page=page,
+            page_size=page_size,
+        )
+        return success(
+            {"items": items, "total": total, "page": page, "page_size": page_size}
+        )
+
+    async def business_assistant_knowledge_create(
+        self, request: web.Request
+    ) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        connection_id = str(body.pop("connection_id", "")).strip()
+        body.pop("confirm", None)
+        if not connection_id:
+            raise ApiProblem("connection_required", "请先选择一个 Business 连接。")
+        entry = await create_knowledge_entry(connection_id, body)
+        set_audit(
+            request,
+            "business_assistant.knowledge.create",
+            target_id=entry["entry_id"],
+            summary=str(entry["question"]),
+            metadata={"connection_id": connection_id},
+        )
+        return success(entry, status=201)
+
+    async def business_assistant_knowledge_update(
+        self, request: web.Request
+    ) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        body.pop("confirm", None)
+        entry_id = request.match_info["entry_id"]
+        set_audit(
+            request,
+            "business_assistant.knowledge.update",
+            target_id=entry_id,
+            summary="更新知识条目",
+        )
+        return success(await update_knowledge_entry(entry_id, body))
+
+    async def business_assistant_knowledge_delete(
+        self, request: web.Request
+    ) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        entry_id = request.match_info["entry_id"]
+        set_audit(
+            request,
+            "business_assistant.knowledge.delete",
+            target_id=entry_id,
+            summary="删除知识条目",
+        )
+        await delete_knowledge_entry(entry_id)
+        return success({"deleted": True})
+
+    async def business_assistant_knowledge_test(
+        self, request: web.Request
+    ) -> web.Response:
+        body = await json_body(request)
+        connection_id = str(body.get("connection_id") or "").strip()
+        text = str(body.get("text") or "").strip()
+        if not connection_id or not text:
+            raise ApiProblem("invalid_parameter", "连接和测试问题不能为空。")
+        from wbb.modules.business_assistant import get_business_assistant_runtime
+
+        runtime = get_business_assistant_runtime()
+        if runtime is None:
+            raise ApiProblem(
+                "assistant_runtime_unavailable", "智能接待运行时尚未启动。", status=503
+            )
+        try:
+            result = await runtime.preview_answer(connection_id, text)
+        except AssistantProviderError as exc:
+            raise ApiProblem("assistant_provider_error", str(exc), status=502) from exc
+        return success(result)
+
+    async def business_assistant_conversations(
+        self, request: web.Request
+    ) -> web.Response:
+        page, page_size = page_params(request)
+        items, total = await list_conversations(
+            connection_id=str(request.query.get("connection_id") or ""),
+            status=str(request.query.get("status") or ""),
+            page=page,
+            page_size=page_size,
+        )
+        return success(
+            {"items": items, "total": total, "page": page, "page_size": page_size}
+        )
+
+    async def business_assistant_conversation_detail(
+        self, request: web.Request
+    ) -> web.Response:
+        return success(
+            await conversation_detail(request.match_info["conversation_id"])
+        )
+
+    async def business_assistant_conversation_action(
+        self, request: web.Request
+    ) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        action = str(body.get("action") or "")
+        conversation_id = request.match_info["conversation_id"]
+        actions = {
+            "pause": pause_conversation,
+            "resume": resume_conversation,
+            "close": close_conversation,
+            "clear": clear_conversation,
+        }
+        handler = actions.get(action)
+        if handler is None:
+            raise ApiProblem("invalid_action", "不支持的会话操作。")
+        set_audit(
+            request,
+            f"business_assistant.conversation.{action}",
+            target_id=conversation_id,
+            summary=f"智能接待会话操作:{action}",
+        )
+        return success(await handler(conversation_id))
+
+    async def business_assistant_usage(self, request: web.Request) -> web.Response:
+        return success(
+            await usage_metrics(
+                connection_id=str(request.query.get("connection_id") or "")
+            )
+        )
+
+    async def business_assistant_model_test(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        from wbb.modules.business_assistant import get_business_assistant_runtime
+
+        runtime = get_business_assistant_runtime()
+        if runtime is None:
+            raise ApiProblem(
+                "assistant_runtime_unavailable", "智能接待运行时尚未启动。", status=503
+            )
+        try:
+            result = await runtime.provider.test_connection()
+        except AssistantProviderError as exc:
+            raise ApiProblem("assistant_provider_error", str(exc), status=502) from exc
+        set_audit(
+            request,
+            "business_assistant.model.test",
+            summary="验证 OpenAI 兼容模型",
+        )
+        return success(result)
+
     async def giveaways(self, request: web.Request) -> web.Response:
     async def giveaways(self, request: web.Request) -> web.Response:
         page, page_size = page_params(request)
         page, page_size = page_params(request)
         chat_raw = request.query.get("chat_id")
         chat_raw = request.query.get("chat_id")

+ 84 - 0
wbb/admin/bot_config.py

@@ -26,6 +26,13 @@ PROFILE_FIELDS = {
     "gban_log_group_id",
     "gban_log_group_id",
     "message_dump_chat",
     "message_dump_chat",
 }
 }
+DEFAULT_ASSISTANT_PROVIDER = {
+    "base_url": "",
+    "api_key": "",
+    "model": "",
+    "timeout_seconds": 30,
+    "max_output_tokens": 600,
+}
 _STORE_LOCK = threading.RLock()
 _STORE_LOCK = threading.RLock()
 
 
 
 
@@ -107,6 +114,49 @@ def _normalize_sudoers(value: Any) -> list[int]:
     return list(dict.fromkeys(normalized))
     return list(dict.fromkeys(normalized))
 
 
 
 
+def _normalize_assistant_provider(
+    value: Any,
+    *,
+    previous: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+    if not isinstance(value, dict):
+        raise BotConfigError("invalid_assistant_provider", "智能接待模型配置格式无效。")
+    current = {**DEFAULT_ASSISTANT_PROVIDER, **(previous or {})}
+    if "base_url" in value:
+        base_url = str(value.get("base_url") or "").strip().rstrip("/")
+        if base_url and not re.fullmatch(r"https?://[^\s]{1,500}", base_url):
+            raise BotConfigError(
+                "invalid_assistant_provider", "OpenAI 兼容 Base URL 必须是 HTTP(S) 地址。"
+            )
+        current["base_url"] = base_url
+    api_key = str(value.get("api_key") or "").strip()
+    if api_key:
+        if len(api_key) > 1000:
+            raise BotConfigError("invalid_assistant_provider", "OpenAI API Key 过长。")
+        current["api_key"] = api_key
+    if value.get("clear_api_key") is True:
+        current["api_key"] = ""
+    if "model" in value:
+        model = str(value.get("model") or "").strip()
+        if len(model) > 200:
+            raise BotConfigError("invalid_assistant_provider", "模型名称不能超过 200 个字符。")
+        current["model"] = model
+    for key, label, minimum, maximum in (
+        ("timeout_seconds", "模型超时秒数", 5, 120),
+        ("max_output_tokens", "模型输出 Token 上限", 100, 2000),
+    ):
+        if key not in value:
+            continue
+        parsed = _integer(value.get(key), label, positive=True)
+        if not minimum <= parsed <= maximum:
+            raise BotConfigError(
+                "invalid_assistant_provider",
+                f"{label}必须在 {minimum} 到 {maximum} 之间。",
+            )
+        current[key] = parsed
+    return current
+
+
 def _find_profile(data: dict[str, Any], bot_id: str) -> dict[str, Any]:
 def _find_profile(data: dict[str, Any], bot_id: str) -> dict[str, Any]:
     profile = next(
     profile = next(
         (item for item in data["bots"] if str(item.get("bot_id")) == str(bot_id)),
         (item for item in data["bots"] if str(item.get("bot_id")) == str(bot_id)),
@@ -207,6 +257,14 @@ def _public_profile(
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     token_configured = bool(BOT_TOKEN_PATTERN.fullmatch(str(profile.get("bot_token") or "")))
     token_configured = bool(BOT_TOKEN_PATTERN.fullmatch(str(profile.get("bot_token") or "")))
     identity = profile.get("identity") if isinstance(profile.get("identity"), dict) else None
     identity = profile.get("identity") if isinstance(profile.get("identity"), dict) else None
+    assistant = {
+        **DEFAULT_ASSISTANT_PROVIDER,
+        **(
+            profile.get("business_assistant")
+            if isinstance(profile.get("business_assistant"), dict)
+            else {}
+        ),
+    }
     return {
     return {
         "bot_id": str(profile.get("bot_id") or ""),
         "bot_id": str(profile.get("bot_id") or ""),
         "label": str(profile.get("label") or ""),
         "label": str(profile.get("label") or ""),
@@ -219,6 +277,18 @@ def _public_profile(
         "role_ids": _profile_role_ids(profile, data),
         "role_ids": _profile_role_ids(profile, data),
         "permissions": _effective_permissions(profile, data),
         "permissions": _effective_permissions(profile, data),
         "identity": identity,
         "identity": identity,
+        "business_assistant": {
+            "base_url": str(assistant.get("base_url") or ""),
+            "api_key_configured": bool(assistant.get("api_key")),
+            "model": str(assistant.get("model") or ""),
+            "timeout_seconds": int(assistant.get("timeout_seconds") or 30),
+            "max_output_tokens": int(assistant.get("max_output_tokens") or 600),
+            "configured": bool(
+                assistant.get("base_url")
+                and assistant.get("api_key")
+                and assistant.get("model")
+            ),
+        },
         "ready_to_connect": api_ready and token_configured,
         "ready_to_connect": api_ready and token_configured,
         "created_at": profile.get("created_at"),
         "created_at": profile.get("created_at"),
         "updated_at": profile.get("updated_at"),
         "updated_at": profile.get("updated_at"),
@@ -317,6 +387,9 @@ def create_bot_profile(path: str | Path, body: dict[str, Any]) -> dict[str, Any]
                 legacy_default="role_ids" not in body,
                 legacy_default="role_ids" not in body,
             ),
             ),
             "identity": None,
             "identity": None,
+            "business_assistant": _normalize_assistant_provider(
+                body.get("business_assistant", {})
+            ),
             "created_at": now,
             "created_at": now,
             "updated_at": now,
             "updated_at": now,
         }
         }
@@ -350,6 +423,15 @@ def update_bot_profile(path: str | Path, bot_id: str, body: dict[str, Any]) -> d
             profile["sudo_users_id"] = _normalize_sudoers(body.get("sudo_users_id"))
             profile["sudo_users_id"] = _normalize_sudoers(body.get("sudo_users_id"))
         if "role_ids" in body:
         if "role_ids" in body:
             profile["role_ids"] = _normalize_role_ids(body.get("role_ids"), data)
             profile["role_ids"] = _normalize_role_ids(body.get("role_ids"), data)
+        if "business_assistant" in body:
+            previous_assistant = (
+                profile.get("business_assistant")
+                if isinstance(profile.get("business_assistant"), dict)
+                else {}
+            )
+            profile["business_assistant"] = _normalize_assistant_provider(
+                body.get("business_assistant"), previous=previous_assistant
+            )
         for field in PROFILE_FIELDS - {"sudo_users_id"}:
         for field in PROFILE_FIELDS - {"sudo_users_id"}:
             if field in body:
             if field in body:
                 profile[field] = _integer(body.get(field), field)
                 profile[field] = _integer(body.get(field), field)
@@ -482,6 +564,7 @@ def store_bot_identity(path: str | Path, bot_id: str, identity: dict[str, Any])
             "id": str(identity.get("id") or ""),
             "id": str(identity.get("id") or ""),
             "username": str(identity.get("username") or ""),
             "username": str(identity.get("username") or ""),
             "name": str(identity.get("name") or ""),
             "name": str(identity.get("name") or ""),
+            "can_connect_to_business": bool(identity.get("can_connect_to_business")),
         }
         }
         profile["updated_at"] = datetime.now(UTC).isoformat().replace("+00:00", "Z")
         profile["updated_at"] = datetime.now(UTC).isoformat().replace("+00:00", "Z")
         _write_document(path, data)
         _write_document(path, data)
@@ -509,4 +592,5 @@ async def test_bot_token(token: str) -> dict[str, Any]:
         "name": " ".join(
         "name": " ".join(
             part for part in (result.get("first_name"), result.get("last_name")) if part
             part for part in (result.get("first_name"), result.get("last_name")) if part
         ),
         ),
+        "can_connect_to_business": bool(result.get("can_connect_to_business")),
     }
     }

+ 14 - 0
wbb/admin/supervisor.py

@@ -176,6 +176,11 @@ class BotSupervisor:
 
 
     def _worker_environment(self, profile: dict[str, Any], port: int) -> dict[str, str]:
     def _worker_environment(self, profile: dict[str, Any], port: int) -> dict[str, str]:
         environment = os.environ.copy()
         environment = os.environ.copy()
+        assistant = (
+            profile.get("business_assistant")
+            if isinstance(profile.get("business_assistant"), dict)
+            else {}
+        )
         environment.update(
         environment.update(
             {
             {
                 "WBB_ADMIN_BOOTSTRAP": "0",
                 "WBB_ADMIN_BOOTSTRAP": "0",
@@ -202,6 +207,15 @@ class BotSupervisor:
                 "ADMIN_WEB_HOST": "127.0.0.1",
                 "ADMIN_WEB_HOST": "127.0.0.1",
                 "ADMIN_WEB_PORT": str(port),
                 "ADMIN_WEB_PORT": str(port),
                 "BOT_PROFILES_PATH": str(self.config_path),
                 "BOT_PROFILES_PATH": str(self.config_path),
+                "BUSINESS_ASSISTANT_OPENAI_BASE_URL": str(assistant.get("base_url") or ""),
+                "BUSINESS_ASSISTANT_OPENAI_API_KEY": str(assistant.get("api_key") or ""),
+                "BUSINESS_ASSISTANT_OPENAI_MODEL": str(assistant.get("model") or ""),
+                "BUSINESS_ASSISTANT_OPENAI_TIMEOUT_SECONDS": str(
+                    assistant.get("timeout_seconds") or 30
+                ),
+                "BUSINESS_ASSISTANT_OPENAI_MAX_OUTPUT_TOKENS": str(
+                    assistant.get("max_output_tokens") or 600
+                ),
             }
             }
         )
         )
         return environment
         return environment

+ 87 - 0
wbb/modules/business_assistant.py

@@ -0,0 +1,87 @@
+from __future__ import annotations
+
+from contextlib import suppress
+
+from pyrogram import filters
+from pyrogram.enums import ChatMemberStatus
+
+import wbb
+from wbb import SUDOERS, app
+from wbb.services.business_assistant import BusinessAssistantRuntime
+from wbb.utils.dbassistant import (
+    get_account_settings,
+    get_business_connection,
+    get_conversation,
+    resume_conversation,
+)
+
+__MODULE__ = "智能接待"
+__HELP__ = "Telegram Business 智能接待由 Web 管理后台配置。"
+
+_runtime: BusinessAssistantRuntime | None = None
+
+
+async def start_business_assistant_runtime() -> None:
+    global _runtime
+    if _runtime is not None:
+        return
+    session = getattr(wbb, "aiohttpsession", None)
+    token = str(getattr(wbb, "BOT_TOKEN", ""))
+    if not session or not token:
+        wbb.log.error("智能接待未启动:Telegram Bot 会话或令牌不可用。")
+        return
+    _runtime = BusinessAssistantRuntime(token=token, session=session)
+    status = await _runtime.start()
+    if status.get("polling_state") == "running":
+        wbb.log.info("Telegram Business 智能接待已启动。")
+    else:
+        wbb.log.error(f"Telegram Business 智能接待未运行:{status.get('last_error') or '未知原因'}")
+
+
+async def stop_business_assistant_runtime() -> None:
+    global _runtime
+    if _runtime is None:
+        return
+    await _runtime.stop()
+    _runtime = None
+
+
+def get_business_assistant_runtime() -> BusinessAssistantRuntime | None:
+    return _runtime
+
+
+async def _ops_group_admin_allowed(query, settings: dict) -> bool:
+    ops_group_id = int(settings.get("ops_group_id") or 0)
+    if not ops_group_id or not query.message or int(query.message.chat.id) != ops_group_id:
+        return False
+    with suppress(Exception):
+        member = await app.get_chat_member(ops_group_id, query.from_user.id)
+        return member.status in {
+            ChatMemberStatus.OWNER,
+            ChatMemberStatus.ADMINISTRATOR,
+        }
+    return False
+
+
+@app.on_callback_query(filters.regex(r"^ba:resume:[a-f0-9]{32}$"), group=-20)
+async def resume_business_assistant_callback(_, query):
+    conversation_id = str(query.data).rsplit(":", 1)[-1]
+    conversation = await get_conversation(conversation_id)
+    if not conversation:
+        return await query.answer("会话不存在或已清理。", show_alert=True)
+    connection = await get_business_connection(conversation["connection_id"])
+    if not connection:
+        return await query.answer("Business 连接不存在。", show_alert=True)
+    settings = await get_account_settings(connection["connection_id"])
+    owner_id = int((connection.get("user") or {}).get("id") or 0)
+    allowed = (
+        query.from_user.id == owner_id
+        or query.from_user.id in SUDOERS
+        or await _ops_group_admin_allowed(query, settings)
+    )
+    if not allowed:
+        return await query.answer("只有账号本人或运营群管理员可以恢复。", show_alert=True)
+    await resume_conversation(conversation_id)
+    with suppress(Exception):
+        await query.message.edit_reply_markup(None)
+    await query.answer("已恢复自动回复。", show_alert=True)

+ 17 - 1
wbb/services/bot_permissions.py

@@ -43,6 +43,12 @@ PERMISSIONS = (
         "社区运营",
         "社区运营",
         "提供技师申请、上下榜、在线状态、位置和附近技师目录。",
         "提供技师申请、上下榜、在线状态、位置和附近技师目录。",
     ),
     ),
+    BotPermission(
+        "business_assistant.manage",
+        "智能接待",
+        "自动化",
+        "管理 Telegram Business 连接、知识库、客户会话和人工接管。",
+    ),
     BotPermission("media.upload", "媒体中转", "系统能力", "向媒体中转群上传文件。"),
     BotPermission("media.upload", "媒体中转", "系统能力", "向媒体中转群上传文件。"),
 )
 )
 
 
@@ -58,7 +64,8 @@ GROUP_MANAGEMENT_PERMISSIONS = frozenset(
     }
     }
 )
 )
 PRIVATE_MANAGEMENT_PERMISSIONS = GROUP_MANAGEMENT_PERMISSIONS | {
 PRIVATE_MANAGEMENT_PERMISSIONS = GROUP_MANAGEMENT_PERMISSIONS | {
-    "teacher_directory.manage"
+    "teacher_directory.manage",
+    "business_assistant.manage",
 }
 }
 
 
 BUILTIN_ROLES = (
 BUILTIN_ROLES = (
@@ -112,6 +119,12 @@ BUILTIN_ROLES = (
         "负责技师申请、位置、上下榜、在线状态和附近技师目录。",
         "负责技师申请、位置、上下榜、在线状态和附近技师目录。",
         ("teacher_directory.manage",),
         ("teacher_directory.manage",),
     ),
     ),
+    BuiltinBotRole(
+        "business_assistant_manager",
+        "智能接待专员",
+        "负责 Telegram Business 智能接待、知识库和客户会话。",
+        ("business_assistant.manage",),
+    ),
 )
 )
 
 
 MODULE_PERMISSIONS: dict[str, frozenset[str]] = {
 MODULE_PERMISSIONS: dict[str, frozenset[str]] = {
@@ -121,6 +134,7 @@ MODULE_PERMISSIONS: dict[str, frozenset[str]] = {
     "antiservice": frozenset({"automation.manage"}),
     "antiservice": frozenset({"automation.manage"}),
     "blacklist": frozenset({"automation.manage"}),
     "blacklist": frozenset({"automation.manage"}),
     "blacklist_chat": frozenset({"automation.manage"}),
     "blacklist_chat": frozenset({"automation.manage"}),
+    "business_assistant": frozenset({"business_assistant.manage"}),
     "chatbot": frozenset({"automation.manage"}),
     "chatbot": frozenset({"automation.manage"}),
     "filters": frozenset({"automation.manage"}),
     "filters": frozenset({"automation.manage"}),
     "flood": frozenset({"automation.manage"}),
     "flood": frozenset({"automation.manage"}),
@@ -215,6 +229,8 @@ def module_allowed(module: str, permissions: Iterable[str] | None) -> bool:
 
 
 
 
 def api_permission(method: str, path: str) -> str | None:
 def api_permission(method: str, path: str) -> str | None:
+    if path.startswith("/api/admin/v1/business-assistant"):
+        return "business_assistant.manage"
     if path.startswith("/api/admin/v1/directory"):
     if path.startswith("/api/admin/v1/directory"):
         return "teacher_directory.manage"
         return "teacher_directory.manage"
     if path.startswith("/api/admin/v1/media"):
     if path.startswith("/api/admin/v1/media"):

+ 893 - 0
wbb/services/business_assistant.py

@@ -0,0 +1,893 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import re
+from contextlib import suppress
+from datetime import UTC, datetime
+from typing import Any
+
+from aiohttp import ClientError, ClientSession
+
+import wbb
+from wbb.utils.dbassistant import (
+    append_conversation_message,
+    claim_update,
+    classify_handoff,
+    dead_letter_update,
+    due_digest_connections,
+    get_account_settings,
+    get_business_connection,
+    get_conversation,
+    get_conversation_by_chat,
+    get_or_create_conversation,
+    list_business_connections,
+    load_update_offset,
+    mark_digest_sent,
+    mark_update_done,
+    mark_update_failed,
+    match_knowledge,
+    pause_conversation_for_human,
+    recent_conversation_messages,
+    reserve_ai_usage,
+    resume_conversation,
+    runtime_status,
+    save_update_offset,
+    set_conversation_handoff,
+    touch_business_connection,
+    update_conversation_summary,
+    update_runtime_status,
+    upsert_business_connection,
+    usage_metrics,
+    utc_now,
+)
+
+BUSINESS_ALLOWED_UPDATES = [
+    "business_connection",
+    "business_message",
+    "edited_business_message",
+    "deleted_business_messages",
+]
+BUSINESS_REPLY_WINDOW_SECONDS = 24 * 60 * 60
+
+
+class TelegramBotApiError(RuntimeError):
+    def __init__(
+        self,
+        message: str,
+        *,
+        error_code: int = 0,
+        retry_after: int = 0,
+    ) -> None:
+        super().__init__(message)
+        self.error_code = int(error_code or 0)
+        self.retry_after = int(retry_after or 0)
+
+
+class AssistantProviderError(RuntimeError):
+    pass
+
+
+class TelegramBusinessApi:
+    def __init__(self, token: str, session: ClientSession) -> None:
+        self._base_url = f"https://api.telegram.org/bot{token}"
+        self._session = session
+
+    async def call(
+        self,
+        method: str,
+        payload: dict[str, Any] | None = None,
+    ) -> Any:
+        last_error: TelegramBotApiError | None = None
+        for attempt in range(3):
+            try:
+                async with self._session.post(
+                    f"{self._base_url}/{method}", json=payload or {}
+                ) as response:
+                    data = await response.json(content_type=None)
+            except (ClientError, TimeoutError, ValueError) as exc:
+                last_error = TelegramBotApiError("无法连接 Telegram Bot API。")
+                if attempt < 2:
+                    await asyncio.sleep(2**attempt)
+                    continue
+                raise last_error from exc
+            if response.status == 200 and isinstance(data, dict) and data.get("ok"):
+                return data.get("result")
+            parameters = data.get("parameters") if isinstance(data, dict) else {}
+            last_error = TelegramBotApiError(
+                str(data.get("description") or "Telegram Bot API 请求失败。")
+                if isinstance(data, dict)
+                else "Telegram Bot API 请求失败。",
+                error_code=int(data.get("error_code") or response.status)
+                if isinstance(data, dict)
+                else response.status,
+                retry_after=int((parameters or {}).get("retry_after") or 0),
+            )
+            retryable = last_error.error_code == 429 or response.status >= 500
+            if retryable and attempt < 2:
+                await asyncio.sleep(last_error.retry_after or 2**attempt)
+                continue
+            raise last_error
+        raise last_error or TelegramBotApiError("Telegram Bot API 请求失败。")
+
+    async def get_me(self) -> dict[str, Any]:
+        result = await self.call("getMe")
+        return result if isinstance(result, dict) else {}
+
+    async def get_webhook_info(self) -> dict[str, Any]:
+        result = await self.call("getWebhookInfo")
+        return result if isinstance(result, dict) else {}
+
+    async def get_business_connection(self, connection_id: str) -> dict[str, Any]:
+        result = await self.call(
+            "getBusinessConnection", {"business_connection_id": str(connection_id)}
+        )
+        return result if isinstance(result, dict) else {}
+
+    async def get_updates(
+        self, *, offset: int, poll_timeout: int = 30
+    ) -> list[dict[str, Any]]:
+        result = await self.call(
+            "getUpdates",
+            {
+                "offset": int(offset),
+                "timeout": int(poll_timeout),
+                "limit": 100,
+                "allowed_updates": BUSINESS_ALLOWED_UPDATES,
+            },
+        )
+        return [item for item in (result or []) if isinstance(item, dict)]
+
+    async def send_message(
+        self,
+        chat_id: int,
+        text: str,
+        *,
+        business_connection_id: str = "",
+        reply_markup: dict[str, Any] | None = None,
+    ) -> dict[str, Any]:
+        payload: dict[str, Any] = {
+            "chat_id": int(chat_id),
+            "text": str(text)[:4096],
+        }
+        if business_connection_id:
+            payload["business_connection_id"] = str(business_connection_id)
+        if reply_markup:
+            payload["reply_markup"] = reply_markup
+        result = await self.call("sendMessage", payload)
+        return result if isinstance(result, dict) else {}
+
+    async def read_business_message(
+        self,
+        connection_id: str,
+        chat_id: int,
+        message_id: int,
+    ) -> None:
+        await self.call(
+            "readBusinessMessage",
+            {
+                "business_connection_id": str(connection_id),
+                "chat_id": int(chat_id),
+                "message_id": int(message_id),
+            },
+        )
+
+
+def _strip_json_fence(value: str) -> str:
+    text = value.strip()
+    if text.startswith("```"):
+        text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
+        text = re.sub(r"\s*```$", "", text)
+    return text.strip()
+
+
+def business_reply_window_open(
+    message: dict[str, Any], *, now: datetime | None = None
+) -> bool:
+    try:
+        sent_at = datetime.fromtimestamp(int(message.get("date") or 0), UTC)
+    except (OSError, OverflowError, TypeError, ValueError):
+        return False
+    if sent_at.timestamp() <= 0:
+        return False
+    age = (now or utc_now()) - sent_at
+    return age.total_seconds() <= BUSINESS_REPLY_WINDOW_SECONDS
+
+
+def parse_ai_decision(value: str, *, allowed_entry_ids: set[str]) -> dict[str, Any]:
+    try:
+        payload = json.loads(_strip_json_fence(value))
+    except (json.JSONDecodeError, TypeError) as exc:
+        raise AssistantProviderError("模型没有返回有效的 JSON 结果。") from exc
+    if not isinstance(payload, dict):
+        raise AssistantProviderError("模型结果必须是 JSON 对象。")
+    action = str(payload.get("action") or "")
+    if action not in {"answer", "clarify", "handoff"}:
+        raise AssistantProviderError("模型返回了不支持的接待动作。")
+    reply = str(payload.get("reply") or "").strip()
+    if action in {"answer", "clarify"} and not reply:
+        raise AssistantProviderError("模型没有提供回复内容。")
+    if len(reply) > 4000:
+        raise AssistantProviderError("模型回复内容过长。")
+    matched_ids = [
+        str(item)
+        for item in payload.get("matched_entry_ids") or []
+        if str(item) in allowed_entry_ids
+    ]
+    if action == "answer" and not matched_ids:
+        raise AssistantProviderError("业务回答没有引用知识条目。")
+    return {
+        "action": action,
+        "reply": reply,
+        "handoff_reason": str(payload.get("handoff_reason") or "ai_handoff")[:200],
+        "matched_entry_ids": matched_ids,
+        "summary": str(payload.get("summary") or "")[:4000],
+    }
+
+
+class OpenAICompatibleAssistant:
+    def __init__(
+        self,
+        session: ClientSession,
+        *,
+        base_url: str,
+        api_key: str,
+        model: str,
+        timeout_seconds: int = 30,
+        max_output_tokens: int = 600,
+    ) -> None:
+        self._session = session
+        self.base_url = str(base_url).rstrip("/")
+        self.api_key = str(api_key)
+        self.model = str(model)
+        self.timeout_seconds = max(5, min(int(timeout_seconds), 120))
+        self.max_output_tokens = max(100, min(int(max_output_tokens), 2000))
+
+    @property
+    def configured(self) -> bool:
+        return bool(self.base_url and self.api_key and self.model)
+
+    async def decide(
+        self,
+        *,
+        settings: dict[str, Any],
+        conversation: dict[str, Any],
+        messages: list[dict[str, Any]],
+        knowledge: list[dict[str, Any]],
+        customer_text: str,
+    ) -> dict[str, Any]:
+        if not self.configured:
+            raise AssistantProviderError("OpenAI 兼容模型尚未配置。")
+        knowledge_payload = [
+            {
+                "entry_id": item["entry_id"],
+                "question": item["question"],
+                "answer": item["answer"],
+                "tags": item.get("tags") or [],
+            }
+            for item in knowledge
+        ]
+        recent_payload = [
+            {"direction": item.get("direction"), "text": item.get("text", "")}
+            for item in messages[-12:]
+        ]
+        system_prompt = (
+            f"{settings.get('system_prompt')}\n"
+            "必须遵守:业务事实只能来自 knowledge;不得自行补全价格、承诺、退款或政策。"
+            "仅输出一个 JSON 对象,不使用 Markdown。字段为 action、reply、handoff_reason、"
+            "matched_entry_ids、summary。action 只能是 answer、clarify、handoff。"
+            "answer 必须填写实际引用的 matched_entry_ids;无法可靠回答时使用 handoff。"
+        )
+        request_body = {
+            "model": self.model,
+            "temperature": 0.2,
+            "max_tokens": self.max_output_tokens,
+            "messages": [
+                {"role": "system", "content": system_prompt},
+                {
+                    "role": "user",
+                    "content": json.dumps(
+                        {
+                            "language": settings.get("language"),
+                            "tone": settings.get("tone"),
+                            "existing_summary": conversation.get("summary", ""),
+                            "recent_messages": recent_payload,
+                            "knowledge": knowledge_payload,
+                            "customer_message": customer_text,
+                        },
+                        ensure_ascii=False,
+                    ),
+                },
+            ],
+        }
+        try:
+            async with asyncio.timeout(self.timeout_seconds):
+                async with self._session.post(
+                    f"{self.base_url}/chat/completions",
+                    headers={
+                        "Authorization": f"Bearer {self.api_key}",
+                        "Content-Type": "application/json",
+                    },
+                    json=request_body,
+                ) as response:
+                    payload = await response.json(content_type=None)
+        except (ClientError, TimeoutError, ValueError) as exc:
+            raise AssistantProviderError("OpenAI 兼容服务暂时不可用。") from exc
+        if response.status != 200:
+            raise AssistantProviderError(f"OpenAI 兼容服务返回 HTTP {response.status}。")
+        try:
+            content = payload["choices"][0]["message"]["content"]
+        except (KeyError, IndexError, TypeError) as exc:
+            raise AssistantProviderError("OpenAI 兼容服务响应格式无效。") from exc
+        return parse_ai_decision(
+            str(content),
+            allowed_entry_ids={str(item["entry_id"]) for item in knowledge},
+        )
+
+    async def test_connection(self) -> dict[str, Any]:
+        if not self.configured:
+            raise AssistantProviderError("OpenAI 兼容模型尚未配置。")
+        request_body = {
+            "model": self.model,
+            "temperature": 0,
+            "max_tokens": 12,
+            "messages": [{"role": "user", "content": "只回复 OK"}],
+        }
+        try:
+            async with asyncio.timeout(self.timeout_seconds):
+                async with self._session.post(
+                    f"{self.base_url}/chat/completions",
+                    headers={
+                        "Authorization": f"Bearer {self.api_key}",
+                        "Content-Type": "application/json",
+                    },
+                    json=request_body,
+                ) as response:
+                    payload = await response.json(content_type=None)
+        except (ClientError, TimeoutError, ValueError) as exc:
+            raise AssistantProviderError("无法连接 OpenAI 兼容服务。") from exc
+        if response.status != 200:
+            raise AssistantProviderError(f"模型测试失败:HTTP {response.status}。")
+        return {"ok": True, "model": self.model, "response_id": payload.get("id", "")}
+
+
+def provider_from_wbb(session: ClientSession) -> OpenAICompatibleAssistant:
+    return OpenAICompatibleAssistant(
+        session,
+        base_url=str(getattr(wbb, "BUSINESS_ASSISTANT_OPENAI_BASE_URL", "")),
+        api_key=str(getattr(wbb, "BUSINESS_ASSISTANT_OPENAI_API_KEY", "")),
+        model=str(getattr(wbb, "BUSINESS_ASSISTANT_OPENAI_MODEL", "")),
+        timeout_seconds=int(
+            getattr(wbb, "BUSINESS_ASSISTANT_OPENAI_TIMEOUT_SECONDS", 30)
+        ),
+        max_output_tokens=int(
+            getattr(wbb, "BUSINESS_ASSISTANT_OPENAI_MAX_OUTPUT_TOKENS", 600)
+        ),
+    )
+
+
+class BusinessAssistantRuntime:
+    def __init__(
+        self,
+        *,
+        token: str,
+        session: ClientSession,
+        provider: OpenAICompatibleAssistant | None = None,
+    ) -> None:
+        self.api = TelegramBusinessApi(token, session)
+        self.provider = provider or provider_from_wbb(session)
+        self._poll_task: asyncio.Task[None] | None = None
+        self._digest_task: asyncio.Task[None] | None = None
+        self._stopping = asyncio.Event()
+
+    async def start(self) -> dict[str, Any]:
+        if self._poll_task and not self._poll_task.done():
+            return await runtime_status()
+        self._stopping.clear()
+        await update_runtime_status(
+            {
+                "polling_state": "starting",
+                "model_configured": self.provider.configured,
+                "last_error": "",
+            }
+        )
+        try:
+            me = await self.api.get_me()
+            webhook = await self.api.get_webhook_info()
+        except TelegramBotApiError as exc:
+            return await update_runtime_status(
+                {"polling_state": "error", "last_error": str(exc)}
+            )
+        webhook_url = str(webhook.get("url") or "")
+        supported = bool(me.get("can_connect_to_business"))
+        await update_runtime_status(
+            {
+                "business_mode_supported": supported,
+                "webhook_conflict": bool(webhook_url),
+                "webhook_url": webhook_url,
+                "bot_username": str(me.get("username") or ""),
+            }
+        )
+        if webhook_url:
+            return await update_runtime_status(
+                {
+                    "polling_state": "blocked",
+                    "last_error": "检测到 Telegram webhook,Business 长轮询未启动。",
+                }
+            )
+        if not supported:
+            return await update_runtime_status(
+                {
+                    "polling_state": "blocked",
+                    "last_error": "请先在 BotFather 为机器人开启 Business Mode。",
+                }
+            )
+        await self._refresh_connections()
+        self._poll_task = asyncio.create_task(
+            self._poll_loop(), name="business-assistant-poller"
+        )
+        self._digest_task = asyncio.create_task(
+            self._digest_loop(), name="business-assistant-digest"
+        )
+        return await update_runtime_status(
+            {"polling_state": "running", "last_error": "", "started_at": utc_now()}
+        )
+
+    async def _refresh_connections(self) -> None:
+        page = 1
+        while True:
+            connections, total = await list_business_connections(
+                page=page, page_size=100
+            )
+            for connection in connections:
+                connection_id = str(connection["connection_id"])
+                try:
+                    payload = await self.api.get_business_connection(connection_id)
+                    await upsert_business_connection(payload)
+                except TelegramBotApiError as exc:
+                    await touch_business_connection(
+                        connection_id,
+                        error=str(exc),
+                        is_enabled=False if exc.error_code in {400, 403} else None,
+                    )
+            if not connections or page * 100 >= total:
+                return
+            page += 1
+
+    async def stop(self) -> None:
+        self._stopping.set()
+        tasks = [task for task in (self._poll_task, self._digest_task) if task]
+        for task in tasks:
+            task.cancel()
+        if tasks:
+            await asyncio.gather(*tasks, return_exceptions=True)
+        self._poll_task = None
+        self._digest_task = None
+        await update_runtime_status(
+            {"polling_state": "stopped", "stopped_at": utc_now()}
+        )
+
+    async def _poll_loop(self) -> None:
+        offset = await load_update_offset()
+        backoff = 1
+        while not self._stopping.is_set():
+            try:
+                updates = await self.api.get_updates(offset=offset, poll_timeout=30)
+                backoff = 1
+                await update_runtime_status(
+                    {"polling_state": "running", "last_poll_at": utc_now(), "last_error": ""}
+                )
+                for update in updates:
+                    update_id = int(update.get("update_id") or 0)
+                    if update_id <= 0:
+                        continue
+                    await self.process_update(update)
+                    offset = max(offset, update_id + 1)
+                    await save_update_offset(offset)
+            except asyncio.CancelledError:
+                raise
+            except TelegramBotApiError as exc:
+                wait_for = exc.retry_after or backoff
+                await update_runtime_status(
+                    {"polling_state": "retrying", "last_error": str(exc)}
+                )
+                await asyncio.sleep(min(max(wait_for, 1), 60))
+                backoff = min(backoff * 2, 60)
+            except Exception as exc:
+                await update_runtime_status(
+                    {"polling_state": "retrying", "last_error": str(exc)[:1000]}
+                )
+                await asyncio.sleep(backoff)
+                backoff = min(backoff * 2, 60)
+
+    async def process_update(self, update: dict[str, Any]) -> None:
+        update_id = int(update.get("update_id") or 0)
+        if not await claim_update(update_id):
+            return
+        last_error = ""
+        for attempt in range(1, 4):
+            try:
+                if isinstance(update.get("business_connection"), dict):
+                    await upsert_business_connection(update["business_connection"])
+                elif isinstance(update.get("business_message"), dict):
+                    await self._handle_business_message(update["business_message"])
+                elif isinstance(update.get("edited_business_message"), dict):
+                    await self._handle_edited_message(update["edited_business_message"])
+                elif isinstance(update.get("deleted_business_messages"), dict):
+                    await self._handle_deleted_messages(update["deleted_business_messages"])
+                await mark_update_done(update_id)
+                return
+            except TelegramBotApiError as exc:
+                last_error = str(exc)
+                if exc.retry_after:
+                    await asyncio.sleep(min(exc.retry_after, 60))
+            except Exception as exc:
+                last_error = str(exc)
+            if attempt < 3:
+                await asyncio.sleep(attempt)
+        await mark_update_failed(update_id, last_error)
+        await dead_letter_update(update, last_error or "unknown update error")
+
+    async def _resolve_connection(self, connection_id: str) -> dict[str, Any]:
+        connection = await get_business_connection(connection_id)
+        if connection:
+            return connection
+        payload = await self.api.get_business_connection(connection_id)
+        return await upsert_business_connection(payload)
+
+    async def _handle_business_message(self, message: dict[str, Any]) -> None:
+        connection_id = str(message.get("business_connection_id") or "")
+        if not connection_id or message.get("is_from_offline"):
+            return
+        if message.get("sender_business_bot"):
+            return
+        connection = await self._resolve_connection(connection_id)
+        await touch_business_connection(connection_id)
+        chat = message.get("chat") if isinstance(message.get("chat"), dict) else {}
+        sender = message.get("from") if isinstance(message.get("from"), dict) else {}
+        chat_id = int(chat.get("id") or 0)
+        message_id = int(message.get("message_id") or 0)
+        if not chat_id or not message_id:
+            return
+        owner_id = int((connection.get("user") or {}).get("id") or 0)
+        owner_reply = int(sender.get("id") or 0) == owner_id
+        conversation = await get_or_create_conversation(
+            connection_id,
+            chat_id,
+            customer=None if owner_reply else sender,
+        )
+        if owner_reply:
+            settings = await get_account_settings(connection_id)
+            await append_conversation_message(
+                conversation["conversation_id"],
+                direction="human",
+                telegram_message_id=message_id,
+                text=str(message.get("text") or message.get("caption") or ""),
+                sender_id=owner_id,
+            )
+            await pause_conversation_for_human(
+                conversation["conversation_id"],
+                hours=int(settings.get("human_pause_hours") or 24),
+            )
+            return
+        await self._handle_customer_message(
+            connection=connection,
+            conversation=conversation,
+            message=message,
+            sender=sender,
+        )
+
+    async def _handle_customer_message(
+        self,
+        *,
+        connection: dict[str, Any],
+        conversation: dict[str, Any],
+        message: dict[str, Any],
+        sender: dict[str, Any],
+    ) -> None:
+        connection_id = str(connection["connection_id"])
+        chat_id = int((message.get("chat") or {}).get("id") or 0)
+        message_id = int(message.get("message_id") or 0)
+        text = str(message.get("text") or "").strip()
+        settings = await get_account_settings(connection_id)
+        await append_conversation_message(
+            conversation["conversation_id"],
+            direction="incoming",
+            telegram_message_id=message_id,
+            text=text or "[非文本消息]",
+            sender_id=int(sender.get("id") or 0),
+            metadata={"content_type": "text" if text else "unsupported"},
+        )
+        if not connection.get("is_enabled") or not settings.get("assistant_enabled"):
+            return
+        current = await get_conversation(conversation["conversation_id"]) or conversation
+        if current.get("status") == "human_paused":
+            paused_until = current.get("paused_until")
+            if isinstance(paused_until, datetime) and (
+                paused_until.replace(tzinfo=UTC) if paused_until.tzinfo is None else paused_until
+            ) <= utc_now():
+                current = await resume_conversation(current["conversation_id"])
+            else:
+                return
+        if current.get("status") in {"handoff", "closed"}:
+            return
+        if not business_reply_window_open(message):
+            await self._handoff(
+                connection,
+                current,
+                settings,
+                "reply_window_expired",
+                send_customer_notice=False,
+            )
+            return
+        rights = connection.get("rights") or {}
+        if not rights.get("can_reply"):
+            await self._handoff(
+                connection, current, settings, "can_reply_missing", send_customer_notice=False
+            )
+            return
+        if rights.get("can_read_messages"):
+            with suppress(TelegramBotApiError):
+                await self.api.read_business_message(connection_id, chat_id, message_id)
+        if not text:
+            await self._handoff(
+                connection,
+                current,
+                settings,
+                "unsupported_message",
+                customer_notice=str(settings.get("unsupported_message")),
+            )
+            return
+        classification = classify_handoff(text)
+        if classification:
+            await self._handoff(connection, current, settings, classification)
+            return
+        knowledge = await match_knowledge(connection_id, text)
+        if not knowledge:
+            await self._handoff(connection, current, settings, "knowledge_not_found")
+            return
+        allowed, quota_reason = await reserve_ai_usage(
+            connection_id, chat_id, settings
+        )
+        if not allowed:
+            await self._handoff(connection, current, settings, quota_reason)
+            return
+        messages = await recent_conversation_messages(current["conversation_id"], limit=12)
+        try:
+            decision = await self.provider.decide(
+                settings=settings,
+                conversation=current,
+                messages=messages,
+                knowledge=knowledge,
+                customer_text=text,
+            )
+        except AssistantProviderError as exc:
+            await update_runtime_status(
+                {"provider_last_error": str(exc)[:1000], "provider_last_error_at": utc_now()}
+            )
+            await self._handoff(
+                connection,
+                current,
+                settings,
+                "provider_error",
+            )
+            return
+        if decision["summary"]:
+            await update_conversation_summary(
+                current["conversation_id"], decision["summary"]
+            )
+        if decision["action"] == "handoff":
+            await self._handoff(
+                connection,
+                current,
+                settings,
+                decision["handoff_reason"],
+                summary=decision["summary"],
+            )
+            return
+        response = await self.api.send_message(
+            chat_id,
+            decision["reply"],
+            business_connection_id=connection_id,
+        )
+        await append_conversation_message(
+            current["conversation_id"],
+            direction="assistant",
+            telegram_message_id=int(response.get("message_id") or 0),
+            text=decision["reply"],
+            sender_id=int((response.get("sender_business_bot") or {}).get("id") or 0),
+            metadata={
+                "action": decision["action"],
+                "matched_entry_ids": decision["matched_entry_ids"],
+            },
+        )
+
+    async def _handoff(
+        self,
+        connection: dict[str, Any],
+        conversation: dict[str, Any],
+        settings: dict[str, Any],
+        reason: str,
+        *,
+        summary: str = "",
+        customer_notice: str = "",
+        send_customer_notice: bool = True,
+    ) -> None:
+        updated = await set_conversation_handoff(
+            conversation["conversation_id"], reason, summary=summary
+        )
+        notice = customer_notice or str(settings.get("handoff_message") or "")
+        if send_customer_notice and notice and connection.get("rights", {}).get("can_reply"):
+            try:
+                response = await self.api.send_message(
+                    int(updated["chat_id"]),
+                    notice,
+                    business_connection_id=str(connection["connection_id"]),
+                )
+                await append_conversation_message(
+                    updated["conversation_id"],
+                    direction="assistant",
+                    telegram_message_id=int(response.get("message_id") or 0),
+                    text=notice,
+                    metadata={"action": "handoff", "reason": reason},
+                )
+            except TelegramBotApiError as exc:
+                await update_runtime_status(
+                    {
+                        "customer_notice_last_error": str(exc)[:1000],
+                        "customer_notice_last_error_at": utc_now(),
+                    }
+                )
+        await self.notify_handoff(connection, updated, settings, reason)
+
+    async def notify_handoff(
+        self,
+        connection: dict[str, Any],
+        conversation: dict[str, Any],
+        settings: dict[str, Any],
+        reason: str,
+    ) -> None:
+        customer = conversation.get("customer") or {}
+        display = " ".join(
+            item for item in (customer.get("first_name"), customer.get("last_name")) if item
+        ) or (f"@{customer.get('username')}" if customer.get("username") else str(conversation["chat_id"]))
+        latest = await recent_conversation_messages(conversation["conversation_id"], limit=1)
+        latest_text = str(latest[-1].get("text") or "")[:1000] if latest else ""
+        text = (
+            "需要人工接待\n"
+            f"账号:{(connection.get('user') or {}).get('first_name') or connection['connection_id']}\n"
+            f"客户:{display}\n"
+            f"原因:{reason}\n"
+            f"原消息:chat_id={conversation['chat_id']} / "
+            f"message_id={latest[-1].get('telegram_message_id') if latest else '未知'}\n"
+            f"摘要:{conversation.get('summary') or latest_text or '暂无'}"
+        )
+        markup = {
+            "inline_keyboard": [
+                [
+                    {
+                        "text": "恢复自动回复",
+                        "callback_data": f"ba:resume:{conversation['conversation_id']}",
+                    }
+                ]
+            ]
+        }
+        destinations: list[int] = []
+        destination = str(settings.get("notification_destination") or "owner")
+        if destination in {"owner", "both"} and int(connection.get("user_chat_id") or 0):
+            destinations.append(int(connection["user_chat_id"]))
+        if destination in {"ops", "both"} and int(settings.get("ops_group_id") or 0):
+            destinations.append(int(settings["ops_group_id"]))
+        for chat_id in dict.fromkeys(destinations):
+            try:
+                await self.api.send_message(chat_id, text, reply_markup=markup)
+            except TelegramBotApiError as exc:
+                await update_runtime_status(
+                    {
+                        "notification_last_error": str(exc)[:1000],
+                        "notification_last_error_at": utc_now(),
+                    }
+                )
+
+    async def _handle_edited_message(self, message: dict[str, Any]) -> None:
+        connection_id = str(message.get("business_connection_id") or "")
+        chat_id = int((message.get("chat") or {}).get("id") or 0)
+        if not connection_id or not chat_id:
+            return
+        await touch_business_connection(connection_id)
+        conversation = await get_conversation_by_chat(connection_id, chat_id)
+        if not conversation:
+            return
+        await append_conversation_message(
+            conversation["conversation_id"],
+            direction="incoming",
+            telegram_message_id=-int(message.get("message_id") or 0),
+            text=str(message.get("text") or "[消息已编辑]"),
+            sender_id=int((message.get("from") or {}).get("id") or 0),
+            metadata={"edited_message_id": int(message.get("message_id") or 0)},
+        )
+
+    async def _handle_deleted_messages(self, payload: dict[str, Any]) -> None:
+        connection_id = str(payload.get("business_connection_id") or "")
+        chat_id = int((payload.get("chat") or {}).get("id") or 0)
+        if connection_id:
+            await touch_business_connection(connection_id)
+        conversation = await get_conversation_by_chat(connection_id, chat_id)
+        if not conversation:
+            return
+        await append_conversation_message(
+            conversation["conversation_id"],
+            direction="incoming",
+            telegram_message_id=-abs(int((payload.get("message_ids") or [0])[0] or 0)) - 1,
+            text="[消息已删除]",
+            metadata={"deleted_message_ids": payload.get("message_ids") or []},
+        )
+
+    async def _digest_loop(self) -> None:
+        while not self._stopping.is_set():
+            try:
+                for item in await due_digest_connections():
+                    connection = item["connection"]
+                    settings = item["settings"]
+                    metrics = await usage_metrics(
+                        connection_id=str(connection["connection_id"]),
+                        day=str(item["day"]),
+                    )
+                    text = (
+                        f"智能接待运营简报 · {item['day']}\n"
+                        f"客户数:{metrics['customers']}\n"
+                        f"AI 调用:{metrics['ai_calls']}\n"
+                        f"待人工:{metrics['handoffs']}\n"
+                        f"人工暂停:{metrics['human_paused']}"
+                    )
+                    destinations: list[int] = []
+                    destination = str(settings.get("notification_destination") or "owner")
+                    if destination in {"owner", "both"} and connection.get("user_chat_id"):
+                        destinations.append(int(connection["user_chat_id"]))
+                    if destination in {"ops", "both"} and settings.get("ops_group_id"):
+                        destinations.append(int(settings["ops_group_id"]))
+                    sent = False
+                    for chat_id in dict.fromkeys(destinations):
+                        try:
+                            await self.api.send_message(chat_id, text)
+                            sent = True
+                        except TelegramBotApiError as exc:
+                            await update_runtime_status(
+                                {
+                                    "digest_last_error": str(exc)[:1000],
+                                    "digest_last_error_at": utc_now(),
+                                }
+                            )
+                    if sent:
+                        await mark_digest_sent(connection["connection_id"], item["day"])
+            except asyncio.CancelledError:
+                raise
+            except Exception as exc:
+                await update_runtime_status({"digest_last_error": str(exc)[:1000]})
+            await asyncio.sleep(60)
+
+    async def preview_answer(self, connection_id: str, text: str) -> dict[str, Any]:
+        settings = await get_account_settings(connection_id)
+        knowledge = await match_knowledge(connection_id, text)
+        classification = classify_handoff(text)
+        if classification or not knowledge:
+            return {
+                "action": "handoff",
+                "reason": classification or "knowledge_not_found",
+                "matched_entries": knowledge,
+            }
+        conversation = {"summary": ""}
+        decision = await self.provider.decide(
+            settings=settings,
+            conversation=conversation,
+            messages=[],
+            knowledge=knowledge,
+            customer_text=text,
+        )
+        return {**decision, "matched_entries": knowledge}
+
+
+async def runtime_overview() -> dict[str, Any]:
+    status = await runtime_status()
+    status["usage"] = await usage_metrics()
+    return status

+ 1047 - 0
wbb/utils/dbassistant.py

@@ -0,0 +1,1047 @@
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import re
+from datetime import UTC, datetime, timedelta
+from typing import Any
+from uuid import uuid4
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+from pymongo import ASCENDING, DESCENDING
+from pymongo.errors import DuplicateKeyError
+
+from wbb import BOT_PROFILE_ID, db
+
+connectionsdb = db.business_assistant_connections
+settingsdb = db.business_assistant_settings
+knowledgedb = db.business_assistant_knowledge
+conversationsdb = db.business_assistant_conversations
+messagesdb = db.business_assistant_messages
+usagedb = db.business_assistant_usage
+updatesdb = db.business_assistant_updates
+deadlettersdb = db.business_assistant_dead_letters
+runtimedb = db.business_assistant_runtime
+
+DEFAULT_ACCOUNT_SETTINGS: dict[str, Any] = {
+    "assistant_enabled": False,
+    "system_prompt": (
+        "你是该 Telegram 账号的智能接待秘书。回答要简洁、礼貌,只能依据提供的知识条目陈述业务事实。"
+    ),
+    "language": "zh-CN",
+    "tone": "professional",
+    "account_daily_limit": 200,
+    "customer_daily_limit": 20,
+    "human_pause_hours": 24,
+    "notification_destination": "owner",
+    "ops_group_id": 0,
+    "timezone": "Asia/Shanghai",
+    "digest_enabled": False,
+    "digest_time": "09:00",
+    "handoff_message": "这个问题需要人工确认,我已经通知负责人,请稍候。",
+    "unsupported_message": "已收到你的消息,这类内容需要人工处理,我已经通知负责人。",
+}
+
+VALID_NOTIFICATION_DESTINATIONS = {"owner", "ops", "both"}
+VALID_TONES = {"professional", "friendly", "concise"}
+HANDOFF_TERMS = (
+    "人工",
+    "真人",
+    "客服",
+    "负责人",
+    "转人工",
+    "human",
+    "agent",
+)
+SENSITIVE_TERMS = (
+    "承诺",
+    "保证",
+    "投诉",
+    "退款",
+    "退钱",
+    "赔偿",
+    "律师",
+    "起诉",
+    "支付失败",
+    "账号被盗",
+    "密码",
+    "验证码",
+)
+
+_index_lock = asyncio.Lock()
+_indexes_ready = False
+
+
+class AssistantDataError(ValueError):
+    def __init__(self, code: str, message: str):
+        super().__init__(message)
+        self.code = code
+
+
+def utc_now() -> datetime:
+    return datetime.now(UTC)
+
+
+def as_utc(value: datetime) -> datetime:
+    return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
+
+
+def _scope(filters: dict[str, Any] | None = None, /, **values: Any) -> dict[str, Any]:
+    return {"bot_id": BOT_PROFILE_ID, **(filters or {}), **values}
+
+
+def clean_text(
+    value: Any,
+    *,
+    max_length: int,
+    required: bool = False,
+    preserve_lines: bool = False,
+) -> str:
+    text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", str(value or ""))
+    text = text.strip()
+    if not preserve_lines:
+        text = " ".join(text.split())
+    if required and not text:
+        raise AssistantDataError("required_field", "必填内容不能为空。")
+    if len(text) > max_length:
+        raise AssistantDataError("text_too_long", f"内容不能超过 {max_length} 个字符。")
+    return text
+
+
+def _bounded_int(
+    value: Any,
+    name: str,
+    *,
+    minimum: int,
+    maximum: int,
+) -> int:
+    try:
+        parsed = int(value)
+    except (TypeError, ValueError) as exc:
+        raise AssistantDataError("invalid_setting", f"{name} 必须是整数。") from exc
+    if not minimum <= parsed <= maximum:
+        raise AssistantDataError(
+            "invalid_setting",
+            f"{name} 必须在 {minimum} 到 {maximum} 之间。",
+        )
+    return parsed
+
+
+def _normalize_string_list(value: Any, *, max_items: int, max_length: int) -> list[str]:
+    values = value if isinstance(value, (list, tuple, set)) else str(value or "").split(",")
+    normalized: list[str] = []
+    seen: set[str] = set()
+    for item in values:
+        text = clean_text(item, max_length=max_length)
+        key = text.casefold()
+        if text and key not in seen:
+            normalized.append(text)
+            seen.add(key)
+    if len(normalized) > max_items:
+        raise AssistantDataError("too_many_items", f"最多允许 {max_items} 项。")
+    return normalized
+
+
+async def ensure_assistant_indexes() -> None:
+    global _indexes_ready
+    if _indexes_ready:
+        return
+    async with _index_lock:
+        if _indexes_ready:
+            return
+        await connectionsdb.create_index(
+            [("bot_id", ASCENDING), ("connection_id", ASCENDING)], unique=True
+        )
+        await connectionsdb.create_index(
+            [("bot_id", ASCENDING), ("updated_at", DESCENDING)]
+        )
+        await settingsdb.create_index(
+            [("bot_id", ASCENDING), ("connection_id", ASCENDING)], unique=True
+        )
+        await knowledgedb.create_index(
+            [("bot_id", ASCENDING), ("entry_id", ASCENDING)], unique=True
+        )
+        await knowledgedb.create_index(
+            [
+                ("bot_id", ASCENDING),
+                ("connection_id", ASCENDING),
+                ("enabled", ASCENDING),
+                ("priority", DESCENDING),
+            ]
+        )
+        await conversationsdb.create_index(
+            [("bot_id", ASCENDING), ("conversation_id", ASCENDING)], unique=True
+        )
+        await conversationsdb.create_index(
+            [
+                ("bot_id", ASCENDING),
+                ("connection_id", ASCENDING),
+                ("chat_id", ASCENDING),
+            ],
+            unique=True,
+        )
+        await conversationsdb.create_index(
+            [
+                ("bot_id", ASCENDING),
+                ("connection_id", ASCENDING),
+                ("status", ASCENDING),
+                ("updated_at", DESCENDING),
+            ]
+        )
+        await messagesdb.create_index(
+            [("bot_id", ASCENDING), ("message_key", ASCENDING)], unique=True
+        )
+        await messagesdb.create_index(
+            [
+                ("bot_id", ASCENDING),
+                ("conversation_id", ASCENDING),
+                ("created_at", ASCENDING),
+            ]
+        )
+        await messagesdb.create_index("expires_at", expireAfterSeconds=0)
+        await usagedb.create_index(
+            [
+                ("bot_id", ASCENDING),
+                ("connection_id", ASCENDING),
+                ("chat_id", ASCENDING),
+                ("day", ASCENDING),
+            ],
+            unique=True,
+        )
+        await updatesdb.create_index(
+            [("bot_id", ASCENDING), ("update_id", ASCENDING)], unique=True
+        )
+        await updatesdb.create_index("expires_at", expireAfterSeconds=0)
+        await deadlettersdb.create_index(
+            [("bot_id", ASCENDING), ("update_id", ASCENDING)], unique=True
+        )
+        await deadlettersdb.create_index(
+            [("bot_id", ASCENDING), ("created_at", DESCENDING)]
+        )
+        await deadlettersdb.create_index("expires_at", expireAfterSeconds=0)
+        await runtimedb.create_index(
+            [("bot_id", ASCENDING), ("runtime_id", ASCENDING)], unique=True
+        )
+        _indexes_ready = True
+
+
+def _public_user(user: Any) -> dict[str, Any]:
+    value = user if isinstance(user, dict) else {}
+    return {
+        "id": int(value.get("id") or 0),
+        "username": clean_text(value.get("username"), max_length=64),
+        "first_name": clean_text(value.get("first_name"), max_length=128),
+        "last_name": clean_text(value.get("last_name"), max_length=128),
+    }
+
+
+async def upsert_business_connection(payload: dict[str, Any]) -> dict[str, Any]:
+    await ensure_assistant_indexes()
+    connection_id = clean_text(payload.get("id"), max_length=128, required=True)
+    now = utc_now()
+    rights = payload.get("rights") if isinstance(payload.get("rights"), dict) else {}
+    document = {
+        "bot_id": BOT_PROFILE_ID,
+        "connection_id": connection_id,
+        "user": _public_user(payload.get("user")),
+        "user_chat_id": int(payload.get("user_chat_id") or 0),
+        "rights": {str(key): bool(value) for key, value in rights.items() if value is True},
+        "is_enabled": bool(payload.get("is_enabled")),
+        "connected_at": datetime.fromtimestamp(int(payload.get("date") or 0), UTC)
+        if payload.get("date")
+        else now,
+        "last_event_at": now,
+        "updated_at": now,
+    }
+    await connectionsdb.update_one(
+        _scope(connection_id=connection_id),
+        {"$set": document, "$setOnInsert": {"created_at": now}},
+        upsert=True,
+    )
+    await settingsdb.update_one(
+        _scope(connection_id=connection_id),
+        {
+            "$setOnInsert": {
+                "bot_id": BOT_PROFILE_ID,
+                "connection_id": connection_id,
+                **DEFAULT_ACCOUNT_SETTINGS,
+                "created_at": now,
+                "updated_at": now,
+            }
+        },
+        upsert=True,
+    )
+    return await get_business_connection(connection_id) or document
+
+
+async def get_business_connection(connection_id: str) -> dict[str, Any] | None:
+    await ensure_assistant_indexes()
+    return await connectionsdb.find_one(_scope(connection_id=str(connection_id)))
+
+
+async def touch_business_connection(
+    connection_id: str, *, error: str = "", is_enabled: bool | None = None
+) -> dict[str, Any] | None:
+    now = utc_now()
+    values: dict[str, Any] = {
+        "last_event_at": now,
+        "updated_at": now,
+        "last_error": clean_text(error, max_length=1000),
+    }
+    if is_enabled is not None:
+        values["is_enabled"] = bool(is_enabled)
+    await connectionsdb.update_one(
+        _scope(connection_id=str(connection_id)),
+        {"$set": values},
+    )
+    return await get_business_connection(connection_id)
+
+
+async def list_business_connections(
+    *, page: int = 1, page_size: int = 20
+) -> tuple[list[dict[str, Any]], int]:
+    await ensure_assistant_indexes()
+    page = max(1, int(page))
+    page_size = max(1, min(int(page_size), 100))
+    filters = _scope()
+    total = await connectionsdb.count_documents(filters)
+    cursor = (
+        connectionsdb.find(filters)
+        .sort("updated_at", DESCENDING)
+        .skip((page - 1) * page_size)
+        .limit(page_size)
+    )
+    items = []
+    async for item in cursor:
+        item["settings"] = await get_account_settings(item["connection_id"])
+        items.append(item)
+    return items, total
+
+
+async def get_account_settings(connection_id: str) -> dict[str, Any]:
+    await ensure_assistant_indexes()
+    stored = await settingsdb.find_one(_scope(connection_id=str(connection_id))) or {}
+    return {
+        **DEFAULT_ACCOUNT_SETTINGS,
+        **{key: value for key, value in stored.items() if key != "_id"},
+        "connection_id": str(connection_id),
+    }
+
+
+def normalize_account_settings(
+    values: dict[str, Any], *, previous: dict[str, Any] | None = None
+) -> dict[str, Any]:
+    current = {**DEFAULT_ACCOUNT_SETTINGS, **(previous or {})}
+    if "assistant_enabled" in values:
+        current["assistant_enabled"] = bool(values.get("assistant_enabled"))
+    if "system_prompt" in values:
+        current["system_prompt"] = clean_text(
+            values.get("system_prompt"), max_length=4000, required=True, preserve_lines=True
+        )
+    if "language" in values:
+        current["language"] = clean_text(values.get("language"), max_length=32, required=True)
+    if "tone" in values:
+        tone = str(values.get("tone") or "")
+        if tone not in VALID_TONES:
+            raise AssistantDataError("invalid_setting", "接待语气无效。")
+        current["tone"] = tone
+    if "account_daily_limit" in values:
+        current["account_daily_limit"] = _bounded_int(
+            values.get("account_daily_limit"), "账号每日额度", minimum=1, maximum=100000
+        )
+    if "customer_daily_limit" in values:
+        current["customer_daily_limit"] = _bounded_int(
+            values.get("customer_daily_limit"), "客户每日额度", minimum=1, maximum=1000
+        )
+    if "human_pause_hours" in values:
+        current["human_pause_hours"] = _bounded_int(
+            values.get("human_pause_hours"), "人工暂停小时数", minimum=1, maximum=168
+        )
+    if "notification_destination" in values:
+        destination = str(values.get("notification_destination") or "")
+        if destination not in VALID_NOTIFICATION_DESTINATIONS:
+            raise AssistantDataError("invalid_setting", "通知目的地无效。")
+        current["notification_destination"] = destination
+    if "ops_group_id" in values:
+        try:
+            current["ops_group_id"] = int(values.get("ops_group_id") or 0)
+        except (TypeError, ValueError) as exc:
+            raise AssistantDataError("invalid_setting", "运营群 ID 必须是整数。") from exc
+    if "timezone" in values:
+        timezone_name = clean_text(values.get("timezone"), max_length=64, required=True)
+        try:
+            ZoneInfo(timezone_name)
+        except ZoneInfoNotFoundError as exc:
+            raise AssistantDataError("invalid_setting", "时区名称无效。") from exc
+        current["timezone"] = timezone_name
+    if "digest_enabled" in values:
+        current["digest_enabled"] = bool(values.get("digest_enabled"))
+    if "digest_time" in values:
+        digest_time = str(values.get("digest_time") or "")
+        if not re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", digest_time):
+            raise AssistantDataError("invalid_setting", "简报时间必须是 HH:MM。")
+        current["digest_time"] = digest_time
+    for key, max_length in (("handoff_message", 1000), ("unsupported_message", 1000)):
+        if key in values:
+            current[key] = clean_text(values.get(key), max_length=max_length, required=True)
+    return {key: current[key] for key in DEFAULT_ACCOUNT_SETTINGS}
+
+
+async def update_account_settings(
+    connection_id: str, values: dict[str, Any]
+) -> dict[str, Any]:
+    connection = await get_business_connection(connection_id)
+    if not connection:
+        raise AssistantDataError("connection_not_found", "未找到 Business 连接。")
+    previous = await get_account_settings(connection_id)
+    normalized = normalize_account_settings(values, previous=previous)
+    now = utc_now()
+    await settingsdb.update_one(
+        _scope(connection_id=str(connection_id)),
+        {
+            "$set": {**normalized, "updated_at": now},
+            "$setOnInsert": {
+                "bot_id": BOT_PROFILE_ID,
+                "connection_id": str(connection_id),
+                "created_at": now,
+            },
+        },
+        upsert=True,
+    )
+    return await get_account_settings(connection_id)
+
+
+async def create_knowledge_entry(
+    connection_id: str, values: dict[str, Any]
+) -> dict[str, Any]:
+    if not await get_business_connection(connection_id):
+        raise AssistantDataError("connection_not_found", "未找到 Business 连接。")
+    now = utc_now()
+    entry = {
+        "bot_id": BOT_PROFILE_ID,
+        "entry_id": uuid4().hex,
+        "connection_id": str(connection_id),
+        "question": clean_text(values.get("question"), max_length=300, required=True),
+        "aliases": _normalize_string_list(values.get("aliases"), max_items=20, max_length=200),
+        "keywords": _normalize_string_list(values.get("keywords"), max_items=30, max_length=50),
+        "answer": clean_text(
+            values.get("answer"), max_length=4000, required=True, preserve_lines=True
+        ),
+        "tags": _normalize_string_list(values.get("tags"), max_items=20, max_length=30),
+        "priority": _bounded_int(
+            values.get("priority", 0), "知识优先级", minimum=-1000, maximum=1000
+        ),
+        "enabled": bool(values.get("enabled", True)),
+        "created_at": now,
+        "updated_at": now,
+    }
+    await knowledgedb.insert_one(entry)
+    return entry
+
+
+async def update_knowledge_entry(entry_id: str, values: dict[str, Any]) -> dict[str, Any]:
+    filters = _scope(entry_id=str(entry_id))
+    current = await knowledgedb.find_one(filters)
+    if not current:
+        raise AssistantDataError("knowledge_not_found", "未找到知识条目。")
+    update: dict[str, Any] = {}
+    if "question" in values:
+        update["question"] = clean_text(values.get("question"), max_length=300, required=True)
+    if "aliases" in values:
+        update["aliases"] = _normalize_string_list(
+            values.get("aliases"), max_items=20, max_length=200
+        )
+    if "keywords" in values:
+        update["keywords"] = _normalize_string_list(
+            values.get("keywords"), max_items=30, max_length=50
+        )
+    if "answer" in values:
+        update["answer"] = clean_text(
+            values.get("answer"), max_length=4000, required=True, preserve_lines=True
+        )
+    if "tags" in values:
+        update["tags"] = _normalize_string_list(values.get("tags"), max_items=20, max_length=30)
+    if "priority" in values:
+        update["priority"] = _bounded_int(
+            values.get("priority"), "知识优先级", minimum=-1000, maximum=1000
+        )
+    if "enabled" in values:
+        update["enabled"] = bool(values.get("enabled"))
+    if not update:
+        raise AssistantDataError("unchanged", "没有可保存的知识条目字段。")
+    update["updated_at"] = utc_now()
+    await knowledgedb.update_one(filters, {"$set": update})
+    return await knowledgedb.find_one(filters) or current
+
+
+async def delete_knowledge_entry(entry_id: str) -> None:
+    result = await knowledgedb.delete_one(_scope(entry_id=str(entry_id)))
+    if not result.deleted_count:
+        raise AssistantDataError("knowledge_not_found", "未找到知识条目。")
+
+
+async def list_knowledge_entries(
+    connection_id: str,
+    *,
+    query: str = "",
+    page: int = 1,
+    page_size: int = 20,
+) -> tuple[list[dict[str, Any]], int]:
+    await ensure_assistant_indexes()
+    filters: dict[str, Any] = _scope(connection_id=str(connection_id))
+    normalized_query = clean_text(query, max_length=100)
+    if normalized_query:
+        pattern = re.escape(normalized_query)
+        filters["$or"] = [
+            {"question": {"$regex": pattern, "$options": "i"}},
+            {"aliases": {"$regex": pattern, "$options": "i"}},
+            {"keywords": {"$regex": pattern, "$options": "i"}},
+            {"tags": {"$regex": pattern, "$options": "i"}},
+        ]
+    page = max(1, int(page))
+    page_size = max(1, min(int(page_size), 100))
+    total = await knowledgedb.count_documents(filters)
+    cursor = (
+        knowledgedb.find(filters)
+        .sort([("priority", DESCENDING), ("updated_at", DESCENDING)])
+        .skip((page - 1) * page_size)
+        .limit(page_size)
+    )
+    return [item async for item in cursor], total
+
+
+def _search_text(value: str) -> str:
+    return re.sub(r"[^\w\u3400-\u9fff]+", "", value.casefold())
+
+
+async def match_knowledge(
+    connection_id: str, query: str, *, limit: int = 8
+) -> list[dict[str, Any]]:
+    await ensure_assistant_indexes()
+    normalized_query = _search_text(query)
+    if not normalized_query:
+        return []
+    entries = [
+        item
+        async for item in knowledgedb.find(
+            _scope(connection_id=str(connection_id), enabled=True)
+        )
+    ]
+    scored: list[tuple[int, dict[str, Any]]] = []
+    for entry in entries:
+        score = int(entry.get("priority") or 0)
+        phrases = [entry.get("question", ""), *(entry.get("aliases") or [])]
+        for phrase in phrases:
+            normalized_phrase = _search_text(str(phrase))
+            if not normalized_phrase:
+                continue
+            if normalized_query == normalized_phrase:
+                score += 1000
+            elif normalized_phrase in normalized_query:
+                score += 300 + min(len(normalized_phrase), 100)
+            elif normalized_query in normalized_phrase and len(normalized_query) >= 4:
+                score += 120
+        for keyword in entry.get("keywords") or []:
+            normalized_keyword = _search_text(str(keyword))
+            if normalized_keyword and normalized_keyword in normalized_query:
+                score += 80
+        if score > int(entry.get("priority") or 0):
+            scored.append((score, entry))
+    scored.sort(key=lambda item: (item[0], item[1].get("priority", 0)), reverse=True)
+    return [{**entry, "match_score": score} for score, entry in scored[: max(1, limit)]]
+
+
+def classify_handoff(text: str) -> str | None:
+    normalized = text.casefold()
+    if any(term in normalized for term in HANDOFF_TERMS):
+        return "customer_requested_human"
+    if any(term in normalized for term in SENSITIVE_TERMS):
+        return "sensitive_request"
+    return None
+
+
+def conversation_id_for(connection_id: str, chat_id: int) -> str:
+    digest = hashlib.sha256(f"{BOT_PROFILE_ID}:{connection_id}:{int(chat_id)}".encode()).hexdigest()
+    return digest[:32]
+
+
+async def get_or_create_conversation(
+    connection_id: str,
+    chat_id: int,
+    *,
+    customer: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+    await ensure_assistant_indexes()
+    conversation_id = conversation_id_for(connection_id, chat_id)
+    now = utc_now()
+    update: dict[str, Any] = {"updated_at": now, "last_message_at": now}
+    if customer:
+        update["customer"] = _public_user(customer)
+    await conversationsdb.update_one(
+        _scope(conversation_id=conversation_id),
+        {
+            "$set": update,
+            "$setOnInsert": {
+                "bot_id": BOT_PROFILE_ID,
+                "conversation_id": conversation_id,
+                "connection_id": str(connection_id),
+                "chat_id": int(chat_id),
+                "status": "auto",
+                "summary": "",
+                "handoff_reason": "",
+                "created_at": now,
+            },
+        },
+        upsert=True,
+    )
+    return await conversationsdb.find_one(_scope(conversation_id=conversation_id)) or {}
+
+
+async def get_conversation(conversation_id: str) -> dict[str, Any] | None:
+    await ensure_assistant_indexes()
+    return await conversationsdb.find_one(
+        _scope(conversation_id=str(conversation_id))
+    )
+
+
+async def get_conversation_by_chat(
+    connection_id: str, chat_id: int
+) -> dict[str, Any] | None:
+    await ensure_assistant_indexes()
+    return await conversationsdb.find_one(
+        _scope(connection_id=str(connection_id), chat_id=int(chat_id))
+    )
+
+
+async def append_conversation_message(
+    conversation_id: str,
+    *,
+    direction: str,
+    telegram_message_id: int,
+    text: str,
+    sender_id: int = 0,
+    metadata: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+    await ensure_assistant_indexes()
+    if direction not in {"incoming", "assistant", "human"}:
+        raise AssistantDataError("invalid_direction", "会话消息方向无效。")
+    now = utc_now()
+    message_key = f"{conversation_id}:{direction}:{int(telegram_message_id)}"
+    document = {
+        "bot_id": BOT_PROFILE_ID,
+        "message_key": message_key,
+        "conversation_id": str(conversation_id),
+        "direction": direction,
+        "telegram_message_id": int(telegram_message_id),
+        "sender_id": int(sender_id or 0),
+        "text": clean_text(text, max_length=12000, preserve_lines=True),
+        "metadata": metadata or {},
+        "created_at": now,
+        "expires_at": now + timedelta(days=30),
+    }
+    try:
+        await messagesdb.insert_one(document)
+    except DuplicateKeyError:
+        return await messagesdb.find_one(_scope(message_key=message_key)) or document
+    await conversationsdb.update_one(
+        _scope(conversation_id=str(conversation_id)),
+        {"$set": {"last_message_at": now, "updated_at": now}},
+    )
+    return document
+
+
+async def recent_conversation_messages(
+    conversation_id: str, *, limit: int = 12
+) -> list[dict[str, Any]]:
+    cursor = (
+        messagesdb.find(_scope(conversation_id=str(conversation_id)))
+        .sort([("created_at", DESCENDING), ("_id", DESCENDING)])
+        .limit(max(1, min(int(limit), 50)))
+    )
+    items = [item async for item in cursor]
+    items.reverse()
+    return items
+
+
+async def set_conversation_handoff(
+    conversation_id: str,
+    reason: str,
+    *,
+    summary: str = "",
+) -> dict[str, Any]:
+    now = utc_now()
+    update: dict[str, Any] = {
+        "status": "handoff",
+        "handoff_reason": clean_text(reason, max_length=200, required=True),
+        "paused_until": None,
+        "handoff_at": now,
+        "updated_at": now,
+    }
+    if summary:
+        update["summary"] = clean_text(summary, max_length=4000, preserve_lines=True)
+    result = await conversationsdb.find_one_and_update(
+        _scope(conversation_id=str(conversation_id)),
+        {"$set": update},
+        return_document=True,
+    )
+    if not result:
+        raise AssistantDataError("conversation_not_found", "未找到客户会话。")
+    return result
+
+
+async def pause_conversation_for_human(
+    conversation_id: str, *, hours: int
+) -> dict[str, Any]:
+    now = utc_now()
+    result = await conversationsdb.find_one_and_update(
+        _scope(conversation_id=str(conversation_id)),
+        {
+            "$set": {
+                "status": "human_paused",
+                "handoff_reason": "human_reply_detected",
+                "paused_until": now + timedelta(hours=max(1, min(int(hours), 168))),
+                "updated_at": now,
+            }
+        },
+        return_document=True,
+    )
+    if not result:
+        raise AssistantDataError("conversation_not_found", "未找到客户会话。")
+    return result
+
+
+async def resume_conversation(conversation_id: str) -> dict[str, Any]:
+    result = await conversationsdb.find_one_and_update(
+        _scope(conversation_id=str(conversation_id)),
+        {
+            "$set": {
+                "status": "auto",
+                "handoff_reason": "",
+                "paused_until": None,
+                "updated_at": utc_now(),
+            }
+        },
+        return_document=True,
+    )
+    if not result:
+        raise AssistantDataError("conversation_not_found", "未找到客户会话。")
+    return result
+
+
+async def pause_conversation(conversation_id: str) -> dict[str, Any]:
+    return await set_conversation_handoff(conversation_id, "admin_paused")
+
+
+async def close_conversation(conversation_id: str) -> dict[str, Any]:
+    result = await conversationsdb.find_one_and_update(
+        _scope(conversation_id=str(conversation_id)),
+        {"$set": {"status": "closed", "closed_at": utc_now(), "updated_at": utc_now()}},
+        return_document=True,
+    )
+    if not result:
+        raise AssistantDataError("conversation_not_found", "未找到客户会话。")
+    return result
+
+
+async def clear_conversation(conversation_id: str) -> dict[str, Any]:
+    current = await get_conversation(conversation_id)
+    if not current:
+        raise AssistantDataError("conversation_not_found", "未找到客户会话。")
+    await messagesdb.delete_many(_scope(conversation_id=str(conversation_id)))
+    await conversationsdb.update_one(
+        _scope(conversation_id=str(conversation_id)),
+        {
+            "$set": {
+                "summary": "",
+                "handoff_reason": "",
+                "status": "auto",
+                "paused_until": None,
+                "cleared_at": utc_now(),
+                "updated_at": utc_now(),
+            }
+        },
+    )
+    return await get_conversation(conversation_id) or current
+
+
+async def update_conversation_summary(
+    conversation_id: str, summary: str
+) -> None:
+    cleaned = clean_text(summary, max_length=4000, preserve_lines=True)
+    if cleaned:
+        await conversationsdb.update_one(
+            _scope(conversation_id=str(conversation_id)),
+            {"$set": {"summary": cleaned, "updated_at": utc_now()}},
+        )
+
+
+async def list_conversations(
+    *,
+    connection_id: str = "",
+    status: str = "",
+    page: int = 1,
+    page_size: int = 20,
+) -> tuple[list[dict[str, Any]], int]:
+    await ensure_assistant_indexes()
+    filters: dict[str, Any] = _scope()
+    if connection_id:
+        filters["connection_id"] = str(connection_id)
+    if status:
+        if status not in {"auto", "handoff", "human_paused", "closed"}:
+            raise AssistantDataError("invalid_status", "会话状态无效。")
+        filters["status"] = status
+    page = max(1, int(page))
+    page_size = max(1, min(int(page_size), 100))
+    total = await conversationsdb.count_documents(filters)
+    cursor = (
+        conversationsdb.find(filters)
+        .sort("updated_at", DESCENDING)
+        .skip((page - 1) * page_size)
+        .limit(page_size)
+    )
+    return [item async for item in cursor], total
+
+
+async def conversation_detail(conversation_id: str) -> dict[str, Any]:
+    conversation = await get_conversation(conversation_id)
+    if not conversation:
+        raise AssistantDataError("conversation_not_found", "未找到客户会话。")
+    conversation["messages"] = await recent_conversation_messages(
+        conversation_id, limit=50
+    )
+    return conversation
+
+
+def _usage_day(now: datetime, timezone_name: str) -> str:
+    try:
+        zone = ZoneInfo(timezone_name)
+    except ZoneInfoNotFoundError:
+        zone = ZoneInfo("Asia/Shanghai")
+    return as_utc(now).astimezone(zone).date().isoformat()
+
+
+async def reserve_ai_usage(
+    connection_id: str,
+    chat_id: int,
+    settings: dict[str, Any],
+    *,
+    now: datetime | None = None,
+) -> tuple[bool, str]:
+    await ensure_assistant_indexes()
+    current_time = as_utc(now or utc_now())
+    day = _usage_day(current_time, str(settings.get("timezone") or "Asia/Shanghai"))
+    account_limit = int(settings.get("account_daily_limit") or 200)
+    customer_limit = int(settings.get("customer_daily_limit") or 20)
+    account_filter = _scope(
+        connection_id=str(connection_id), chat_id=0, day=day
+    )
+    customer_filter = _scope(
+        connection_id=str(connection_id), chat_id=int(chat_id), day=day
+    )
+    for filters, usage_scope in (
+        (account_filter, "account"),
+        (customer_filter, "customer"),
+    ):
+        await usagedb.update_one(
+            filters,
+            {
+                "$setOnInsert": {
+                    **filters,
+                    "scope": usage_scope,
+                    "call_count": 0,
+                    "created_at": current_time,
+                    "updated_at": current_time,
+                }
+            },
+            upsert=True,
+        )
+    account = await usagedb.find_one_and_update(
+        {**account_filter, "call_count": {"$lt": account_limit}},
+        {"$inc": {"call_count": 1}, "$set": {"updated_at": current_time}},
+        return_document=True,
+    )
+    if not account:
+        return False, "account_daily_limit"
+    customer = await usagedb.find_one_and_update(
+        {**customer_filter, "call_count": {"$lt": customer_limit}},
+        {"$inc": {"call_count": 1}, "$set": {"updated_at": current_time}},
+        return_document=True,
+    )
+    if not customer:
+        await usagedb.update_one(
+            {**account_filter, "call_count": {"$gt": 0}},
+            {"$inc": {"call_count": -1}, "$set": {"updated_at": current_time}},
+        )
+        return False, "customer_daily_limit"
+    return True, ""
+
+
+async def usage_metrics(*, connection_id: str = "", day: str = "") -> dict[str, Any]:
+    await ensure_assistant_indexes()
+    match = _scope()
+    if connection_id:
+        match["connection_id"] = str(connection_id)
+    if day:
+        match["day"] = str(day)
+    calls = 0
+    customers: set[tuple[str, int]] = set()
+    customer_match = {
+        **match,
+        "scope": {"$ne": "account"},
+        "chat_id": {"$ne": 0},
+        "call_count": {"$gt": 0},
+    }
+    async for item in usagedb.find(customer_match):
+        calls += int(item.get("call_count") or 0)
+        customers.add((str(item.get("connection_id")), int(item.get("chat_id") or 0)))
+    conversation_filters = _scope()
+    if connection_id:
+        conversation_filters["connection_id"] = str(connection_id)
+    return {
+        "ai_calls": calls,
+        "customers": len(customers),
+        "conversations": await conversationsdb.count_documents(conversation_filters),
+        "handoffs": await conversationsdb.count_documents(
+            {**conversation_filters, "status": "handoff"}
+        ),
+        "human_paused": await conversationsdb.count_documents(
+            {**conversation_filters, "status": "human_paused"}
+        ),
+    }
+
+
+async def claim_update(update_id: int) -> bool:
+    await ensure_assistant_indexes()
+    now = utc_now()
+    try:
+        await updatesdb.insert_one(
+            {
+                "bot_id": BOT_PROFILE_ID,
+                "update_id": int(update_id),
+                "status": "processing",
+                "attempts": 1,
+                "created_at": now,
+                "updated_at": now,
+                "expires_at": now + timedelta(days=7),
+            }
+        )
+        return True
+    except DuplicateKeyError:
+        filters = _scope(update_id=int(update_id))
+        current = await updatesdb.find_one(filters) or {}
+        if current.get("status") == "done":
+            return False
+        await updatesdb.update_one(
+            filters,
+            {"$inc": {"attempts": 1}, "$set": {"updated_at": now}},
+        )
+        return True
+
+
+async def mark_update_done(update_id: int) -> None:
+    await updatesdb.update_one(
+        _scope(update_id=int(update_id)),
+        {"$set": {"status": "done", "updated_at": utc_now(), "last_error": ""}},
+    )
+
+
+async def mark_update_failed(update_id: int, error: str) -> int:
+    await updatesdb.update_one(
+        _scope(update_id=int(update_id)),
+        {"$set": {"status": "failed", "updated_at": utc_now(), "last_error": error[:1000]}},
+    )
+    current = await updatesdb.find_one(_scope(update_id=int(update_id))) or {}
+    return int(current.get("attempts") or 1)
+
+
+async def dead_letter_update(update: dict[str, Any], error: str) -> None:
+    update_id = int(update.get("update_id") or 0)
+    now = utc_now()
+    await deadlettersdb.update_one(
+        _scope(update_id=update_id),
+        {
+            "$set": {
+                "bot_id": BOT_PROFILE_ID,
+                "payload": update,
+                "error": error[:2000],
+                "updated_at": now,
+                "expires_at": now + timedelta(days=30),
+            },
+            "$setOnInsert": {"created_at": now},
+        },
+        upsert=True,
+    )
+    await mark_update_done(update_id)
+
+
+async def runtime_status() -> dict[str, Any]:
+    await ensure_assistant_indexes()
+    stored = await runtimedb.find_one(
+        _scope(runtime_id="business_assistant")
+    ) or {}
+    return {
+        "runtime_id": "business_assistant",
+        "polling_state": "stopped",
+        "business_mode_supported": False,
+        "webhook_conflict": False,
+        "webhook_url": "",
+        "model_configured": False,
+        "last_error": "",
+        **{key: value for key, value in stored.items() if key != "_id"},
+    }
+
+
+async def update_runtime_status(values: dict[str, Any]) -> dict[str, Any]:
+    await ensure_assistant_indexes()
+    insert_values = {
+        "bot_id": BOT_PROFILE_ID,
+        "runtime_id": "business_assistant",
+        "created_at": utc_now(),
+    }
+    if "offset" not in values:
+        insert_values["offset"] = 0
+    await runtimedb.update_one(
+        _scope(runtime_id="business_assistant"),
+        {
+            "$set": {**values, "updated_at": utc_now()},
+            "$setOnInsert": insert_values,
+        },
+        upsert=True,
+    )
+    return await runtime_status()
+
+
+async def load_update_offset() -> int:
+    return int((await runtime_status()).get("offset") or 0)
+
+
+async def save_update_offset(offset: int) -> None:
+    await update_runtime_status({"offset": int(offset), "last_poll_at": utc_now()})
+
+
+async def due_digest_connections(now: datetime | None = None) -> list[dict[str, Any]]:
+    current = as_utc(now or utc_now())
+    results: list[dict[str, Any]] = []
+    async for settings in settingsdb.find(
+        _scope(digest_enabled=True, assistant_enabled=True)
+    ):
+        try:
+            local = current.astimezone(ZoneInfo(str(settings.get("timezone"))))
+        except ZoneInfoNotFoundError:
+            local = current.astimezone(ZoneInfo("Asia/Shanghai"))
+        day = local.date().isoformat()
+        if local.strftime("%H:%M") != str(settings.get("digest_time") or "09:00"):
+            continue
+        if settings.get("last_digest_day") == day:
+            continue
+        connection = await get_business_connection(settings["connection_id"])
+        if connection and connection.get("is_enabled"):
+            results.append({"connection": connection, "settings": settings, "day": day})
+    return results
+
+
+async def mark_digest_sent(connection_id: str, day: str) -> None:
+    await settingsdb.update_one(
+        _scope(connection_id=str(connection_id)),
+        {"$set": {"last_digest_day": str(day), "last_digest_at": utc_now()}},
+    )