import * as React from 'react'; import { DEFAULT_ACCESS_POLICY, normalizeAccessPolicy, validateAccessPolicy, } from '../../src/channels/shared/access-policy.mjs'; import { DEFAULT_GROUP_SESSION_SCOPE, normalizeGroupSessionScope, validateGroupSessionScope, } from '../../src/channels/shared/session-scope.mjs'; import { h, localizeText } from './i18n.js'; export const ACCESS_POLICY_ENDPOINT = 'bot.access-policy.set'; export const GROUP_SESSION_SCOPE_ENDPOINT = 'bot.group-session-scope.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 initialScope = normalizeGroupSessionScope(account?.groupSessionScope); const [draft, setDraft] = React.useState(() => clonePolicy( initialPolicy ?? DEFAULT_ACCESS_POLICY, )); const [groupSessionScope, setGroupSessionScope] = React.useState(initialScope); 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)); setGroupSessionScope(normalizeGroupSessionScope(account?.groupSessionScope)); }, [account?.botId, initialKey, account?.groupSessionScope]); 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); const scope = validateGroupSessionScope(groupSessionScope); if (typeof rpcCall !== 'function') throw new Error('访问设置暂不可用。'); const value = unwrapRpcResult(await rpcCall(ACCESS_POLICY_ENDPOINT, { botId: account.botId, policy, })); const scopeValue = unwrapRpcResult(await rpcCall(GROUP_SESSION_SCOPE_ENDPOINT, { botId: account.botId, groupSessionScope: scope, })); const saved = policyFromSnapshot(value, account.botId); if (!saved) throw new Error('服务没有返回已保存的访问策略,请刷新后重试。'); const savedScope = normalizeGroupSessionScope( scopeValue?.snapshot?.bots?.find?.((bot) => bot?.botId === account.botId)?.groupSessionScope ?? scopeValue?.bots?.find?.((bot) => bot?.botId === account.botId)?.groupSessionScope ?? scope, ); setDraft(clonePolicy(saved)); setGroupSessionScope(savedScope); onSaved?.({ ...saved, groupSessionScope: savedScope }); 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); }, }), definition.groupSupported === false ? null : h('fieldset', { className: 'dim-accessScene', disabled: saving, 'data-scene': 'group-session', 'aria-label': localizeText('群会话策略'), }, h('legend', null, h('span', { className: 'dim-accessLegendContent' }, h('span', null, '群会话策略'), h('span', { className: 'dim-channelHelp dim-accessLegendHelp' }, h('button', { type: 'button', className: 'dim-channelHelpButton', 'aria-label': localizeText('查看群会话策略说明'), }, h('span', { 'aria-hidden': true }, '?')), h('span', { className: 'dim-channelTooltip dim-accessHelpTooltip', role: 'tooltip', }, '运维默认按发言人拆分 Session,避免同群多人互相打断;可选改回整群共享 Session。')))), h('div', { className: 'dim-accessControls' }, h('label', { className: 'dim-accessField' }, h('span', null, '群内 Session 绑定'), h('select', { value: groupSessionScope || DEFAULT_GROUP_SESSION_SCOPE, 'aria-label': localizeText('群内 Session 绑定'), onChange: (event) => { setGroupSessionScope(event.target.value); setFeedback(null); }, }, h('option', { value: 'user_in_chat' }, '按发言人拆分(推荐)'), h('option', { value: 'chat' }, '整群共享一个 Session'))))), 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 ? '正在保存…' : '保存访问与会话设置'))); }