mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
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>
405 lines
16 KiB
JavaScript
405 lines
16 KiB
JavaScript
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 ? '正在保存…' : '保存访问与会话设置')));
|
||
}
|