feat: add unified IM access policies

This commit is contained in:
xmanrui 2026-09-01 10:45:12 +08:00
parent 075d2713c1
commit 840e5aa857
89 changed files with 5573 additions and 1676 deletions

View file

@ -1,5 +1,6 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
import { normalizeLastMessageError } from '../../last-message-error.js';
import { normalizeAccessPolicy } from '../../../../src/channels/shared/access-policy.mjs';
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
export const DINGTALK_RPC_CHANNEL = '/dingtalk';
@ -15,6 +16,7 @@ export const DINGTALK_ENDPOINTS = Object.freeze({
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: 'bot.context-enhancement.set',
setAccessPolicy: 'bot.access-policy.set',
});
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
@ -165,6 +167,9 @@ function normalizeBot(value) {
workspace: optionalString(value.workspace, 4_096) ?? '',
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
...(Object.hasOwn(value, 'accessPolicy')
? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) }
: {}),
bot: {
name: optionalString(bot.name, 100) ?? '钉钉机器人',
clientIdMasked: optionalString(bot.clientIdMasked, 140) ?? '已安全保存',

View file

@ -253,6 +253,7 @@ export function AccountCard({
botId: account.botId,
botName: account.bot.name,
connected: account.connected,
accessPolicy: account.accessPolicy,
}))),
h(WorkspaceEditor, {
workspace: account.workspace,

View file

@ -8,6 +8,7 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId } from "../../agent-preset.js";
import { normalizeLastMessageError } from "../../last-message-error.js";
import { normalizeAccessPolicy } from "../../../../src/channels/shared/access-policy.mjs";
import { normalizeContextEnhancementConfig } from "../../../../src/channels/shared/context-enhancement.mjs";
export const FEISHU_RPC_CHANNEL = "/feishu";
@ -26,6 +27,7 @@ export const FEISHU_ENDPOINTS = Object.freeze({
setWorkspace: "bot.workspace.set",
setAgentPreset: "bot.preset.set",
setContextEnhancement: "bot.context-enhancement.set",
setAccessPolicy: "bot.access-policy.set",
setGroupResponseMode: "bot.group-response-mode.set",
// Kept for rolling upgrades. The multi-bot UI never calls these endpoints.
testConnection: "connection.test",
@ -206,6 +208,9 @@ export function normalizeBotConnection(value, fallbackBotId) {
workspace: optionalString(value.workspace)?.slice(0, 4_096) ?? "",
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
...(Object.hasOwn(value, "accessPolicy")
? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) }
: {}),
groupResponseMode: normalizeGroupResponseMode(value.groupResponseMode),
groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
bot: normalizeBot(value.bot),

View file

@ -609,6 +609,7 @@ export function BotCard({
botId: connection.botId,
botName: bot.name,
connected,
accessPolicy: connection.accessPolicy,
})),
),
h(WorkspaceEditor, {

View file

@ -1,5 +1,6 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
import { normalizeLastMessageError } from '../../last-message-error.js';
import { normalizeAccessPolicy } from '../../../../src/channels/shared/access-policy.mjs';
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
export const QQ_RPC_CHANNEL = '/qq';
@ -15,6 +16,7 @@ export const QQ_ENDPOINTS = Object.freeze({
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: 'bot.context-enhancement.set',
setAccessPolicy: 'bot.access-policy.set',
});
const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']);
@ -90,6 +92,9 @@ function normalizeBot(value) {
workspace: text(value.workspace, '', 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
...(Object.hasOwn(value, 'accessPolicy')
? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) }
: {}),
bot: {
name: text(value.bot?.name, 'QQ机器人', 100),
appIdMasked: text(value.bot?.appIdMasked, '应用标识已安全保存', 140),

View file

@ -197,6 +197,7 @@ export function AccountCard({
botId: account.botId,
botName: account.bot.name,
connected: account.connected,
accessPolicy: account.accessPolicy,
}))),
h(WorkspaceEditor, {
workspace: account.workspace,

View file

@ -1,5 +1,6 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
import { normalizeLastMessageError } from '../../last-message-error.js';
import { normalizeAccessPolicy } from '../../../../src/channels/shared/access-policy.mjs';
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
@ -31,6 +32,7 @@ export const TOKEN_BOT_ENDPOINTS = Object.freeze({
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: 'bot.context-enhancement.set',
setAccessPolicy: 'bot.access-policy.set',
});
export function createTokenChannelApi(channel, connectionSummary, {
@ -60,6 +62,9 @@ export function createTokenChannelApi(channel, connectionSummary, {
workspace: text(value.workspace, '', 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
...(Object.hasOwn(value, 'accessPolicy')
? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) }
: {}),
bot: {
name: text(value.bot?.name, `${channel}机器人`, 100),
username: text(value.bot?.username, '', 100),

View file

@ -103,6 +103,7 @@ export function createTokenChannelSettings(definition) {
botId: account.botId,
botName: account.bot.name,
connected: account.connected,
accessPolicy: account.accessPolicy,
}))),
h(WorkspaceEditor, {
workspace: account.workspace,

View file

@ -1,24 +1,9 @@
import { TOKEN_BOT_ENDPOINTS, createTokenChannelApi } from '../shared/token-api.js';
export const TELEGRAM_RPC_CHANNEL = '/telegram';
export const TELEGRAM_ENDPOINTS = Object.freeze({
...TOKEN_BOT_ENDPOINTS,
setAccessPolicy: 'bot.access-policy.set',
});
export const TELEGRAM_ENDPOINTS = TOKEN_BOT_ENDPOINTS;
const api = createTokenChannelApi('Telegram', ' Bot API 长轮询', {
normalizeBotExtension: (value) => {
const source = value?.accessPolicy;
const accessMode = source?.accessMode === 'private-allowlist'
? 'private-allowlist' : 'compatible';
const allowedUsers = Array.isArray(source?.allowedUsers)
? [...new Set(source.allowedUsers.filter((entry) => (
typeof entry === 'string' && /^[1-9]\d{0,15}$/.test(entry)
)))]
: [];
return { accessPolicy: { accessMode, allowedUsers } };
},
});
const api = createTokenChannelApi('Telegram', ' Bot API 长轮询');
export const unwrapRpcResult = api.unwrapRpcResult;
export const normalizeSnapshot = api.normalizeSnapshot;

View file

@ -1,121 +1,11 @@
import * as React from 'react';
import { TelegramLogoGlyph } from '../../channel-logos.js';
import { createTokenChannelSettings } from '../shared/token-channel.js';
import { h } from '../../i18n.js';
import {
TELEGRAM_ENDPOINTS,
telegramClientApi,
} from './api.js';
import { installTelegramStyles } from './styles.js';
function policyFor(account) {
return {
accessMode: account?.accessPolicy?.accessMode === 'private-allowlist'
? 'private-allowlist' : 'compatible',
allowedUsers: Array.isArray(account?.accessPolicy?.allowedUsers)
? account.accessPolicy.allowedUsers : [],
};
}
function allowedUsersFromText(value) {
const entries = value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean);
if (entries.some((entry) => !/^[1-9]\d{0,15}$/.test(entry))) {
throw new TypeError('User ID 必须是 1–16 位正整数,每行一个。');
}
return [...new Set(entries)];
}
export function TelegramAccessSettings({ account, busy = false, onSave }) {
const policy = policyFor(account);
const sourceUsers = policy.allowedUsers.join('\n');
const accessHelpId = React.useId();
const [accessMode, setAccessMode] = React.useState(policy.accessMode);
const [allowedUsers, setAllowedUsers] = React.useState(sourceUsers);
const [error, setError] = React.useState(null);
React.useEffect(() => {
setAccessMode(policy.accessMode);
setAllowedUsers(sourceUsers);
setError(null);
}, [policy.accessMode, sourceUsers]);
const save = async (event) => {
event.preventDefault();
setError(null);
try {
const normalized = allowedUsersFromText(allowedUsers);
if (typeof onSave !== 'function') throw new Error('Telegram 访问设置暂不可用。');
await onSave({ accessMode, allowedUsers: normalized });
} catch (caught) {
setError(caught?.message ?? 'Telegram 访问设置保存失败。');
}
};
const privateAllowlist = accessMode === 'private-allowlist';
const savedPrivateAllowlist = policy.accessMode === 'private-allowlist';
const emptyAllowlist = privateAllowlist && allowedUsers.trim() === '';
return h('form', { className: 'dtg-access', onSubmit: save },
h('div', { className: 'dtg-accessHeading' },
h('strong', null, '访问设置'),
h('span', { className: 'dtg-accessStatus' },
h('span', { className: 'dtg-accessBadge', 'data-mode': policy.accessMode },
savedPrivateAllowlist ? '已生效:安全模式' : '已生效:兼容模式'),
h('span', { className: 'dtg-accessHelp' },
h('button', {
type: 'button',
className: 'dtg-accessHelpButton',
'aria-label': '查看 Telegram 访问模式说明',
'aria-describedby': accessHelpId,
}, h('span', { 'aria-hidden': 'true' }, '?')),
h('span', {
id: accessHelpId,
className: 'dtg-accessTooltip',
role: 'tooltip',
},
h('span', { className: 'dtg-accessTooltipItem' },
h('strong', null, '兼容模式'),
h('span', null, '保持原有行为:私聊直接响应,群聊在被提及或回复时响应。')),
h('span', { className: 'dtg-accessTooltipItem' },
h('strong', null, '安全模式'),
h('span', null, '群聊全部忽略,私聊仅允许白名单用户。')))))),
h('label', { className: 'dtg-accessField' },
h('span', null, '模式'),
h('select', {
value: accessMode,
disabled: busy,
'aria-label': 'Telegram 访问模式',
onChange: (event) => { setAccessMode(event.target.value); setError(null); },
},
h('option', { value: 'compatible' }, '兼容模式(默认)'),
h('option', { value: 'private-allowlist' }, '安全模式(私聊白名单)'))),
h('label', { className: 'dtg-accessField' },
h('span', null, '允许私聊的 Telegram User ID'),
h('textarea', {
value: allowedUsers,
disabled: busy || !privateAllowlist,
rows: 3,
placeholder: '每行一个数字 User ID',
'aria-label': '允许私聊的 Telegram User ID',
onChange: (event) => { setAllowedUsers(event.target.value); setError(null); },
}),
h('small', null, privateAllowlist
? '白名单仅属于当前机器人。'
: '兼容模式下暂不使用白名单,切换模式时会保留。')),
emptyAllowlist
? h('p', { className: 'dtg-accessWarning', role: 'status' },
'白名单为空;保存后该机器人会拒绝所有入站消息。')
: null,
error ? h('p', { className: 'dtg-accessError', role: 'alert' }, error) : null,
h('div', { className: 'dtg-accessActions' },
h('button', {
type: 'submit',
className: 'ddt-button',
'data-kind': 'secondary',
disabled: busy,
}, busy ? '正在保存…' : '保存访问设置')));
}
const channel = createTokenChannelSettings({
channel: 'Telegram',
endpoints: TELEGRAM_ENDPOINTS,
@ -129,8 +19,6 @@ const channel = createTokenChannelSettings({
emptyTitle: '接入 Telegram 机器人',
emptyDescription: '先通过 @BotFather 获取 Bot Token,再在这里完成接入。',
platformLabel: 'Telegram',
AccountSettings: TelegramAccessSettings,
accountSettingsEndpoint: TELEGRAM_ENDPOINTS.setAccessPolicy,
});
export const TelegramSettingsTab = channel.SettingsTab;

View file

@ -4,34 +4,6 @@ const CSS = String.raw`
.dtg-page { --ddt-accent: #229ed9; --ddt-accent-deep: #1687bd; --ddt-accent-wash: #eaf7fd; }
.dtg-avatar { color: #fff; background: #229ed9; }
.dtg-avatar svg { display: block; }
.dtg-access { min-width: 0; width: 100%; max-width: 100%; display: grid; gap: 10px; padding: 12px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 10px; background: var(--dsw-alias-bg-layer-2, #f7f8fa); }
.dtg-accessHeading { position: relative; min-width: 0; max-width: 100%; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 12px; }
.dtg-accessHeading > strong { min-width: 0; font-size: 13px; overflow-wrap: anywhere; }
.dtg-accessStatus { min-width: 0; max-width: 100%; flex: 0 1 auto; display: inline-flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 6px; }
.dtg-accessBadge { min-width: 0; max-width: 100%; flex: 0 1 auto; padding: 3px 8px; border-radius: 999px; color: #1687bd; background: #eaf7fd; font-size: 11px; font-weight: 700; overflow-wrap: anywhere; text-align: center; }
.dtg-accessBadge[data-mode="private-allowlist"] { color: #a15c00; background: #fff3d6; }
.dtg-accessHelp { position: static; display: inline-flex; flex: none; }
.dtg-accessHelpButton { width: 20px; height: 20px; display: grid; place-items: center; padding: 0; border: 1px solid color-mix(in srgb, #229ed9 28%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 50%; color: #1687bd; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; line-height: 1; font-weight: 750; cursor: help; transition: border-color .15s ease, color .15s ease, background .15s ease, box-shadow .15s ease; }
.dtg-accessHelpButton:hover { border-color: #229ed9; color: #1178a8; background: #eaf7fd; }
.dtg-accessHelpButton:focus-visible { outline: none; border-color: #229ed9; box-shadow: 0 0 0 3px color-mix(in srgb, #229ed9 18%, transparent); }
.dtg-accessTooltip { position: absolute; top: calc(100% + 8px); right: 0; z-index: 30; width: min(300px, 100%); max-width: 100%; display: grid; gap: 8px; padding: 10px 11px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 9px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 10px 28px rgb(31 35 41 / 16%); opacity: 0; visibility: hidden; transform: translateY(-3px); pointer-events: none; transition: opacity .15s ease, transform .15s ease, visibility .15s ease; }
.dtg-accessTooltipItem { display: grid; gap: 2px; }
.dtg-accessTooltipItem + .dtg-accessTooltipItem { padding-top: 8px; border-top: 1px solid var(--dsw-alias-border-l2, #eef0f3); }
.dtg-accessTooltipItem strong { color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; line-height: 17px; font-weight: 700; }
.dtg-accessTooltipItem > span { color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 16px; font-weight: 400; }
.dtg-accessHelp:hover .dtg-accessTooltip, .dtg-accessHelp:focus-within .dtg-accessTooltip { opacity: 1; visibility: visible; transform: translateY(0); }
.dtg-accessField { min-width: 0; max-width: 100%; display: grid; gap: 5px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; font-weight: 600; }
.dtg-accessField select, .dtg-accessField textarea { min-width: 0; width: 100%; max-width: 100%; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l1, #c9cdd4); border-radius: 7px; color: inherit; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-weight: 400; }
.dtg-accessField select { height: 34px; padding: 0 9px; }
.dtg-accessField textarea { min-height: 68px; padding: 8px 9px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.dtg-accessField textarea:disabled { color: var(--dsw-alias-label-tertiary, #8f959e); background: var(--dsw-alias-bg-module-platform, #f2f3f5); cursor: not-allowed; resize: none; opacity: 1; }
.dtg-accessField > span, .dtg-accessField small { overflow-wrap: anywhere; }
.dtg-accessField small { color: var(--dsw-alias-label-secondary, #646a73); font-weight: 400; }
.dtg-accessWarning, .dtg-accessError { margin: 0; font-size: 12px; line-height: 1.5; }
.dtg-accessWarning { color: #a15c00; }
.dtg-accessError { color: var(--dsw-alias-state-error-primary, #d83931); }
.dtg-accessActions { min-width: 0; max-width: 100%; display: flex; justify-content: flex-end; flex-wrap: wrap; }
.dtg-accessActions .ddt-button { max-width: 100%; white-space: normal; }
`;
export function installTelegramStyles() {

View file

@ -1,5 +1,6 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
import { normalizeLastMessageError } from '../../last-message-error.js';
import { normalizeAccessPolicy } from '../../../../src/channels/shared/access-policy.mjs';
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
export const WECOM_RPC_CHANNEL = '/wecom';
@ -15,6 +16,7 @@ export const WECOM_ENDPOINTS = Object.freeze({
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: 'bot.context-enhancement.set',
setAccessPolicy: 'bot.access-policy.set',
});
const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']);
@ -99,6 +101,9 @@ function normalizeBot(value) {
workspace: text(value.workspace, '', 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
...(Object.hasOwn(value, 'accessPolicy')
? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) }
: {}),
bot: {
name: text(value.bot?.name, '企业微信机器人', 100),
appIdMasked: text(value.bot?.appIdMasked, '应用标识已安全保存', 140),

View file

@ -196,6 +196,7 @@ export function AccountCard({
botId: account.botId,
botName: account.bot.name,
connected: account.connected,
accessPolicy: account.accessPolicy,
}))),
h(WorkspaceEditor, {
workspace: account.workspace,

View file

@ -1,5 +1,6 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
import { normalizeLastMessageError } from '../../last-message-error.js';
import { normalizeAccessPolicy } from '../../../../src/channels/shared/access-policy.mjs';
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
export const WEIXIN_RPC_CHANNEL = '/weixin';
@ -14,6 +15,7 @@ export const WEIXIN_ENDPOINTS = Object.freeze({
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: 'bot.context-enhancement.set',
setAccessPolicy: 'bot.access-policy.set',
});
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
@ -124,6 +126,9 @@ function normalizeBot(value) {
workspace: string(value.workspace).slice(0, 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
...(Object.hasOwn(value, 'accessPolicy')
? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) }
: {}),
bot: {
name: string(value.bot.name, '微信机器人'),
accountIdMasked: string(value.bot.accountIdMasked, '已安全保存'),

View file

@ -237,6 +237,7 @@ export function AccountCard({
botId: account.botId,
botName: account.bot.name,
connected: account.connected,
accessPolicy: account.accessPolicy,
}))),
h(WorkspaceEditor, {
workspace: account.workspace,

View file

@ -1,5 +1,6 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
import { normalizeLastMessageError } from '../../last-message-error.js';
import { normalizeAccessPolicy } from '../../../../src/channels/shared/access-policy.mjs';
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
export const WHATSAPP_RPC_CHANNEL = '/whatsapp';
@ -91,16 +92,9 @@ function normalizeBot(value) {
workspace: text(value.workspace, '', 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
accessPolicy: {
accessMode: ['self-only', 'private-allowlist', 'open'].includes(
value.accessPolicy?.accessMode,
) ? value.accessPolicy.accessMode : 'self-only',
allowedNumbers: Array.isArray(value.accessPolicy?.allowedNumbers)
? [...new Set(value.accessPolicy.allowedNumbers.filter((entry) => (
typeof entry === 'string' && /^[1-9]\d{4,14}$/.test(entry)
)))]
: [],
},
...(Object.hasOwn(value, 'accessPolicy')
? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) }
: {}),
bot: {
name: text(value.bot?.name, 'WhatsApp机器人', 100),
idMasked: text(value.bot?.idMasked, 'WhatsApp账号', 140),

View file

@ -31,119 +31,6 @@ import { installWhatsappStyles } from './styles.js';
const ACTIVE_STATES = new Set(['pending', 'connecting']);
function accessPolicyFor(account) {
const accessMode = ['self-only', 'private-allowlist', 'open'].includes(
account?.accessPolicy?.accessMode,
) ? account.accessPolicy.accessMode : 'self-only';
return {
accessMode,
allowedNumbers: Array.isArray(account?.accessPolicy?.allowedNumbers)
? account.accessPolicy.allowedNumbers : [],
};
}
function allowedNumbersFromText(value) {
const entries = value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean);
const normalized = entries.map((entry) => entry.replace(/^\+/, ''));
if (normalized.some((entry) => !/^[1-9]\d{4,14}$/.test(entry))) {
throw new TypeError('电话号码必须包含国家或地区代码,每行一个。');
}
return [...new Set(normalized)];
}
export function WhatsappAccessSettings({ account, busy = false, onSave }) {
const policy = accessPolicyFor(account);
const sourceNumbers = policy.allowedNumbers.join('\n');
const helpId = React.useId();
const [accessMode, setAccessMode] = React.useState(policy.accessMode);
const [allowedNumbers, setAllowedNumbers] = React.useState(sourceNumbers);
const [error, setError] = React.useState(null);
React.useEffect(() => {
setAccessMode(policy.accessMode);
setAllowedNumbers(sourceNumbers);
setError(null);
}, [policy.accessMode, sourceNumbers]);
const save = async (event) => {
event.preventDefault();
setError(null);
try {
const normalized = allowedNumbersFromText(allowedNumbers);
if (typeof onSave !== 'function') throw new Error('WhatsApp 访问设置暂不可用。');
await onSave({ accessMode, allowedNumbers: normalized });
} catch (caught) {
setError(caught?.message ?? 'WhatsApp 访问设置保存失败。');
}
};
const allowlistEnabled = accessMode === 'private-allowlist';
const labels = {
'self-only': '仅自己模式',
'private-allowlist': '指定联系人模式',
open: '开放响应模式',
};
return h('form', { className: 'dwa-access', onSubmit: save },
h('div', { className: 'dwa-accessHeading' },
h('strong', null, '访问设置'),
h('span', { className: 'dwa-accessStatus' },
h('span', { className: 'dwa-accessBadge', 'data-mode': policy.accessMode },
['已生效:', labels[policy.accessMode]]),
h('span', { className: 'dwa-accessHelp' },
h('button', {
type: 'button',
className: 'dwa-accessHelpButton',
'aria-label': '查看 WhatsApp 访问模式说明',
'aria-describedby': helpId,
}, h('span', { 'aria-hidden': 'true' }, '?')),
h('span', { id: helpId, className: 'dwa-accessTooltip', role: 'tooltip' },
h('span', { className: 'dwa-accessTooltipItem' },
h('strong', null, '仅自己模式'),
h('span', null, '只响应已绑定 WhatsApp 账号的自聊消息。')),
h('span', { className: 'dwa-accessTooltipItem' },
h('strong', null, '指定联系人模式'),
h('span', null, '响应自聊和白名单联系人的私聊,忽略群聊。')),
h('span', { className: 'dwa-accessTooltipItem' },
h('strong', null, '开放响应模式'),
h('span', null, '响应所有私聊、已绑定账号自己发出的群聊消息,以及其他群成员的提及或回复。')))))),
h('label', { className: 'dwa-accessField' },
h('span', null, '模式'),
h('select', {
value: accessMode,
disabled: busy,
'aria-label': 'WhatsApp 访问模式',
onChange: (event) => { setAccessMode(event.target.value); setError(null); },
},
h('option', { value: 'self-only' }, '仅自己模式(默认)'),
h('option', { value: 'private-allowlist' }, '指定联系人模式'),
h('option', { value: 'open' }, '开放响应模式'))),
allowlistEnabled
? h('label', { className: 'dwa-accessField' },
h('span', null, '允许私聊的 WhatsApp 电话号码'),
h('textarea', {
value: allowedNumbers,
disabled: busy,
rows: 3,
placeholder: '每行一个含国家或地区代码的号码',
'aria-label': '允许私聊的 WhatsApp 电话号码',
onChange: (event) => { setAllowedNumbers(event.target.value); setError(null); },
}),
h('small', null, '可以包含开头的 +,保存时会自动移除。'))
: null,
allowlistEnabled && allowedNumbers.trim() === ''
? h('p', { className: 'dwa-accessWarning', role: 'status' },
'白名单为空;保存后将只接受自聊消息。')
: null,
error ? h('p', { className: 'dwa-accessError', role: 'alert' }, error) : null,
h('div', { className: 'dwa-accessActions' },
h('button', {
type: 'submit',
className: 'ddt-button',
'data-kind': 'secondary',
disabled: busy,
}, busy ? '正在保存…' : '保存访问设置')));
}
const Button = React.forwardRef(function Button(
{ children, kind = 'secondary', className = '', ...props },
ref,
@ -300,7 +187,6 @@ export function WhatsappAccountCard({
onWorkspaceSave,
onAgentPresetSave,
onContextEnhancementSave,
onAccessPolicySave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
@ -333,6 +219,7 @@ export function WhatsappAccountCard({
botId: account.botId,
botName: account.bot.name,
connected: account.connected,
accessPolicy: account.accessPolicy,
}))),
h(WorkspaceEditor, {
workspace: account.workspace,
@ -349,11 +236,6 @@ export function WhatsappAccountCard({
disabled: Boolean(busy),
onSave: onContextEnhancementSave,
}),
h(WhatsappAccessSettings, {
account,
busy: Boolean(busy),
onSave: onAccessPolicySave,
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
h('div', { className: 'dim-cardFooterLayout' },
h('div', { className: 'ddt-actions dim-cardActions' },
@ -617,12 +499,6 @@ export function WhatsappSettingsTab({ rpcCall }) {
WHATSAPP_ENDPOINTS.setContextEnhancement,
{ botId: account.botId, config },
),
onAccessPolicySave: (accessPolicy) => botAction(
account,
'access',
WHATSAPP_ENDPOINTS.setAccessPolicy,
{ botId: account.botId, ...accessPolicy },
),
onRequestRemove: () => setRemoveTarget(account.botId),
onCancelRemove: () => setRemoveTarget(null),
onConfirmRemove: async () => {

View file

@ -4,31 +4,6 @@ const CSS = String.raw`
.dwa-page { --ddt-accent: #25d366; --ddt-accent-deep: #128c7e; --ddt-accent-wash: #eafbf0; }
.dwa-avatar { color: #fff; background: #25d366; }
.dwa-avatar svg { display: block; }
.dwa-access { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 10px; background: var(--dsw-alias-bg-layer-2, #f7f8fa); }
.dwa-accessHeading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.dwa-accessHeading > strong { font-size: 13px; }
.dwa-accessStatus { min-width: 0; display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px; }
.dwa-accessBadge { flex: none; padding: 3px 8px; border-radius: 999px; color: #08785f; background: #eafbf0; font-size: 11px; font-weight: 700; }
.dwa-accessBadge[data-mode="private-allowlist"] { color: #0f6f8f; background: #eaf7fd; }
.dwa-accessBadge[data-mode="open"] { color: #a15c00; background: #fff3d6; }
.dwa-accessHelp { position: relative; display: inline-flex; flex: none; }
.dwa-accessHelpButton { width: 20px; height: 20px; display: grid; place-items: center; padding: 0; border: 1px solid color-mix(in srgb, #25d366 34%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 50%; color: #128c7e; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; line-height: 1; font-weight: 750; cursor: help; }
.dwa-accessTooltip { position: absolute; top: calc(100% + 8px); right: 0; z-index: 30; width: 270px; max-width: min(290px, calc(100vw - 48px)); display: grid; gap: 8px; padding: 10px 11px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 9px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 10px 28px rgb(31 35 41 / 16%); opacity: 0; visibility: hidden; transform: translateY(-3px); pointer-events: none; transition: opacity .15s ease, transform .15s ease, visibility .15s ease; }
.dwa-accessTooltipItem { display: grid; gap: 2px; }
.dwa-accessTooltipItem + .dwa-accessTooltipItem { padding-top: 8px; border-top: 1px solid var(--dsw-alias-border-l2, #eef0f3); }
.dwa-accessTooltipItem strong { font-size: 12px; line-height: 17px; }
.dwa-accessTooltipItem > span { color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 16px; }
.dwa-accessHelp:hover .dwa-accessTooltip, .dwa-accessHelp:focus-within .dwa-accessTooltip { opacity: 1; visibility: visible; transform: translateY(0); }
.dwa-accessField { display: grid; gap: 5px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; font-weight: 600; }
.dwa-accessField select, .dwa-accessField textarea { width: 100%; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l1, #c9cdd4); border-radius: 7px; color: inherit; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-weight: 400; }
.dwa-accessField select { height: 34px; padding: 0 9px; }
.dwa-accessField textarea { min-height: 68px; padding: 8px 9px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.dwa-accessField textarea:disabled { color: var(--dsw-alias-label-tertiary, #8f959e); background: var(--dsw-alias-bg-module-platform, #f2f3f5); cursor: not-allowed; resize: none; opacity: 1; }
.dwa-accessField small { color: var(--dsw-alias-label-secondary, #646a73); font-weight: 400; }
.dwa-accessWarning, .dwa-accessError { margin: 0; font-size: 12px; line-height: 1.5; }
.dwa-accessWarning { color: #a15c00; }
.dwa-accessError { color: var(--dsw-alias-state-error-primary, #d83931); }
.dwa-accessActions { display: flex; justify-content: flex-end; }
`;
export function installWhatsappStyles() {