Procházet zdrojové kódy

feat: add Telegram Business assistant

AI-Co-Authored-By: Codex
chendeben před 1 měsícem
rodič
revize
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
 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:
 
 - Address: `http://127.0.0.1:8088/admin`

+ 21 - 0
README.md

@@ -30,6 +30,27 @@
 
 旧 `/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">
     <a href="https://python.org">
         <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: '/directory', component: '@/pages/Directory' },
         { path: '/services', component: '@/pages/Services' },
+        { path: '/business-assistant', component: '@/pages/BusinessAssistant' },
         { path: '/audit', component: '@/pages/AuditLogs' },
         { 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 {
   Bot,
+  BotMessageSquare,
   ChevronDown,
   ClipboardList,
   Gift,
@@ -35,6 +36,7 @@ const menuItems = [
   { path: '/points', name: '积分管理', icon: <Star size={18} /> },
   { path: '/directory', name: '技师与附近', icon: <MapPinned 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: '/settings', name: '系统设置', icon: <Settings size={18} /> },
 ];
@@ -104,6 +106,9 @@ export default function AdminLayout() {
     if (item.path === '/directory' || item.path === '/services') {
       return hasPermission('teacher_directory.manage');
     }
+    if (item.path === '/business-assistant') {
+      return hasPermission('business_assistant.manage');
+    }
     return true;
   });
 
@@ -118,6 +123,8 @@ export default function AdminLayout() {
         !hasPermission('teacher_directory.manage')) ||
       (location.pathname.startsWith('/services') &&
         !hasPermission('teacher_directory.manage')) ||
+      (location.pathname.startsWith('/business-assistant') &&
+        !hasPermission('business_assistant.manage')) ||
       (location.pathname.startsWith('/chats') &&
         !hasAnyPermission(chatPermissions));
     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;
   role_ids: 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;
   created_at?: string;
   updated_at?: string;
   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 {
   api_id?: number | null;
   api_hash_configured: boolean;

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

@@ -38,6 +38,7 @@ const permissions = [
   'giveaways.manage',
   'karma.manage',
   'teacher_directory.manage',
+  'business_assistant.manage',
   'media.upload',
 ];
 
@@ -52,7 +53,15 @@ const bot = {
   message_dump_chat: '-1001234567890',
   role_ids: ['full_access'],
   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,
   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')) {
       data = {
         web_address: 'http://127.0.0.1:8088/admin',
@@ -314,6 +370,7 @@ test('登录与主要管理视图在不同视口无页面级横向滚动', async
     ['/admin/points', '积分管理'],
     ['/admin/directory', '技师与附近'],
     ['/admin/services', '技师咨询与评价'],
+    ['/admin/business-assistant', '智能接待'],
     ['/admin/audit', '操作日志'],
     ['/admin/settings', '系统设置'],
   ] as const) {
@@ -332,6 +389,11 @@ test('登录与主要管理视图在不同视口无页面级横向滚动', async
       await expect(page.getByText('咨询平台模式')).toBeVisible();
       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);
   }
 });

+ 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_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
 RSS_DELAY=300
 PM_PERMIT=1

+ 2 - 0
pyproject.toml

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

+ 16 - 0
sample_config.py

@@ -76,6 +76,22 @@ SERVICE_MINI_APP_AUTH_MAX_AGE_SECONDS = _env_int(
     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"]
 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"]

+ 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,
             "sudo_users_id": "100 200 100",
             "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(
@@ -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]["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)
     assert first_token not in serialized
     assert second_token not in serialized
     assert "a" * 32 not in serialized
+    assert "secret-assistant-key" not in serialized
     assert path.stat().st_mode & 0o777 == 0o600
 
     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,
         "message_dump_chat": 0,
         "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)
@@ -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_DATABASE"] == "wbb_bot_bot_one_secondary"
     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):
@@ -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):
             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)
         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 (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_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",
     "blacklist",
     "blacklist_chat",
+    "business_assistant",
     "chat_watcher",
     "chatbot",
     "filters",

+ 18 - 0
wbb/__main__.py

@@ -203,8 +203,10 @@ async def bot_sys_stats() -> str:
 async def start_bot():
     global HELPABLE
 
+    loaded_modules = {}
     for module in ALL_MODULES:
         imported_module = importlib.import_module("wbb.modules." + module)
+        loaded_modules[module] = imported_module
         if (
             module in HELP_MODULE_KEYS
             and hasattr(imported_module, "__MODULE__")
@@ -239,6 +241,15 @@ async def start_bot():
     except Exception as 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()
 
     try:
@@ -257,6 +268,12 @@ async def start_bot():
 
     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()
     log.info("正在停止客户端")
     await app.stop()
@@ -316,6 +333,7 @@ assigned_features = [
         ("giveaways.manage", "抽奖"),
         ("karma.manage", "声望"),
         ("teacher_directory.manage", "技师咨询与评价"),
+        ("business_assistant.manage", "Telegram Business 智能接待"),
     )
     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,
 )
 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 (
     ChatManagementError,
     apply_automation_settings,
@@ -88,6 +92,25 @@ from wbb.utils.dbadmin import (
     revoke_admin_session,
     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 (
     APPLICATION_APPROVED,
     DirectoryDataError,
@@ -161,6 +184,7 @@ SESSION_COOKIE = "wbb_admin_session"
 UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
 PUBLIC_API_PATHS = {f"{API_PREFIX}/auth/login", f"{API_PREFIX}/health"}
 BOT_SCOPED_PREFIXES = (
+    f"{API_PREFIX}/business-assistant",
     f"{API_PREFIX}/chats",
     f"{API_PREFIX}/giveaways",
     f"{API_PREFIX}/points",
@@ -179,6 +203,7 @@ TELEGRAM_ID_KEYS = {
     "message_id",
     "removed_by",
     "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)
         await _record_request_audit(request, success_state=False, error=str(exc))
         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:
         problem = ApiProblem("points_error", str(exc), status=409)
         await _record_request_audit(request, success_state=False, error=str(exc))
@@ -630,6 +660,7 @@ class AdminApi:
         )
         await ensure_directory_indexes()
         await ensure_builtin_package_templates()
+        await ensure_assistant_indexes()
 
     def register(self, application: web.Application) -> None:
         router = application.router
@@ -679,6 +710,63 @@ class AdminApi:
         router.add_get(f"{API_PREFIX}/audit-logs", self.audit_logs)
         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/{{chat_id}}", self.chat)
         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"'},
         )
 
+    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:
         page, page_size = page_params(request)
         chat_raw = request.query.get("chat_id")

+ 84 - 0
wbb/admin/bot_config.py

@@ -26,6 +26,13 @@ PROFILE_FIELDS = {
     "gban_log_group_id",
     "message_dump_chat",
 }
+DEFAULT_ASSISTANT_PROVIDER = {
+    "base_url": "",
+    "api_key": "",
+    "model": "",
+    "timeout_seconds": 30,
+    "max_output_tokens": 600,
+}
 _STORE_LOCK = threading.RLock()
 
 
@@ -107,6 +114,49 @@ def _normalize_sudoers(value: Any) -> list[int]:
     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]:
     profile = next(
         (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]:
     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
+    assistant = {
+        **DEFAULT_ASSISTANT_PROVIDER,
+        **(
+            profile.get("business_assistant")
+            if isinstance(profile.get("business_assistant"), dict)
+            else {}
+        ),
+    }
     return {
         "bot_id": str(profile.get("bot_id") or ""),
         "label": str(profile.get("label") or ""),
@@ -219,6 +277,18 @@ def _public_profile(
         "role_ids": _profile_role_ids(profile, data),
         "permissions": _effective_permissions(profile, data),
         "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,
         "created_at": profile.get("created_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,
             ),
             "identity": None,
+            "business_assistant": _normalize_assistant_provider(
+                body.get("business_assistant", {})
+            ),
             "created_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"))
         if "role_ids" in body:
             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"}:
             if field in body:
                 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 ""),
             "username": str(identity.get("username") 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")
         _write_document(path, data)
@@ -509,4 +592,5 @@ async def test_bot_token(token: str) -> dict[str, Any]:
         "name": " ".join(
             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]:
         environment = os.environ.copy()
+        assistant = (
+            profile.get("business_assistant")
+            if isinstance(profile.get("business_assistant"), dict)
+            else {}
+        )
         environment.update(
             {
                 "WBB_ADMIN_BOOTSTRAP": "0",
@@ -202,6 +207,15 @@ class BotSupervisor:
                 "ADMIN_WEB_HOST": "127.0.0.1",
                 "ADMIN_WEB_PORT": str(port),
                 "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

+ 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", "媒体中转", "系统能力", "向媒体中转群上传文件。"),
 )
 
@@ -58,7 +64,8 @@ GROUP_MANAGEMENT_PERMISSIONS = frozenset(
     }
 )
 PRIVATE_MANAGEMENT_PERMISSIONS = GROUP_MANAGEMENT_PERMISSIONS | {
-    "teacher_directory.manage"
+    "teacher_directory.manage",
+    "business_assistant.manage",
 }
 
 BUILTIN_ROLES = (
@@ -112,6 +119,12 @@ BUILTIN_ROLES = (
         "负责技师申请、位置、上下榜、在线状态和附近技师目录。",
         ("teacher_directory.manage",),
     ),
+    BuiltinBotRole(
+        "business_assistant_manager",
+        "智能接待专员",
+        "负责 Telegram Business 智能接待、知识库和客户会话。",
+        ("business_assistant.manage",),
+    ),
 )
 
 MODULE_PERMISSIONS: dict[str, frozenset[str]] = {
@@ -121,6 +134,7 @@ MODULE_PERMISSIONS: dict[str, frozenset[str]] = {
     "antiservice": frozenset({"automation.manage"}),
     "blacklist": frozenset({"automation.manage"}),
     "blacklist_chat": frozenset({"automation.manage"}),
+    "business_assistant": frozenset({"business_assistant.manage"}),
     "chatbot": frozenset({"automation.manage"}),
     "filters": 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:
+    if path.startswith("/api/admin/v1/business-assistant"):
+        return "business_assistant.manage"
     if path.startswith("/api/admin/v1/directory"):
         return "teacher_directory.manage"
     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()}},
+    )