dsh-im-ops/plugin-src/client/access-policy-settings.js
oliver 7f1b9bd480 Add configurable group session scope (default user_in_chat).
Ops bots now isolate per-speaker Harness sessions in groups, with a
settings/RPC toggle to restore shared chat sessions when needed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 15:39:28 +08:00

405 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 ? '正在保存…' : '保存访问与会话设置')));
}