dsh-im-ops/plugin-src/client/access-grant-settings.js
oliver 9f97c44031 Add WhatsApp graded phone ACL (4.9.1-ops.4).
Replace allowlist-only gating with phone-scoped grants: global admins, DM members, per-group admins/members, pending approval via quote YES/NO or settings UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 16:45:21 +08:00

491 lines
18 KiB
JavaScript
Raw 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 {
normalizeAccessGrant,
normalizeAccessPhone,
validateAccessGrant,
} from '../../src/channels/shared/access-grant.mjs';
import {
DEFAULT_GROUP_SESSION_SCOPE,
normalizeGroupSessionScope,
validateGroupSessionScope,
} from '../../src/channels/shared/session-scope.mjs';
import { h, localizeText } from './i18n.js';
export const ACCESS_GRANT_ENDPOINT = 'bot.access-grant.set';
export const ACCESS_PENDING_RESOLVE_ENDPOINT = 'bot.access-pending.resolve';
export const GROUP_SESSION_SCOPE_ENDPOINT = 'bot.group-session-scope.set';
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 grantFromSnapshot(value, botId) {
const source = value?.snapshot ?? value;
const bot = Array.isArray(source?.bots)
? source.bots.find((entry) => entry?.botId === botId)
: null;
return normalizeAccessGrant(bot?.accessGrant ?? source?.accessGrant ?? source?.grant);
}
function ownerPhoneFromAccount(account) {
return normalizeAccessPhone(account?.accountJid) ?? normalizeAccessPhone(account?.phone) ?? '';
}
function emptyDraft(ownerPhone) {
return {
version: 1,
globalAdmins: ownerPhone ? [ownerPhone] : [],
directMembers: [],
groups: {},
pending: [],
contacts: [],
};
}
function cloneGrant(grant, ownerPhone) {
const base = grant ?? emptyDraft(ownerPhone);
return {
version: 1,
globalAdmins: [...(base.globalAdmins ?? [])],
directMembers: (base.directMembers ?? []).map((m) => ({ ...m })),
groups: Object.fromEntries(Object.entries(base.groups ?? {}).map(([jid, group]) => [jid, {
title: group.title ?? '',
admins: [...(group.admins ?? [])],
members: (group.members ?? []).map((m) => ({ ...m })),
}])),
pending: [...(base.pending ?? [])],
contacts: [...(base.contacts ?? [])],
};
}
function contactLabel(contact) {
const name = contact.pushName || '未命名';
const phone = contact.phone || '待补电话';
return `${name} · ${phone}`;
}
function PhoneTypeahead({
value,
onChange,
contacts,
placeholder,
disabled,
requirePhone = true,
}) {
const [query, setQuery] = React.useState(value || '');
React.useEffect(() => { setQuery(value || ''); }, [value]);
const q = query.trim().toLowerCase();
const suggestions = (contacts ?? [])
.filter((contact) => {
if (requirePhone && !contact.phone) return false;
if (!q) return Boolean(contact.phone);
return (contact.phone ?? '').includes(q.replace(/[^\d]/g, ''))
|| (contact.pushName ?? '').toLowerCase().includes(q);
})
.slice(0, 8);
return h('div', { className: 'dim-accessTypeahead' },
h('input', {
value: query,
disabled,
placeholder,
maxLength: 32,
autoCapitalize: 'none',
autoCorrect: 'off',
spellCheck: false,
onChange: (event) => {
setQuery(event.target.value);
onChange(event.target.value);
},
}),
suggestions.length === 0 ? null : h('ul', { className: 'dim-accessSuggestList' },
suggestions.map((contact) => h('li', { key: `${contact.phone}-${contact.lids?.[0] ?? ''}` },
h('button', {
type: 'button',
className: 'dim-deliveryButton',
disabled: disabled || (requirePhone && !contact.phone),
onClick: () => {
if (!contact.phone) return;
setQuery(contact.phone);
onChange(contact.phone);
},
}, contactLabel(contact))))));
}
function MemberRows({
title,
members,
contacts,
disabled,
onChange,
}) {
return h('fieldset', { className: 'dim-accessScene', disabled },
h('legend', null, title),
h('ul', { className: 'dim-accessUserList' }, members.map((member, index) =>
h('li', { key: `${member.phone}-${index}`, className: 'dim-accessUserRow' },
h('label', { className: 'dim-accessField dim-accessUserId' },
h('span', null, '电话'),
h(PhoneTypeahead, {
value: member.phone,
contacts,
disabled,
placeholder: '8613800000000',
onChange: (phone) => onChange(members.map((entry, i) => (
i === index ? { ...entry, phone } : entry
))),
})),
h('label', { className: 'dim-accessField dim-accessUserCommand' },
h('span', null, '命令权限'),
h('select', {
value: member.canExecuteCommands ? 'allow' : 'deny',
onChange: (event) => onChange(members.map((entry, i) => (
i === index
? { ...entry, canExecuteCommands: event.target.value === 'allow' }
: entry
))),
},
h('option', { value: 'allow' }, '可以执行命令'),
h('option', { value: 'deny' }, '不可以执行命令'))),
h('button', {
type: 'button',
className: 'dim-deliveryButton',
'data-kind': 'danger',
onClick: () => onChange(members.filter((_, i) => i !== index)),
}, '删除')))),
h('button', {
type: 'button',
className: 'dim-deliveryButton',
onClick: () => onChange([...members, { phone: '', canExecuteCommands: true }]),
}, '新增用户'));
}
function AdminPhones({
title,
help,
phones,
contacts,
disabled,
lockedPhone,
onChange,
}) {
return h('fieldset', { className: 'dim-accessScene', disabled },
h('legend', null, title),
help ? h('p', { className: 'dim-accessUsersEmpty' }, help) : null,
h('ul', { className: 'dim-accessUserList' }, phones.map((phone, index) => {
const locked = lockedPhone && phone === lockedPhone;
return h('li', { key: `${phone}-${index}`, className: 'dim-accessUserRow' },
h('label', { className: 'dim-accessField dim-accessUserId' },
h('span', null, locked ? '绑定账号(全局管理员)' : '电话'),
h(PhoneTypeahead, {
value: phone,
contacts,
disabled: disabled || locked,
placeholder: '8613800000000',
onChange: (next) => onChange(phones.map((entry, i) => (i === index ? next : entry))),
})),
locked ? null : h('button', {
type: 'button',
className: 'dim-deliveryButton',
'data-kind': 'danger',
onClick: () => onChange(phones.filter((_, i) => i !== index)),
}, '删除'));
})),
h('button', {
type: 'button',
className: 'dim-deliveryButton',
onClick: () => onChange([...phones, '']),
}, '新增管理员'));
}
/**
* WhatsApp graded access settings (phone-canonical).
*/
export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) {
if (channel !== 'whatsapp') {
return h('div', { className: 'dim-accessState', role: 'alert' },
'当前渠道仍使用旧版访问设置。');
}
const ownerPhone = ownerPhoneFromAccount(account);
const initialGrant = normalizeAccessGrant(account?.accessGrant);
const initialScope = normalizeGroupSessionScope(account?.groupSessionScope);
const [draft, setDraft] = React.useState(() => cloneGrant(initialGrant, ownerPhone));
const [groupSessionScope, setGroupSessionScope] = React.useState(initialScope);
const [saving, setSaving] = React.useState(false);
const [feedback, setFeedback] = React.useState(null);
const [newGroupJid, setNewGroupJid] = React.useState('');
React.useEffect(() => {
setDraft(cloneGrant(normalizeAccessGrant(account?.accessGrant), ownerPhone));
setGroupSessionScope(normalizeGroupSessionScope(account?.groupSessionScope));
}, [account?.botId, account?.accessGrant, account?.groupSessionScope, ownerPhone]);
const contacts = draft.contacts ?? [];
const knownGroupJids = React.useMemo(() => {
const set = new Set(Object.keys(draft.groups ?? {}));
for (const contact of contacts) {
for (const jid of contact.groupJids ?? []) set.add(jid);
}
for (const pending of draft.pending ?? []) {
if (pending.groupJid) set.add(pending.groupJid);
}
return [...set].sort();
}, [draft.groups, draft.pending, contacts]);
const resolvePending = async (pendingId, action) => {
setFeedback(null);
setSaving(true);
try {
if (typeof rpcCall !== 'function') throw new Error('访问授权暂不可用。');
const resolvedByPhone = ownerPhone || draft.globalAdmins[0];
if (!resolvedByPhone) throw new Error('缺少可用于审批的全局管理员电话。');
const value = unwrapRpcResult(await rpcCall(ACCESS_PENDING_RESOLVE_ENDPOINT, {
botId: account.botId,
pendingId,
action,
resolvedByPhone,
}));
const saved = grantFromSnapshot(value, account.botId);
if (!saved) throw new Error('服务没有返回已保存的访问授权。');
setDraft(cloneGrant(saved, ownerPhone));
onSaved?.(saved);
setFeedback({
tone: 'success',
message: action === 'approve' ? '已批准申请。' : '已拒绝申请。',
});
} catch (error) {
setFeedback({ tone: 'error', message: error?.message || '审批失败。' });
} finally {
setSaving(false);
}
};
const save = async (event) => {
event.preventDefault();
setFeedback(null);
setSaving(true);
try {
const admins = [...new Set(draft.globalAdmins
.map((phone) => normalizeAccessPhone(phone))
.filter(Boolean))];
if (ownerPhone && !admins.includes(ownerPhone)) admins.unshift(ownerPhone);
const grant = validateAccessGrant({
...draft,
globalAdmins: admins,
directMembers: draft.directMembers
.map((m) => ({ ...m, phone: normalizeAccessPhone(m.phone) }))
.filter((m) => m.phone),
groups: Object.fromEntries(Object.entries(draft.groups).map(([jid, group]) => [jid, {
title: group.title,
admins: group.admins.map(normalizeAccessPhone).filter(Boolean),
members: group.members
.map((m) => ({ ...m, phone: normalizeAccessPhone(m.phone) }))
.filter((m) => m.phone),
}])),
});
const scope = validateGroupSessionScope(groupSessionScope);
if (typeof rpcCall !== 'function') throw new Error('访问授权暂不可用。');
const value = unwrapRpcResult(await rpcCall(ACCESS_GRANT_ENDPOINT, {
botId: account.botId,
grant,
}));
unwrapRpcResult(await rpcCall(GROUP_SESSION_SCOPE_ENDPOINT, {
botId: account.botId,
groupSessionScope: scope,
}));
const saved = grantFromSnapshot(value, account.botId) ?? grant;
setDraft(cloneGrant(saved, ownerPhone));
setGroupSessionScope(scope);
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),
},
h('p', { className: 'dim-accessUsersEmpty' },
'WhatsApp 按电话号码授权:全局管理员管私聊;群管理员只批本群。成员不可跨群、有群权也不自动获得私聊权。'),
h(AdminPhones, {
title: '全局管理员',
help: '绑定账号自动为全局管理员,至少保留一位。',
phones: draft.globalAdmins,
contacts,
disabled: saving,
lockedPhone: ownerPhone || null,
onChange: (globalAdmins) => {
setDraft((current) => ({ ...current, globalAdmins }));
setFeedback(null);
},
}),
h(MemberRows, {
title: '私聊授权用户',
members: draft.directMembers,
contacts,
disabled: saving,
onChange: (directMembers) => {
setDraft((current) => ({ ...current, directMembers }));
setFeedback(null);
},
}),
h('fieldset', { className: 'dim-accessScene', disabled: saving },
h('legend', null, '待审批'),
(draft.pending ?? []).filter((entry) => entry.status === 'pending' || !entry.status).length === 0
? h('div', { className: 'dim-accessUsersEmpty' }, '暂无待批申请')
: h('ul', { className: 'dim-accessUserList' },
(draft.pending ?? [])
.filter((entry) => entry.status === 'pending' || !entry.status)
.map((entry) => h('li', { key: entry.id, className: 'dim-accessUserRow' },
h('div', { className: 'dim-accessField' },
h('span', null, entry.kind === 'group'
? ['群聊', ' ', entry.groupJid].join('')
: '私聊'),
h('strong', null, entry.pushName || entry.phone || entry.lid || entry.id),
entry.unresolved
? h('span', null, '(电话未解析,请先在联系人中确认)')
: null,
entry.requestText
? h('p', null, entry.requestText.slice(0, 120))
: null),
h('button', {
type: 'button',
className: 'dim-deliveryButton',
'data-kind': 'primary',
disabled: saving || entry.unresolved,
onClick: () => void resolvePending(entry.id, 'approve'),
}, '批准'),
h('button', {
type: 'button',
className: 'dim-deliveryButton',
'data-kind': 'danger',
disabled: saving,
onClick: () => void resolvePending(entry.id, 'deny'),
}, '拒绝'))))),
h('fieldset', { className: 'dim-accessScene', disabled: saving },
h('legend', null, '群授权'),
knownGroupJids.length === 0
? h('div', { className: 'dim-accessUsersEmpty' },
'尚无已知群。可在下方粘贴群 JID(…@g.us),或等群内有人 @ 机器人后自动出现。')
: knownGroupJids.map((groupJid) => {
const group = draft.groups[groupJid] ?? { title: '', admins: [], members: [] };
return h('div', { key: groupJid, className: 'dim-accessScene', 'data-scene': 'group-grant' },
h('h3', null, group.title || groupJid),
h('label', { className: 'dim-accessField' },
h('span', null, '群备注名'),
h('input', {
value: group.title ?? '',
maxLength: 128,
onChange: (event) => setDraft((current) => ({
...current,
groups: {
...current.groups,
[groupJid]: { ...group, title: event.target.value },
},
})),
})),
h(AdminPhones, {
title: '本群管理员',
help: '未配置时,本群申请回落给全局管理员审批(仍只授予本群权)。',
phones: group.admins ?? [],
contacts,
disabled: saving,
lockedPhone: null,
onChange: (admins) => setDraft((current) => ({
...current,
groups: {
...current.groups,
[groupJid]: { ...group, admins },
},
})),
}),
h(MemberRows, {
title: '本群授权成员',
members: group.members ?? [],
contacts,
disabled: saving,
onChange: (members) => setDraft((current) => ({
...current,
groups: {
...current.groups,
[groupJid]: { ...group, members },
},
})),
}));
}),
h('div', { className: 'dim-accessControls' },
h('label', { className: 'dim-accessField' },
h('span', null, '添加群 JID'),
h('input', {
value: newGroupJid,
placeholder: '120363…@g.us',
onChange: (event) => setNewGroupJid(event.target.value),
})),
h('button', {
type: 'button',
className: 'dim-deliveryButton',
onClick: () => {
const jid = newGroupJid.trim();
if (!/^\d{5,32}@g\.us$/.test(jid)) {
setFeedback({ tone: 'error', message: '群 JID 格式无效。' });
return;
}
setDraft((current) => ({
...current,
groups: {
...current.groups,
[jid]: current.groups[jid] ?? { title: '', admins: [], members: [] },
},
}));
setNewGroupJid('');
setFeedback(null);
},
}, '添加群'))),
h('fieldset', { className: 'dim-accessScene', disabled: saving },
h('legend', null, '最近联系人(自动沉淀)'),
contacts.length === 0
? h('div', { className: 'dim-accessUsersEmpty' }, '暂无联系人。私聊或群内触发后会出现在此,便于补齐电话与昵称。')
: h('ul', { className: 'dim-accessUserList' }, contacts.slice(0, 30).map((contact) =>
h('li', {
key: `${contact.phone ?? ''}-${(contact.lids ?? []).join(',')}`,
className: 'dim-accessUserRow',
},
h('span', null, contactLabel(contact)),
h('span', null, (contact.scenes ?? []).join('/')),
contact.phone ? null : h('span', null, '待补电话'))))),
h('fieldset', { className: 'dim-accessScene', disabled: saving },
h('legend', null, '群会话策略'),
h('label', { className: 'dim-accessField' },
h('span', null, '群内 Session 绑定'),
h('select', {
value: groupSessionScope || DEFAULT_GROUP_SESSION_SCOPE,
onChange: (event) => setGroupSessionScope(event.target.value),
},
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',
}, feedback.message) : null,
h('div', { className: 'dim-accessActions' },
h('button', {
type: 'submit',
className: 'dim-deliveryButton',
'data-kind': 'primary',
disabled: saving,
}, saving ? '正在保存…' : '保存分级访问设置')));
}