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

@ -0,0 +1,351 @@
import * as React from 'react';
import {
DEFAULT_ACCESS_POLICY,
normalizeAccessPolicy,
validateAccessPolicy,
} from '../../src/channels/shared/access-policy.mjs';
import { h, localizeText } from './i18n.js';
export const ACCESS_POLICY_ENDPOINT = 'bot.access-policy.set';
export const ACCESS_CHANNEL_DEFINITIONS = Object.freeze({
weixin: Object.freeze({
directUserLabel: '微信用户 ID',
directPlaceholder: '填写微信用户 ID',
groupSupported: false,
}),
feishu: Object.freeze({
directUserLabel: '飞书 Open ID',
directPlaceholder: 'ou_xxx',
groupUserLabel: '群成员 Open ID',
groupPlaceholder: 'ou_xxx',
}),
dingtalk: Object.freeze({
directUserLabel: '钉钉用户 ID',
directPlaceholder: '填写 senderStaffId 或 senderId',
groupUserLabel: '群成员用户 ID',
groupPlaceholder: '填写 senderStaffId 或 senderId',
}),
wecom: Object.freeze({
directUserLabel: '企业微信用户 ID',
directPlaceholder: '填写 userid',
groupUserLabel: '群成员用户 ID',
groupPlaceholder: '填写 userid',
}),
qq: Object.freeze({
directUserLabel: 'QQ User Open ID',
directPlaceholder: '填写 user_openid',
groupUserLabel: '群成员 Open ID',
groupPlaceholder: '填写 member_openid',
}),
slack: Object.freeze({
directUserLabel: 'Slack User ID',
directPlaceholder: 'U0123456789',
groupUserLabel: '群成员 User ID',
groupPlaceholder: 'U0123456789',
}),
telegram: Object.freeze({
directUserLabel: 'Telegram User ID',
directPlaceholder: '填写数字 User ID',
groupUserLabel: '群成员 User ID',
groupPlaceholder: '填写数字 User ID',
}),
discord: Object.freeze({
directUserLabel: 'Discord User ID',
directPlaceholder: '填写数字 User ID',
groupUserLabel: '群成员 User ID',
groupPlaceholder: '填写数字 User ID',
}),
whatsapp: Object.freeze({
directUserLabel: 'WhatsApp 电话号码或 JID',
directPlaceholder: '8613800000000 或完整 JID',
groupUserLabel: '群成员电话号码或 JID',
groupPlaceholder: '8613800000000 或完整 JID',
}),
});
function clonePolicy(policy) {
const cloneScope = (scope) => ({
mode: scope.mode,
open: {
defaultCanExecuteCommands: scope.open.defaultCanExecuteCommands,
commandPermissionOverrides: scope.open.commandPermissionOverrides.map((user) => ({
...user,
})),
},
allowlist: {
users: scope.allowlist.users.map((user) => ({ ...user })),
},
});
return {
direct: cloneScope(policy.direct),
group: cloneScope(policy.group),
};
}
function unwrapRpcResult(result) {
if (result?.ok === true) return result.value;
if (result?.ok === false) {
const error = new Error(result.error?.message || '访问设置保存失败,请稍后重试。');
error.code = result.error?.code;
throw error;
}
return result;
}
function policyFromSnapshot(value, botId) {
const source = value?.snapshot ?? value;
const bot = Array.isArray(source?.bots)
? source.bots.find((entry) => entry?.botId === botId)
: null;
return normalizeAccessPolicy(bot?.accessPolicy ?? source?.accessPolicy ?? source?.policy);
}
function commandValue(value) {
return value === 'allow';
}
function ScenePolicyEditor({
scene,
title,
policy,
userLabel,
placeholder,
disabled = false,
unsupported = false,
onChange,
}) {
const ownerHelpId = React.useId();
const emptyAllowlistHelpId = React.useId();
const allowlist = policy.mode === 'allowlist';
const collectionKey = allowlist ? 'users' : 'commandPermissionOverrides';
const branchKey = allowlist ? 'allowlist' : 'open';
const users = policy[branchKey][collectionKey];
const emptyAllowlist = allowlist && users.length === 0;
const updateUsers = (nextUsers) => onChange({
...policy,
[branchKey]: {
...policy[branchKey],
[collectionKey]: nextUsers,
},
});
const updateUser = (index, patch) => updateUsers(users.map((user, userIndex) => (
userIndex === index ? { ...user, ...patch } : user
)));
return h('fieldset', {
className: 'dim-accessScene',
disabled,
'data-scene': scene,
'aria-label': localizeText(title),
},
h('legend', null,
h('span', { className: 'dim-accessLegendContent' },
h('span', null, title),
h('span', { className: 'dim-channelHelp dim-accessLegendHelp' },
h('button', {
type: 'button',
className: 'dim-channelHelpButton',
'aria-label': [localizeText(title), localizeText('查看访问权限说明')].join(' '),
'aria-describedby': ownerHelpId,
}, h('span', { 'aria-hidden': true }, '?')),
h('span', {
id: ownerHelpId,
className: 'dim-channelTooltip dim-accessHelpTooltip',
role: 'tooltip',
}, '原所有者或扫码接入者始终可以访问并执行命令;以下设置仅约束其他用户。')))),
unsupported
? h('div', { className: 'dim-accessUnsupported', role: 'note' },
h('strong', null, '当前渠道不支持群聊'),
h('p', null, '此区域无需配置,保存私聊设置时会保留现有群聊策略。'))
: h(React.Fragment, null,
h('div', { className: 'dim-accessControls', 'data-mode': policy.mode },
h('label', { className: 'dim-accessField' },
h('span', null, '访问模式'),
h('select', {
value: policy.mode,
'aria-label': [localizeText(title), localizeText('访问模式')].join(' '),
onChange: (event) => onChange({ ...policy, mode: event.target.value }),
},
h('option', { value: 'open' }, '允许所有用户'),
h('option', { value: 'allowlist' }, '仅白名单用户'))),
allowlist ? null : h('label', { className: 'dim-accessField' },
h('span', null, '默认命令权限'),
h('select', {
value: policy.open.defaultCanExecuteCommands ? 'allow' : 'deny',
'aria-label': [localizeText(title), localizeText('默认命令权限')].join(' '),
onChange: (event) => onChange({
...policy,
open: {
...policy.open,
defaultCanExecuteCommands: commandValue(event.target.value),
},
}),
},
h('option', { value: 'allow' }, '可以执行命令'),
h('option', { value: 'deny' }, '不可以执行命令')))),
h('div', { className: 'dim-accessUsers' },
h('div', { className: 'dim-accessUsersHeading' },
h('div', { className: 'dim-accessUsersTitle' },
h('strong', null, allowlist ? '白名单用户' : '命令权限例外'),
emptyAllowlist
? h('span', { className: 'dim-channelHelp dim-accessUsersHelp' },
h('button', {
type: 'button',
className: 'dim-channelHelpButton',
'aria-label': [localizeText(title), localizeText('查看白名单说明')].join(' '),
'aria-describedby': emptyAllowlistHelpId,
}, h('span', { 'aria-hidden': true }, '?')),
h('span', {
id: emptyAllowlistHelpId,
className: 'dim-channelTooltip dim-accessEmptyAllowlistTooltip',
role: 'tooltip',
}, '当前没有白名单用户,保存后普通用户将无法使用机器人。'))
: null),
h('button', {
type: 'button',
className: 'dim-deliveryButton dim-accessAddUser',
'aria-label': [localizeText(title), localizeText('新增用户')].join(' '),
title: localizeText('新增用户'),
onClick: () => updateUsers([...users, {
id: '',
canExecuteCommands: allowlist
? false
: !policy.open.defaultCanExecuteCommands,
}]),
}, h('span', { 'aria-hidden': true }, '+'))),
users.length === 0
? h('div', { className: 'dim-accessUsersEmpty' }, '尚未添加用户')
: h('ul', { className: 'dim-accessUserList' }, users.map((user, index) =>
h('li', { key: `${scene}-${policy.mode}-${index}`, className: 'dim-accessUserRow' },
h('label', { className: 'dim-accessField dim-accessUserId' },
h('span', null, userLabel),
h('input', {
value: user.id,
maxLength: 256,
required: true,
autoCapitalize: 'none',
autoCorrect: 'off',
spellCheck: false,
placeholder,
'aria-label': [localizeText(title), localizeText(userLabel), index + 1].join(' '),
onChange: (event) => updateUser(index, { id: event.target.value }),
})),
h('label', { className: 'dim-accessField dim-accessUserCommand' },
h('span', null, '命令权限'),
h('select', {
value: user.canExecuteCommands ? 'allow' : 'deny',
'aria-label': [
localizeText(title), localizeText('用户'), index + 1,
localizeText('命令权限'),
].join(' '),
onChange: (event) => updateUser(index, {
canExecuteCommands: commandValue(event.target.value),
}),
},
h('option', { value: 'allow' }, '可以执行命令'),
h('option', { value: 'deny' }, '不可以执行命令'))),
h('button', {
type: 'button',
className: 'dim-deliveryButton dim-accessDeleteUser',
'data-kind': 'danger',
'aria-label': [
localizeText(title), localizeText('删除'),
localizeText('用户'), index + 1,
].join(' '),
onClick: () => updateUsers(users.filter((_, userIndex) => userIndex !== index)),
}, '删除')))))));
}
export function AccessPolicySettingsPage({ channel, account, rpcCall, onSaved }) {
const definition = ACCESS_CHANNEL_DEFINITIONS[channel];
const initialPolicy = normalizeAccessPolicy(account?.accessPolicy);
const initialKey = JSON.stringify(initialPolicy);
const [draft, setDraft] = React.useState(() => clonePolicy(
initialPolicy ?? DEFAULT_ACCESS_POLICY,
));
const [saving, setSaving] = React.useState(false);
const [feedback, setFeedback] = React.useState(null);
React.useEffect(() => {
const next = normalizeAccessPolicy(account?.accessPolicy);
setDraft(clonePolicy(next ?? DEFAULT_ACCESS_POLICY));
}, [account?.botId, initialKey]);
React.useEffect(() => {
setFeedback(null);
}, [account?.botId]);
if (!definition) {
return h('div', { className: 'dim-accessState', role: 'alert' },
'当前渠道暂不支持访问设置。');
}
const save = async (event) => {
event.preventDefault();
setFeedback(null);
setSaving(true);
try {
const policy = validateAccessPolicy(draft);
if (typeof rpcCall !== 'function') throw new Error('访问设置暂不可用。');
const value = unwrapRpcResult(await rpcCall(ACCESS_POLICY_ENDPOINT, {
botId: account.botId,
policy,
}));
const saved = policyFromSnapshot(value, account.botId);
if (!saved) throw new Error('服务没有返回已保存的访问策略,请刷新后重试。');
setDraft(clonePolicy(saved));
onSaved?.(saved);
setFeedback({ tone: 'success', message: '访问设置已保存。' });
} catch (error) {
setFeedback({
tone: 'error',
message: error?.message || '访问设置保存失败,请稍后重试。',
});
} finally {
setSaving(false);
}
};
return h('form', {
className: 'dim-accessPage',
onSubmit: (event) => void save(event),
},
initialPolicy
? null
: h('div', { className: 'dim-accessState', role: 'alert' },
'访问策略尚未就绪,请返回机器人列表刷新后重试。'),
h(ScenePolicyEditor, {
scene: 'direct',
title: '私聊',
policy: draft.direct,
userLabel: definition.directUserLabel,
placeholder: definition.directPlaceholder,
disabled: saving,
onChange: (direct) => { setDraft((current) => ({ ...current, direct })); setFeedback(null); },
}),
h(ScenePolicyEditor, {
scene: 'group',
title: '群聊',
policy: draft.group,
userLabel: definition.groupUserLabel ?? definition.directUserLabel,
placeholder: definition.groupPlaceholder ?? definition.directPlaceholder,
disabled: saving || definition.groupSupported === false,
unsupported: definition.groupSupported === false,
onChange: (group) => { setDraft((current) => ({ ...current, group })); setFeedback(null); },
}),
feedback ? h('p', {
className: 'dim-accessFeedback',
'data-tone': feedback.tone,
role: feedback.tone === 'error' ? 'alert' : 'status',
'aria-live': 'polite',
}, feedback.message) : null,
h('div', { className: 'dim-accessActions' },
h('button', {
type: 'submit',
className: 'dim-deliveryButton',
'data-kind': 'primary',
disabled: saving || !initialPolicy,
}, saving ? '正在保存…' : '保存访问设置')));
}

View file

@ -22,7 +22,7 @@ function SettingsGlyph() {
h('path', { d: 'M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06a1.7 1.7 0 0 0-1.88-.34 1.7 1.7 0 0 0-1.03 1.55V21h-4v-.08A1.7 1.7 0 0 0 8.97 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.52-1.03H3v-4h.08A1.7 1.7 0 0 0 4.6 8.97a1.7 1.7 0 0 0-.34-1.88l-.06-.06L7.03 4.2l.06.06a1.7 1.7 0 0 0 1.88.34A1.7 1.7 0 0 0 10 3.08V3h4v.08a1.7 1.7 0 0 0 1.03 1.52 1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06a1.7 1.7 0 0 0-.34 1.88A1.7 1.7 0 0 0 20.92 10H21v4h-.08A1.7 1.7 0 0 0 19.4 15Z' }));
}
export function BotSettingsButton({ channel, botId, botName, connected }) {
export function BotSettingsButton({ channel, botId, botName, connected, accessPolicy }) {
const { openBotSettings } = React.useContext(BotSettingsContext);
const tooltipId = React.useId();
return h('span', { className: 'dim-botSettingsAction' },
@ -37,6 +37,7 @@ export function BotSettingsButton({ channel, botId, botName, connected }) {
botId,
botName,
connected: Boolean(connected),
accessPolicy,
}),
}, h(SettingsGlyph)),
h('span', {

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() {

View file

@ -1,5 +1,6 @@
import * as React from 'react';
import { AccessPolicySettingsPage } from './access-policy-settings.js';
import { h, isEnglish, localizeText } from './i18n.js';
export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
@ -20,6 +21,7 @@ export const DELIVERY_ENDPOINTS = Object.freeze({
export const BOT_SETTINGS_TABS = Object.freeze([
Object.freeze({ id: 'delivery', label: '投递设置' }),
Object.freeze({ id: 'access', label: '访问设置' }),
]);
const CHANNEL_DEFINITIONS = Object.freeze({
@ -536,8 +538,15 @@ function TargetRow({ definition, target, botId, connected, rpcCall, onChanged, o
: null);
}
export function DeliveryTargetSettingsPage({ channel, account, rpcCall, onBack }) {
export function DeliveryTargetSettingsPage({
channel,
account,
rpcCall,
accessRpcCall,
onBack,
}) {
const definition = CHANNEL_DEFINITIONS[channel];
const [activeTabId, setActiveTabId] = React.useState(BOT_SETTINGS_TABS[0].id);
const [phase, setPhase] = React.useState('loading');
const [targets, setTargets] = React.useState([]);
const [suggestionPhase, setSuggestionPhase] = React.useState('idle');
@ -547,8 +556,13 @@ export function DeliveryTargetSettingsPage({ channel, account, rpcCall, onBack }
const [editor, setEditor] = React.useState(null);
const [saving, setSaving] = React.useState(false);
const [botCopyState, setBotCopyState] = React.useState(null);
const [accessPolicy, setAccessPolicy] = React.useState(account.accessPolicy);
const mounted = React.useRef(true);
React.useEffect(() => {
setAccessPolicy(account.accessPolicy);
}, [account.botId, account.accessPolicy]);
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
if (typeof rpcCall !== 'function') throw new Error('投递目标设置暂不可用。');
return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
@ -658,9 +672,10 @@ export function DeliveryTargetSettingsPage({ channel, account, rpcCall, onBack }
}
};
const deliveryTab = BOT_SETTINGS_TABS[0];
const deliveryTabId = `dim-bot-settings-${deliveryTab.id}-tab`;
const deliveryPanelId = `dim-bot-settings-${deliveryTab.id}-panel`;
const activeTab = BOT_SETTINGS_TABS.find((tab) => tab.id === activeTabId)
?? BOT_SETTINGS_TABS[0];
const activeTabDomId = `dim-bot-settings-${activeTab.id}-tab`;
const activePanelId = `dim-bot-settings-${activeTab.id}-panel`;
return h('section', {
className: 'dim-deliveryPage',
@ -679,16 +694,25 @@ export function DeliveryTargetSettingsPage({ channel, account, rpcCall, onBack }
type: 'button',
role: 'tab',
className: 'dim-botSettingsTab',
'aria-selected': tab.id === deliveryTab.id,
'aria-selected': tab.id === activeTab.id,
'aria-controls': `dim-bot-settings-${tab.id}-panel`,
tabIndex: tab.id === deliveryTab.id ? 0 : -1,
tabIndex: tab.id === activeTab.id ? 0 : -1,
onClick: () => setActiveTabId(tab.id),
}, tab.label)))),
h('div', {
id: deliveryPanelId,
id: activePanelId,
className: 'dim-botSettingsTabPanel',
role: 'tabpanel',
'aria-labelledby': deliveryTabId,
'aria-labelledby': activeTabDomId,
},
activeTab.id === 'access'
? h(AccessPolicySettingsPage, {
channel,
account: { ...account, accessPolicy },
rpcCall: accessRpcCall,
onSaved: setAccessPolicy,
})
: h(React.Fragment, null,
h('section', { className: 'dim-deliveryIdentity', 'aria-labelledby': 'dim-delivery-bot-title' },
h('div', { className: 'dim-deliveryIdentityHeading' },
h('h2', { id: 'dim-delivery-bot-title', className: 'dim-deliveryBotName' },
@ -773,5 +797,5 @@ export function DeliveryTargetSettingsPage({ channel, account, rpcCall, onBack }
rpcCall: invoke,
onChanged: () => loadTargets({ silent: true }),
onEdit: () => setEditor({ mode: 'edit', target, source: 'edit' }),
}))))));
})))))));
}

View file

@ -10,6 +10,59 @@ const EN = Object.freeze({
'机器人设置': 'Bot settings',
'机器人设置页签': 'Bot settings tabs',
'投递设置': 'Delivery settings',
'访问设置': 'Access settings',
'查看访问权限说明': 'View access permission details',
'允许所有用户': 'Allow all users',
'仅白名单用户': 'Allowlisted users only',
'默认命令权限': 'Default command permission',
'命令权限': 'Command permission',
'可以执行命令': 'Can run commands',
'不可以执行命令': 'Cannot run commands',
'白名单用户': 'Allowlisted users',
'查看白名单说明': 'View allowlist details',
'命令权限例外': 'Command permission exceptions',
'当前没有白名单用户,保存后普通用户将无法使用机器人。': 'There are currently no allowlisted users. After saving, regular users will not be able to use the bot.',
'新增用户': 'Add user',
'尚未添加用户': 'No users added',
'当前渠道不支持群聊': 'Group chat is not supported by this channel',
'原所有者或扫码接入者始终可以访问并执行命令;以下设置仅约束其他用户。': 'The original owner or QR-code operator can always access the bot and run commands; the settings below apply only to other users.',
'此区域无需配置,保存私聊设置时会保留现有群聊策略。': 'No setup is needed here. Saving direct-message settings keeps the existing group policy.',
'访问设置已保存。': 'Access settings saved.',
'访问设置暂不可用。': 'Access settings are currently unavailable.',
'访问设置保存失败,请稍后重试。': 'Could not save access settings. Try again later.',
'服务没有返回已保存的访问策略,请刷新后重试。': 'The service did not return the saved access policy. Refresh and try again.',
'访问策略尚未就绪,请返回机器人列表刷新后重试。': 'The access policy is not ready. Return to the bot list, refresh, and try again.',
'当前渠道暂不支持访问设置。': 'Access settings are not supported by this channel yet.',
'用户标识无效。': 'The user ID is invalid.',
'用户标识必须是字符串。': 'The user ID must be a string.',
'用户标识不能为空、包含控制字符或超过 256 个字符。': 'The user ID cannot be empty, contain control characters, or exceed 256 characters.',
'用户条目必须包含用户标识和命令权限。': 'Each user entry must include a user ID and command permission.',
'命令权限必须是布尔值。': 'Command permission must be a boolean.',
'访问模式只能是 open 或 allowlist。': 'Access mode must be open or allowlist.',
'开放模式设置必须完整。': 'Open-mode settings must be complete.',
'开放模式默认命令权限必须是布尔值。': 'The open-mode default command permission must be a boolean.',
'开放模式命令权限覆盖用户必须是数组。': 'Open-mode command permission overrides must be an array.',
'开放模式命令权限覆盖用户不能包含重复的用户标识。': 'Open-mode command permission overrides cannot contain duplicate user IDs.',
'白名单模式设置必须完整。': 'Allowlist-mode settings must be complete.',
'白名单模式用户必须是数组。': 'Allowlist-mode users must be an array.',
'白名单模式用户不能包含重复的用户标识。': 'Allowlist-mode users cannot contain duplicate user IDs.',
'访问场景设置必须同时包含模式、开放模式设置和白名单模式设置。': 'Each access context must include its mode, open-mode settings, and allowlist-mode settings.',
'请同时提交完整的私聊和群聊访问设置。': 'Submit complete direct-message and group access settings together.',
'填写微信用户 ID': 'Enter a WeChat user ID',
'飞书 Open ID': 'Feishu Open ID',
'群成员 Open ID': 'Group member Open ID',
'钉钉用户 ID': 'DingTalk user ID',
'填写 senderStaffId 或 senderId': 'Enter senderStaffId or senderId',
'群成员用户 ID': 'Group member user ID',
'企业微信用户 ID': 'WeCom user ID',
'填写 userid': 'Enter userid',
'填写 member_openid': 'Enter member_openid',
'QQ User Open ID': 'QQ User Open ID',
'群成员 User ID': 'Group member User ID',
'填写数字 User ID': 'Enter a numeric user ID',
'WhatsApp 电话号码或 JID': 'WhatsApp phone number or JID',
'群成员电话号码或 JID': 'Group member phone number or JID',
'8613800000000 或完整 JID': '8613800000000 or a full JID',
'IM 渠道': 'IM channels',
'让 DeepSeek Harness 触手可及': 'Connecting DeepSeek Harness',
'当前版本': 'Current version',
@ -524,7 +577,6 @@ const EN = Object.freeze({
'先通过 @BotFather 获取 Bot Token,再在这里完成接入。': 'Get a Bot Token from @BotFather, then connect it here.',
'填写 @BotFather 生成的 Bot Token': 'Enter the Bot Token from @BotFather',
'访问模式': 'Access mode',
'访问设置': 'Access settings',
'Telegram 访问模式': 'Telegram access mode',
'查看 Telegram 访问模式说明': 'View Telegram access mode details',
'群聊全部忽略,私聊仅允许白名单用户。': 'All group messages are ignored; only allowlisted users may send DMs.',

View file

@ -294,6 +294,7 @@ export function IMSettingsTab({
channel: active.id,
account: deliverySettings,
rpcCall: rpcCalls.deliveryRpcCall,
accessRpcCall: rpcCalls[`${active.id}RpcCall`],
onBack: () => setDeliverySettings(null),
})
: active.id === 'weixin'

View file

@ -435,6 +435,38 @@ const CSS = String.raw`
.dim-targetField input[readonly] { color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
.dim-targetFormError { margin: 10px 0 0; font-size: 12px; line-height: 18px; }
.dim-targetFormActions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 12px; }
.dim-accessPage { min-width: 0; display: grid; gap: 14px; }
.dim-accessScene { position: relative; min-width: 0; margin: 0; padding: 16px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 12px; background: var(--dsw-alias-bg-layer-3, #fff); }
.dim-accessScene > legend { padding: 0 6px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 15px; line-height: 22px; font-weight: 650; }
.dim-accessLegendContent { display: inline-flex; align-items: center; gap: 6px; }
.dim-panel .dim-accessLegendHelp { position: static; }
.dim-accessLegendHelp .dim-channelTooltip { top: 20px; right: auto; left: 16px; width: min(320px, calc(100% - 32px)); max-width: none; }
.dim-accessControls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.dim-accessControls[data-mode="allowlist"] { grid-template-columns: minmax(0, 1fr); }
.dim-accessField { min-width: 0; display: grid; align-content: start; gap: 5px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 16px; }
.dim-accessField input, .dim-accessField select { width: 100%; min-width: 0; height: 36px; padding: 0 9px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; }
.dim-accessField input:focus, .dim-accessField select:focus { outline: 2px solid color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 28%, transparent); border-color: var(--dsw-alias-state-business-primary, #3370ff); }
.dim-accessUsers { min-width: 0; margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); }
.dim-accessUsersHeading { position: relative; min-width: 0; display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.dim-accessUsersHeading > div { min-width: 0; }
.dim-accessUsersTitle { display: inline-flex; align-items: center; gap: 6px; }
.dim-accessUsersHeading strong { color: var(--dsw-alias-label-primary, #1f2329); font-size: 13px; line-height: 20px; font-weight: 620; }
.dim-accessUsersHeading p { margin: 2px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 17px; }
.dim-panel .dim-accessUsersHelp { position: static; }
.dim-accessUsersHelp .dim-channelTooltip { top: calc(100% + 7px); right: auto; left: 0; width: min(320px, 100%); max-width: none; }
.dim-accessAddUser { width: 32px; height: 32px; min-height: 32px; flex: 0 0 32px; padding: 0; font-size: 20px; line-height: 1; }
.dim-accessUsersEmpty { margin-top: 10px; padding: 15px 12px; border: 1px dashed var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; line-height: 18px; text-align: center; }
.dim-accessUserList { display: grid; gap: 9px; margin: 10px 0 0; padding: 0; list-style: none; }
.dim-accessUserRow { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) minmax(145px, 180px) max-content; align-items: end; gap: 10px; padding: 11px; border: 1px solid var(--dsw-alias-border-l1, #eef0f3); border-radius: 9px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
.dim-accessDeleteUser { margin-bottom: 1px; }
.dim-accessUnsupported { padding: 18px 14px; border: 1px dashed var(--dsw-alias-border-l2, #dfe1e5); border-radius: 9px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-module-platform, #f7f8fa); text-align: center; }
.dim-accessUnsupported strong { color: var(--dsw-alias-label-primary, #1f2329); font-size: 13px; line-height: 20px; }
.dim-accessUnsupported p { margin: 4px 0 0; font-size: 12px; line-height: 18px; }
.dim-accessState { padding: 11px 13px; border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #d97706) 24%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 9px; color: var(--dsw-alias-state-warn-primary, #d97706); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #d97706) 7%, var(--dsw-alias-bg-layer-1, #fff)); font-size: 12px; line-height: 18px; }
.dim-accessFeedback { margin: 0; padding: 10px 12px; border-radius: 8px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-module-platform, #f7f8fa); font-size: 12px; line-height: 18px; }
.dim-accessFeedback[data-tone="success"] { color: var(--dsw-alias-state-success-primary, #20a162); }
.dim-accessFeedback[data-tone="error"] { color: var(--dsw-alias-state-error-primary, #d54941); }
.dim-accessActions { display: flex; justify-content: flex-end; }
.dim-panel .dim-botCard .dim-cardFooter { margin-top: 0; }
.dim-panel .ddt-headingCopy { display: none; }
.dim-panel .ddt-qrFrame, .dim-panel .ddt-countdown { width: min(270px, 100%); }
@ -458,6 +490,9 @@ const CSS = String.raw`
.dim-targetActions { justify-content: flex-start; }
.dim-targetFormGrid { grid-template-columns: minmax(0, 1fr); }
.dim-targetSuggestionHeading { align-items: stretch; flex-direction: column; }
.dim-accessControls { grid-template-columns: minmax(0, 1fr); }
.dim-accessUserRow { grid-template-columns: minmax(0, 1fr); }
.dim-accessDeleteUser { justify-self: start; }
}
@media (max-width: 840px) {
.dim-title { align-items: flex-start; }
@ -486,6 +521,7 @@ const CSS = String.raw`
.dim-deliveryBotId { grid-template-columns: minmax(0, 1fr) max-content; }
.dim-deliveryBotId > span { grid-column: 1 / -1; }
.dim-targetActions .dim-deliveryButton { flex: 1 1 auto; }
.dim-accessActions .dim-deliveryButton { width: 100%; }
.dim-directoryPickerBackdrop { padding: 10px; }
.dim-directoryPicker { height: calc(100vh - 20px); min-height: 0; border-radius: 14px; }
.dim-directoryPickerHeader { padding: 18px 17px 14px; }