mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-08 23:13:17 +08:00
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>
This commit is contained in:
parent
e5e1e6573a
commit
9f97c44031
15 changed files with 3146 additions and 327 deletions
922
lib/client.js
922
lib/client.js
File diff suppressed because it is too large
Load diff
476
lib/index.js
476
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "dsh-im-ops",
|
"name": "dsh-im-ops",
|
||||||
"version": "4.9.1-ops.3",
|
"version": "4.9.1-ops.4",
|
||||||
"description": "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 — access/session policies owned here.",
|
"description": "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 — access/session policies owned here.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"deepseek-harness",
|
"deepseek-harness",
|
||||||
|
|
|
||||||
491
plugin-src/client/access-grant-settings.js
Normal file
491
plugin-src/client/access-grant-settings.js
Normal file
|
|
@ -0,0 +1,491 @@
|
||||||
|
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 ? '正在保存…' : '保存分级访问设置')));
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { AccessPolicySettingsPage } from './access-policy-settings.js';
|
import { AccessPolicySettingsPage } from './access-policy-settings.js';
|
||||||
|
import { AccessGrantSettingsPage } from './access-grant-settings.js';
|
||||||
import { h, isEnglish, localizeText } from './i18n.js';
|
import { h, isEnglish, localizeText } from './i18n.js';
|
||||||
|
|
||||||
export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
|
export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
|
||||||
|
|
@ -557,11 +558,13 @@ export function DeliveryTargetSettingsPage({
|
||||||
const [saving, setSaving] = React.useState(false);
|
const [saving, setSaving] = React.useState(false);
|
||||||
const [botCopyState, setBotCopyState] = React.useState(null);
|
const [botCopyState, setBotCopyState] = React.useState(null);
|
||||||
const [accessPolicy, setAccessPolicy] = React.useState(account.accessPolicy);
|
const [accessPolicy, setAccessPolicy] = React.useState(account.accessPolicy);
|
||||||
|
const [accessGrant, setAccessGrant] = React.useState(account.accessGrant);
|
||||||
const mounted = React.useRef(true);
|
const mounted = React.useRef(true);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setAccessPolicy(account.accessPolicy);
|
setAccessPolicy(account.accessPolicy);
|
||||||
}, [account.botId, account.accessPolicy]);
|
setAccessGrant(account.accessGrant);
|
||||||
|
}, [account.botId, account.accessPolicy, account.accessGrant]);
|
||||||
|
|
||||||
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
|
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
|
||||||
if (typeof rpcCall !== 'function') throw new Error('投递目标设置暂不可用。');
|
if (typeof rpcCall !== 'function') throw new Error('投递目标设置暂不可用。');
|
||||||
|
|
@ -706,12 +709,23 @@ export function DeliveryTargetSettingsPage({
|
||||||
'aria-labelledby': activeTabDomId,
|
'aria-labelledby': activeTabDomId,
|
||||||
},
|
},
|
||||||
activeTab.id === 'access'
|
activeTab.id === 'access'
|
||||||
? h(AccessPolicySettingsPage, {
|
? (channel === 'whatsapp'
|
||||||
channel,
|
? h(AccessGrantSettingsPage, {
|
||||||
account: { ...account, accessPolicy },
|
channel,
|
||||||
rpcCall: accessRpcCall,
|
account: {
|
||||||
onSaved: setAccessPolicy,
|
...account,
|
||||||
})
|
accessGrant,
|
||||||
|
groupSessionScope: account.groupSessionScope,
|
||||||
|
},
|
||||||
|
rpcCall: accessRpcCall,
|
||||||
|
onSaved: setAccessGrant,
|
||||||
|
})
|
||||||
|
: h(AccessPolicySettingsPage, {
|
||||||
|
channel,
|
||||||
|
account: { ...account, accessPolicy },
|
||||||
|
rpcCall: accessRpcCall,
|
||||||
|
onSaved: setAccessPolicy,
|
||||||
|
}))
|
||||||
: h(React.Fragment, null,
|
: h(React.Fragment, null,
|
||||||
h('section', { className: 'dim-deliveryIdentity', 'aria-labelledby': 'dim-delivery-bot-title' },
|
h('section', { className: 'dim-deliveryIdentity', 'aria-labelledby': 'dim-delivery-bot-title' },
|
||||||
h('div', { className: 'dim-deliveryIdentityHeading' },
|
h('div', { className: 'dim-deliveryIdentityHeading' },
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,49 @@ const EN = Object.freeze({
|
||||||
'机器人设置页签': 'Bot settings tabs',
|
'机器人设置页签': 'Bot settings tabs',
|
||||||
'投递设置': 'Delivery settings',
|
'投递设置': 'Delivery settings',
|
||||||
'访问设置': 'Access settings',
|
'访问设置': 'Access settings',
|
||||||
|
"WhatsApp 按电话号码授权:全局管理员管私聊;群管理员只批本群。成员不可跨群、有群权也不自动获得私聊权。": "WhatsApp authorizes by phone number: global admins manage DMs; group admins approve only their group. Members do not cross groups, and group access does not grant DMs.",
|
||||||
|
"保存分级访问设置": "Save graded access settings",
|
||||||
|
"保存访问与会话设置": "Save access and session settings",
|
||||||
|
"全局管理员": "Global admins",
|
||||||
|
"分级访问设置已保存。": "Graded access settings saved.",
|
||||||
|
"审批失败。": "Approval failed.",
|
||||||
|
"尚无已知群。可在下方粘贴群 JID(…@g.us),或等群内有人 @ 机器人后自动出现。": "No known groups yet. Paste a group JID (…@g.us) below, or wait until someone @mentions the bot in a group.",
|
||||||
|
"已批准申请。": "Request approved.",
|
||||||
|
"已拒绝申请。": "Request denied.",
|
||||||
|
"当前渠道仍使用旧版访问设置。": "This channel still uses the legacy access settings.",
|
||||||
|
"待审批": "Pending approval",
|
||||||
|
"待补电话": "Phone pending",
|
||||||
|
"批准": "Approve",
|
||||||
|
"拒绝": "Deny",
|
||||||
|
"按发言人拆分(推荐)": "Split by speaker (recommended)",
|
||||||
|
"整群共享一个 Session": "Share one session for the whole group",
|
||||||
|
"新增管理员": "Add admin",
|
||||||
|
"暂无待批申请": "No pending requests",
|
||||||
|
"暂无联系人。私聊或群内触发后会出现在此,便于补齐电话与昵称。": "No contacts yet. People who DM or trigger the bot in a group appear here so you can fill in phones and names.",
|
||||||
|
"最近联系人(自动沉淀)": "Recent contacts (auto-collected)",
|
||||||
|
"服务没有返回已保存的访问授权。": "The service did not return the saved access grant.",
|
||||||
|
"未命名": "Unnamed",
|
||||||
|
"未配置时,本群申请回落给全局管理员审批(仍只授予本群权)。": "When unset, this group's requests fall back to global admins (still grants only this group).",
|
||||||
|
"本群授权成员": "Group members",
|
||||||
|
"本群管理员": "Group admins",
|
||||||
|
"查看群会话策略说明": "View group session policy details",
|
||||||
|
"添加群": "Add group",
|
||||||
|
"添加群 JID": "Add group JID",
|
||||||
|
"电话": "Phone",
|
||||||
|
"私聊授权用户": "DM members",
|
||||||
|
"绑定账号自动为全局管理员,至少保留一位。": "The linked account is a global admin automatically. Keep at least one.",
|
||||||
|
"绑定账号(全局管理员)": "Linked account (global admin)",
|
||||||
|
"缺少可用于审批的全局管理员电话。": "No global admin phone is available for approval.",
|
||||||
|
"群 JID 格式无效。": "Invalid group JID.",
|
||||||
|
"群会话策略": "Group session policy",
|
||||||
|
"群内 Session 绑定": "In-group session binding",
|
||||||
|
"群备注名": "Group display name",
|
||||||
|
"群授权": "Group grants",
|
||||||
|
"访问与群会话设置已保存。": "Access and group session settings saved.",
|
||||||
|
"访问授权保存失败,请稍后重试。": "Could not save the access grant. Try again later.",
|
||||||
|
"访问授权暂不可用。": "Access grant settings are currently unavailable.",
|
||||||
|
"运维默认按发言人拆分 Session,避免同群多人互相打断;可选改回整群共享 Session。": "Ops defaults to one session per speaker so group members do not interrupt each other. You can switch back to one shared group session.",
|
||||||
|
"(电话未解析,请先在联系人中确认)": "(Phone unresolved — confirm it in contacts first)",
|
||||||
'查看访问权限说明': 'View access permission details',
|
'查看访问权限说明': 'View access permission details',
|
||||||
'允许所有用户': 'Allow all users',
|
'允许所有用户': 'Allow all users',
|
||||||
'仅白名单用户': 'Allowlisted users only',
|
'仅白名单用户': 'Allowlisted users only',
|
||||||
|
|
|
||||||
|
|
@ -468,6 +468,12 @@ const CSS = String.raw`
|
||||||
.dim-accessFeedback[data-tone="success"] { color: var(--dsw-alias-state-success-primary, #20a162); }
|
.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-accessFeedback[data-tone="error"] { color: var(--dsw-alias-state-error-primary, #d54941); }
|
||||||
.dim-accessActions { display: flex; justify-content: flex-end; }
|
.dim-accessActions { display: flex; justify-content: flex-end; }
|
||||||
|
.dim-accessTypeahead { position: relative; min-width: 0; display: grid; gap: 6px; }
|
||||||
|
.dim-accessTypeahead > input { 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-accessTypeahead > input: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-accessSuggestList { position: absolute; z-index: 4; top: calc(100% + 4px); left: 0; right: 0; margin: 0; padding: 6px; list-style: none; display: grid; gap: 4px; max-height: 220px; overflow: auto; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 9px; background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 8px 24px color-mix(in srgb, #1f2329 12%, transparent); }
|
||||||
|
.dim-accessSuggestList li { min-width: 0; }
|
||||||
|
.dim-accessSuggestList .dim-deliveryButton { width: 100%; justify-content: flex-start; text-align: left; white-space: normal; }
|
||||||
.dim-panel .dim-botCard .dim-cardFooter { margin-top: 0; }
|
.dim-panel .dim-botCard .dim-cardFooter { margin-top: 0; }
|
||||||
.dim-panel .ddt-headingCopy { display: none; }
|
.dim-panel .ddt-headingCopy { display: none; }
|
||||||
.dim-panel .ddt-qrFrame, .dim-panel .ddt-countdown { width: min(270px, 100%); }
|
.dim-panel .ddt-qrFrame, .dim-panel .ddt-countdown { width: min(270px, 100%); }
|
||||||
|
|
|
||||||
78
plugin-src/host/channels/shared/access-grant-rpc.mjs
Normal file
78
plugin-src/host/channels/shared/access-grant-rpc.mjs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { validateAccessGrant, normalizeAccessGrant } from '../../../../src/channels/shared/access-grant.mjs';
|
||||||
|
|
||||||
|
export const SET_ACCESS_GRANT_ENDPOINT = 'bot.access-grant.set';
|
||||||
|
export const RESOLVE_ACCESS_PENDING_ENDPOINT = 'bot.access-pending.resolve';
|
||||||
|
|
||||||
|
export function validAccessGrantPayload(payload) {
|
||||||
|
try {
|
||||||
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)
|
||||||
|
|| !Object.hasOwn(payload, 'botId') || !Object.hasOwn(payload, 'grant')
|
||||||
|
|| typeof payload.botId !== 'string'
|
||||||
|
|| !/^[A-Za-z0-9_-]{1,128}$/.test(payload.botId)) return false;
|
||||||
|
validateAccessGrant(payload.grant);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validAccessPendingResolvePayload(payload) {
|
||||||
|
try {
|
||||||
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false;
|
||||||
|
const keys = Reflect.ownKeys(payload);
|
||||||
|
if (keys.length !== 4
|
||||||
|
|| !Object.hasOwn(payload, 'botId')
|
||||||
|
|| !Object.hasOwn(payload, 'pendingId')
|
||||||
|
|| !Object.hasOwn(payload, 'action')
|
||||||
|
|| !Object.hasOwn(payload, 'resolvedByPhone')) return false;
|
||||||
|
if (typeof payload.botId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(payload.botId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof payload.pendingId !== 'string' || !/^[A-Za-z0-9_-]{6,64}$/.test(payload.pendingId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (payload.action !== 'approve' && payload.action !== 'deny') return false;
|
||||||
|
if (typeof payload.resolvedByPhone !== 'string' || !payload.resolvedByPhone.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publicAccessGrant(grant) {
|
||||||
|
const normalized = normalizeAccessGrant(grant);
|
||||||
|
if (!normalized) return null;
|
||||||
|
return {
|
||||||
|
version: normalized.version,
|
||||||
|
globalAdmins: [...normalized.globalAdmins],
|
||||||
|
directMembers: normalized.directMembers.map((m) => ({ ...m })),
|
||||||
|
groups: Object.fromEntries(Object.entries(normalized.groups).map(([jid, group]) => [jid, {
|
||||||
|
...(group.title ? { title: group.title } : {}),
|
||||||
|
admins: [...group.admins],
|
||||||
|
members: group.members.map((m) => ({ ...m })),
|
||||||
|
}])),
|
||||||
|
pending: normalized.pending
|
||||||
|
.filter((entry) => entry.status === 'pending')
|
||||||
|
.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
kind: entry.kind,
|
||||||
|
...(entry.groupJid ? { groupJid: entry.groupJid } : {}),
|
||||||
|
phone: entry.phone,
|
||||||
|
...(entry.lid ? { lid: entry.lid } : {}),
|
||||||
|
...(entry.pushName ? { pushName: entry.pushName } : {}),
|
||||||
|
...(entry.requestText ? { requestText: entry.requestText } : {}),
|
||||||
|
createdAt: entry.createdAt,
|
||||||
|
unresolved: entry.unresolved === true,
|
||||||
|
})),
|
||||||
|
contacts: normalized.contacts.slice(0, 100).map((contact) => ({
|
||||||
|
...(contact.phone ? { phone: contact.phone } : {}),
|
||||||
|
lids: [...contact.lids],
|
||||||
|
...(contact.pushName ? { pushName: contact.pushName } : {}),
|
||||||
|
lastSeenAt: contact.lastSeenAt,
|
||||||
|
scenes: [...contact.scenes],
|
||||||
|
...(contact.groupJids ? { groupJids: [...contact.groupJids] } : {}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -123,6 +123,8 @@ export async function createProductionController(ctx, config = {}, internals = {
|
||||||
channel: 'whatsapp', config: botConfig, equals: whatsappAccessPolicyIdsEqual,
|
channel: 'whatsapp', config: botConfig, equals: whatsappAccessPolicyIdsEqual,
|
||||||
}),
|
}),
|
||||||
groupSessionScope: { getScope: () => workspaces.groupSessionScopeFor(botId) },
|
groupSessionScope: { getScope: () => workspaces.groupSessionScopeFor(botId) },
|
||||||
|
workspaces,
|
||||||
|
botId,
|
||||||
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
|
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
|
||||||
connectTimeoutMs: config.connectTimeoutMs ?? 30_000,
|
connectTimeoutMs: config.connectTimeoutMs ?? 30_000,
|
||||||
createSession,
|
createSession,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,12 @@ import QRCode from 'qrcode';
|
||||||
|
|
||||||
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
|
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
|
||||||
import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.mjs';
|
import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.mjs';
|
||||||
|
import {
|
||||||
|
RESOLVE_ACCESS_PENDING_ENDPOINT,
|
||||||
|
SET_ACCESS_GRANT_ENDPOINT,
|
||||||
|
validAccessGrantPayload,
|
||||||
|
validAccessPendingResolvePayload,
|
||||||
|
} from '../shared/access-grant-rpc.mjs';
|
||||||
import { SET_GROUP_SESSION_SCOPE_ENDPOINT, validGroupSessionScopePayload } from '../shared/group-session-scope-rpc.mjs';
|
import { SET_GROUP_SESSION_SCOPE_ENDPOINT, validGroupSessionScopePayload } from '../shared/group-session-scope-rpc.mjs';
|
||||||
import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
|
import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
|
||||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||||
|
|
@ -17,6 +23,8 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
|
||||||
reconnectBot: 'bot.reconnect',
|
reconnectBot: 'bot.reconnect',
|
||||||
deleteBot: 'bot.delete',
|
deleteBot: 'bot.delete',
|
||||||
setAccessPolicy: SET_ACCESS_POLICY_ENDPOINT,
|
setAccessPolicy: SET_ACCESS_POLICY_ENDPOINT,
|
||||||
|
setAccessGrant: SET_ACCESS_GRANT_ENDPOINT,
|
||||||
|
resolveAccessPending: RESOLVE_ACCESS_PENDING_ENDPOINT,
|
||||||
setGroupSessionScope: SET_GROUP_SESSION_SCOPE_ENDPOINT,
|
setGroupSessionScope: SET_GROUP_SESSION_SCOPE_ENDPOINT,
|
||||||
setWorkspace: SET_WORKSPACE_ENDPOINT,
|
setWorkspace: SET_WORKSPACE_ENDPOINT,
|
||||||
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
|
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
|
||||||
|
|
@ -60,6 +68,14 @@ function payloadFailure(endpoint, payload) {
|
||||||
return validAccessPolicyPayload(payload)
|
return validAccessPolicyPayload(payload)
|
||||||
? null : '请提交有效的访问设置。';
|
? null : '请提交有效的访问设置。';
|
||||||
}
|
}
|
||||||
|
if (endpoint === WHATSAPP_ENDPOINTS.setAccessGrant) {
|
||||||
|
return validAccessGrantPayload(payload)
|
||||||
|
? null : '请提交有效的分级访问授权。';
|
||||||
|
}
|
||||||
|
if (endpoint === WHATSAPP_ENDPOINTS.resolveAccessPending) {
|
||||||
|
return validAccessPendingResolvePayload(payload)
|
||||||
|
? null : '请提交有效的审批请求。';
|
||||||
|
}
|
||||||
if (endpoint === WHATSAPP_ENDPOINTS.setGroupSessionScope) {
|
if (endpoint === WHATSAPP_ENDPOINTS.setGroupSessionScope) {
|
||||||
return validGroupSessionScopePayload(payload)
|
return validGroupSessionScopePayload(payload)
|
||||||
? null : '请提交有效的群会话策略。';
|
? null : '请提交有效的群会话策略。';
|
||||||
|
|
@ -196,6 +212,24 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
|
||||||
payload.policy,
|
payload.policy,
|
||||||
(status) => publicStatus(status, cachedEncode),
|
(status) => publicStatus(status, cachedEncode),
|
||||||
);
|
);
|
||||||
|
} else if (endpoint === WHATSAPP_ENDPOINTS.setAccessGrant) {
|
||||||
|
if (typeof controller.updateAccessGrant !== 'function') throw new Error('Access grant update is unavailable');
|
||||||
|
value = await controller.updateAccessGrant(
|
||||||
|
payload.botId,
|
||||||
|
payload.grant,
|
||||||
|
(status) => publicStatus(status, cachedEncode),
|
||||||
|
);
|
||||||
|
} else if (endpoint === WHATSAPP_ENDPOINTS.resolveAccessPending) {
|
||||||
|
if (typeof controller.resolveAccessPending !== 'function') throw new Error('Access pending resolve is unavailable');
|
||||||
|
value = await controller.resolveAccessPending(
|
||||||
|
payload.botId,
|
||||||
|
{
|
||||||
|
pendingId: payload.pendingId,
|
||||||
|
action: payload.action,
|
||||||
|
resolvedByPhone: payload.resolvedByPhone,
|
||||||
|
},
|
||||||
|
(status) => publicStatus(status, cachedEncode),
|
||||||
|
);
|
||||||
} else if (endpoint === WHATSAPP_ENDPOINTS.setGroupSessionScope) {
|
} else if (endpoint === WHATSAPP_ENDPOINTS.setGroupSessionScope) {
|
||||||
if (typeof controller.updateGroupSessionScope !== 'function') throw new Error('Group session scope update is unavailable');
|
if (typeof controller.updateGroupSessionScope !== 'function') throw new Error('Group session scope update is unavailable');
|
||||||
value = await controller.updateGroupSessionScope(
|
value = await controller.updateGroupSessionScope(
|
||||||
|
|
|
||||||
730
src/channels/shared/access-grant.mjs
Normal file
730
src/channels/shared/access-grant.mjs
Normal file
|
|
@ -0,0 +1,730 @@
|
||||||
|
/**
|
||||||
|
* WhatsApp ops access grants: phone-canonical identity, global vs group scopes.
|
||||||
|
* Browser-compatible (shared with settings UI).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ACCESS_GRANT_VERSION = 1;
|
||||||
|
export const ACCESS_PENDING_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
export const ACCESS_GRANT_PHONE_MAX_LENGTH = 32;
|
||||||
|
export const ACCESS_GRANT_GROUP_JID_PATTERN = /^\d{5,32}@g\.us$/;
|
||||||
|
|
||||||
|
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
|
||||||
|
|
||||||
|
/** @typedef {'direct' | 'group'} AccessGrantScene */
|
||||||
|
/** @typedef {'approve' | 'deny'} AccessPendingAction */
|
||||||
|
|
||||||
|
function invalidGrant(message) {
|
||||||
|
const error = new TypeError(message);
|
||||||
|
error.code = 'access-grant-invalid';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a phone to digits only (strip +, spaces, dashes).
|
||||||
|
* Accepts bare numbers, +E.164, or PN JIDs like `86138…@s.whatsapp.net`.
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function normalizeAccessPhone(value) {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) value = String(value);
|
||||||
|
if (typeof value === 'bigint') value = String(value);
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
let raw = value.trim();
|
||||||
|
if (!raw || CONTROL_CHARACTERS.test(raw)) return null;
|
||||||
|
const at = raw.indexOf('@');
|
||||||
|
if (at > 0) {
|
||||||
|
const user = raw.slice(0, at).split(':')[0];
|
||||||
|
const server = raw.slice(at + 1).toLowerCase();
|
||||||
|
if (!['s.whatsapp.net', 'c.us', 'hosted'].includes(server)) return null;
|
||||||
|
raw = user;
|
||||||
|
}
|
||||||
|
const digits = raw.replace(/[^\d]/g, '');
|
||||||
|
if (digits.length < 5 || digits.length > ACCESS_GRANT_PHONE_MAX_LENGTH) return null;
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function validateAccessPhone(value) {
|
||||||
|
const phone = normalizeAccessPhone(value);
|
||||||
|
if (!phone) invalidGrant('电话号码无效。');
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract phone digits from a WhatsApp JID when it is a PN (not LID).
|
||||||
|
* @param {unknown} jid
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function phoneFromWhatsappJid(jid) {
|
||||||
|
return normalizeAccessPhone(jid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function validateGroupJid(value) {
|
||||||
|
if (typeof value !== 'string' || !ACCESS_GRANT_GROUP_JID_PATTERN.test(value.trim())) {
|
||||||
|
invalidGrant('群 JID 无效。');
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} input
|
||||||
|
* @returns {{ phone: string, canExecuteCommands: boolean }}
|
||||||
|
*/
|
||||||
|
function validateMember(input) {
|
||||||
|
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||||
|
invalidGrant('成员条目无效。');
|
||||||
|
}
|
||||||
|
const phone = validateAccessPhone(input.phone ?? input.id);
|
||||||
|
const canExecuteCommands = input.canExecuteCommands === undefined
|
||||||
|
? true
|
||||||
|
: Boolean(input.canExecuteCommands);
|
||||||
|
return Object.freeze({ phone, canExecuteCommands });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} input
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function validatePhoneList(input, { emptyMessage, label }) {
|
||||||
|
if (!Array.isArray(input)) invalidGrant(`${label}必须是数组。`);
|
||||||
|
const phones = input.map((entry) => (
|
||||||
|
typeof entry === 'string' || typeof entry === 'number' || typeof entry === 'bigint'
|
||||||
|
? validateAccessPhone(entry)
|
||||||
|
: validateAccessPhone(entry?.phone ?? entry?.id)
|
||||||
|
));
|
||||||
|
if (new Set(phones).size !== phones.length) invalidGrant(`${label}不能包含重复电话。`);
|
||||||
|
if (phones.length === 0 && emptyMessage) invalidGrant(emptyMessage);
|
||||||
|
return Object.freeze(phones);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} input
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
function validateGroupGrant(input) {
|
||||||
|
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||||
|
invalidGrant('群授权条目无效。');
|
||||||
|
}
|
||||||
|
const admins = validatePhoneList(input.admins ?? [], { label: '群管理员' });
|
||||||
|
const members = Object.freeze((Array.isArray(input.members) ? input.members : [])
|
||||||
|
.map(validateMember));
|
||||||
|
if (new Set(members.map((m) => m.phone)).size !== members.length) {
|
||||||
|
invalidGrant('群成员不能包含重复电话。');
|
||||||
|
}
|
||||||
|
const title = typeof input.title === 'string' ? input.title.trim().slice(0, 128) : '';
|
||||||
|
return Object.freeze({
|
||||||
|
...(title ? { title } : {}),
|
||||||
|
admins,
|
||||||
|
members,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} input
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
function validatePending(input) {
|
||||||
|
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||||
|
invalidGrant('待批条目无效。');
|
||||||
|
}
|
||||||
|
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
||||||
|
if (!/^[A-Za-z0-9_-]{6,64}$/.test(id)) invalidGrant('待批编号无效。');
|
||||||
|
const kind = input.kind === 'group' ? 'group' : input.kind === 'direct' ? 'direct' : null;
|
||||||
|
if (!kind) invalidGrant('待批场景无效。');
|
||||||
|
const phone = input.phone ? validateAccessPhone(input.phone) : '';
|
||||||
|
const lid = typeof input.lid === 'string' ? input.lid.trim().slice(0, 128) : '';
|
||||||
|
if (!phone && !lid) invalidGrant('待批缺少电话或 LID。');
|
||||||
|
const groupJid = kind === 'group' ? validateGroupJid(input.groupJid) : undefined;
|
||||||
|
const pushName = typeof input.pushName === 'string' ? input.pushName.trim().slice(0, 128) : '';
|
||||||
|
const requestText = typeof input.requestText === 'string'
|
||||||
|
? input.requestText.trim().slice(0, 500) : '';
|
||||||
|
const createdAt = typeof input.createdAt === 'string' && input.createdAt
|
||||||
|
? input.createdAt : new Date().toISOString();
|
||||||
|
const unresolved = input.unresolved === true || !phone;
|
||||||
|
const notifyRefs = Array.isArray(input.notifyRefs)
|
||||||
|
? Object.freeze(input.notifyRefs.map((ref) => Object.freeze({
|
||||||
|
adminPhone: validateAccessPhone(ref.adminPhone),
|
||||||
|
providerMessageId: String(ref.providerMessageId ?? '').trim().slice(0, 128),
|
||||||
|
})).filter((ref) => ref.providerMessageId))
|
||||||
|
: Object.freeze([]);
|
||||||
|
const resolvedByPhone = input.resolvedByPhone
|
||||||
|
? validateAccessPhone(input.resolvedByPhone) : undefined;
|
||||||
|
const resolvedAt = typeof input.resolvedAt === 'string' ? input.resolvedAt : undefined;
|
||||||
|
const status = ['pending', 'approved', 'denied', 'expired'].includes(input.status)
|
||||||
|
? input.status : 'pending';
|
||||||
|
return Object.freeze({
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
...(groupJid ? { groupJid } : {}),
|
||||||
|
phone: phone || '',
|
||||||
|
...(lid ? { lid } : {}),
|
||||||
|
...(pushName ? { pushName } : {}),
|
||||||
|
...(requestText ? { requestText } : {}),
|
||||||
|
createdAt,
|
||||||
|
unresolved,
|
||||||
|
notifyRefs,
|
||||||
|
status,
|
||||||
|
...(resolvedByPhone ? { resolvedByPhone } : {}),
|
||||||
|
...(resolvedAt ? { resolvedAt } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} input
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
function validateContact(input) {
|
||||||
|
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||||
|
invalidGrant('联系人条目无效。');
|
||||||
|
}
|
||||||
|
const phone = input.phone ? validateAccessPhone(input.phone) : undefined;
|
||||||
|
const lids = Object.freeze([...(Array.isArray(input.lids) ? input.lids : [])]
|
||||||
|
.map((lid) => String(lid ?? '').trim())
|
||||||
|
.filter((lid) => lid && lid.length <= 128)
|
||||||
|
.slice(0, 8));
|
||||||
|
const pushName = typeof input.pushName === 'string' ? input.pushName.trim().slice(0, 128) : '';
|
||||||
|
const lastSeenAt = typeof input.lastSeenAt === 'string' && input.lastSeenAt
|
||||||
|
? input.lastSeenAt : new Date().toISOString();
|
||||||
|
const scenes = Object.freeze([...(Array.isArray(input.scenes) ? input.scenes : [])]
|
||||||
|
.filter((scene) => scene === 'direct' || scene === 'group')
|
||||||
|
.slice(0, 2));
|
||||||
|
const groupJids = Object.freeze([...(Array.isArray(input.groupJids) ? input.groupJids : [])]
|
||||||
|
.map((jid) => String(jid ?? '').trim())
|
||||||
|
.filter((jid) => ACCESS_GRANT_GROUP_JID_PATTERN.test(jid))
|
||||||
|
.slice(0, 32));
|
||||||
|
if (!phone && lids.length === 0) invalidGrant('联系人缺少电话或 LID。');
|
||||||
|
return Object.freeze({
|
||||||
|
...(phone ? { phone } : {}),
|
||||||
|
lids,
|
||||||
|
...(pushName ? { pushName } : {}),
|
||||||
|
lastSeenAt,
|
||||||
|
scenes: scenes.length > 0 ? scenes : Object.freeze(['direct']),
|
||||||
|
...(groupJids.length > 0 ? { groupJids } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty grant document (before owner seed).
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
export function emptyAccessGrant() {
|
||||||
|
return Object.freeze({
|
||||||
|
version: ACCESS_GRANT_VERSION,
|
||||||
|
globalAdmins: Object.freeze([]),
|
||||||
|
directMembers: Object.freeze([]),
|
||||||
|
groups: Object.freeze({}),
|
||||||
|
pending: Object.freeze([]),
|
||||||
|
contacts: Object.freeze([]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} input
|
||||||
|
* @param {{ requireGlobalAdmin?: boolean }} [options]
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
export function validateAccessGrant(input, { requireGlobalAdmin = true } = {}) {
|
||||||
|
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||||
|
invalidGrant('访问授权文档无效。');
|
||||||
|
}
|
||||||
|
const version = input.version === undefined ? ACCESS_GRANT_VERSION : Number(input.version);
|
||||||
|
if (version !== ACCESS_GRANT_VERSION) invalidGrant('访问授权版本不支持。');
|
||||||
|
const globalAdmins = validatePhoneList(input.globalAdmins ?? [], {
|
||||||
|
label: '全局管理员',
|
||||||
|
emptyMessage: requireGlobalAdmin ? '至少保留一位全局管理员。' : undefined,
|
||||||
|
});
|
||||||
|
if (requireGlobalAdmin && globalAdmins.length === 0) {
|
||||||
|
invalidGrant('至少保留一位全局管理员。');
|
||||||
|
}
|
||||||
|
const directMembers = Object.freeze((Array.isArray(input.directMembers) ? input.directMembers : [])
|
||||||
|
.map(validateMember));
|
||||||
|
if (new Set(directMembers.map((m) => m.phone)).size !== directMembers.length) {
|
||||||
|
invalidGrant('私聊授权用户不能包含重复电话。');
|
||||||
|
}
|
||||||
|
const groups = Object.create(null);
|
||||||
|
if (input.groups && typeof input.groups === 'object' && !Array.isArray(input.groups)) {
|
||||||
|
for (const [groupJid, grant] of Object.entries(input.groups)) {
|
||||||
|
groups[validateGroupJid(groupJid)] = validateGroupGrant(grant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pending = Object.freeze((Array.isArray(input.pending) ? input.pending : [])
|
||||||
|
.map(validatePending)
|
||||||
|
.slice(0, 200));
|
||||||
|
const contacts = Object.freeze((Array.isArray(input.contacts) ? input.contacts : [])
|
||||||
|
.map(validateContact)
|
||||||
|
.slice(0, 500));
|
||||||
|
return Object.freeze({
|
||||||
|
version: ACCESS_GRANT_VERSION,
|
||||||
|
globalAdmins,
|
||||||
|
directMembers,
|
||||||
|
groups: Object.freeze(groups),
|
||||||
|
pending,
|
||||||
|
contacts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} input
|
||||||
|
* @returns {object|null}
|
||||||
|
*/
|
||||||
|
export function normalizeAccessGrant(input) {
|
||||||
|
try {
|
||||||
|
return validateAccessGrant(input, { requireGlobalAdmin: false });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed owner phone as the first global admin when missing.
|
||||||
|
* @param {object|null} grant
|
||||||
|
* @param {string|null} ownerPhone
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
export function ensureOwnerGlobalAdmin(grant, ownerPhone) {
|
||||||
|
const base = grant ? validateAccessGrant(grant, { requireGlobalAdmin: false })
|
||||||
|
: emptyAccessGrant();
|
||||||
|
const owner = normalizeAccessPhone(ownerPhone);
|
||||||
|
if (!owner) return base;
|
||||||
|
if (base.globalAdmins.includes(owner)) return base;
|
||||||
|
return validateAccessGrant({
|
||||||
|
...base,
|
||||||
|
globalAdmins: [owner, ...base.globalAdmins],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate legacy shared accessPolicy allowlist phones into directMembers only.
|
||||||
|
* @param {object|null} policy
|
||||||
|
* @param {object|null} existingGrant
|
||||||
|
* @param {string|null} ownerPhone
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
export function migrateAccessPolicyToGrant(policy, existingGrant, ownerPhone) {
|
||||||
|
let grant = ensureOwnerGlobalAdmin(existingGrant, ownerPhone);
|
||||||
|
const phones = new Set(grant.directMembers.map((m) => m.phone));
|
||||||
|
const scopes = [policy?.direct, policy?.group];
|
||||||
|
for (const scope of scopes) {
|
||||||
|
for (const user of scope?.allowlist?.users ?? []) {
|
||||||
|
const phone = normalizeAccessPhone(user?.id);
|
||||||
|
if (!phone || phones.has(phone)) continue;
|
||||||
|
phones.add(phone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const directMembers = [
|
||||||
|
...grant.directMembers,
|
||||||
|
...[...phones]
|
||||||
|
.filter((phone) => !grant.directMembers.some((m) => m.phone === phone))
|
||||||
|
.map((phone) => ({ phone, canExecuteCommands: true })),
|
||||||
|
];
|
||||||
|
return validateAccessGrant({
|
||||||
|
...grant,
|
||||||
|
directMembers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate whether a phone may use the bot in a scene.
|
||||||
|
* @param {object} grant
|
||||||
|
* @param {{ scene: AccessGrantScene, phone: string|null, groupJid?: string, isCommand?: boolean }} input
|
||||||
|
* @returns {{ allowed: boolean, reason: string, canExecuteCommands?: boolean, canApprove?: boolean }}
|
||||||
|
*/
|
||||||
|
export function evaluateAccessGrant(grant, {
|
||||||
|
scene,
|
||||||
|
phone,
|
||||||
|
groupJid,
|
||||||
|
isCommand = false,
|
||||||
|
} = {}) {
|
||||||
|
const doc = normalizeAccessGrant(grant) ?? emptyAccessGrant();
|
||||||
|
const normalizedPhone = normalizeAccessPhone(phone);
|
||||||
|
if (!normalizedPhone) {
|
||||||
|
return { allowed: false, reason: 'phone-unavailable' };
|
||||||
|
}
|
||||||
|
if (doc.globalAdmins.includes(normalizedPhone)) {
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
reason: 'global-admin',
|
||||||
|
canExecuteCommands: true,
|
||||||
|
canApprove: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (scene === 'direct') {
|
||||||
|
const member = doc.directMembers.find((m) => m.phone === normalizedPhone);
|
||||||
|
if (!member) return { allowed: false, reason: 'direct-not-allowed' };
|
||||||
|
if (isCommand && !member.canExecuteCommands) {
|
||||||
|
return { allowed: false, reason: 'command-not-allowed', canExecuteCommands: false };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
reason: 'direct-member',
|
||||||
|
canExecuteCommands: member.canExecuteCommands,
|
||||||
|
canApprove: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (scene !== 'group' || !groupJid || !ACCESS_GRANT_GROUP_JID_PATTERN.test(groupJid)) {
|
||||||
|
return { allowed: false, reason: 'invalid-context' };
|
||||||
|
}
|
||||||
|
const group = doc.groups[groupJid];
|
||||||
|
if (!group) return { allowed: false, reason: 'group-not-allowed' };
|
||||||
|
if (group.admins.includes(normalizedPhone)) {
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
reason: 'group-admin',
|
||||||
|
canExecuteCommands: true,
|
||||||
|
canApprove: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const member = group.members.find((m) => m.phone === normalizedPhone);
|
||||||
|
if (!member) return { allowed: false, reason: 'group-not-allowed' };
|
||||||
|
if (isCommand && !member.canExecuteCommands) {
|
||||||
|
return { allowed: false, reason: 'command-not-allowed', canExecuteCommands: false };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
reason: 'group-member',
|
||||||
|
canExecuteCommands: member.canExecuteCommands,
|
||||||
|
canApprove: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who should receive a pending notify for this request.
|
||||||
|
* @param {object} grant
|
||||||
|
* @param {{ kind: AccessGrantScene, groupJid?: string }} pending
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function approverPhonesForPending(grant, pending) {
|
||||||
|
const doc = normalizeAccessGrant(grant) ?? emptyAccessGrant();
|
||||||
|
if (pending.kind === 'group' && pending.groupJid) {
|
||||||
|
const group = doc.groups[pending.groupJid];
|
||||||
|
if (group?.admins?.length) return [...group.admins];
|
||||||
|
}
|
||||||
|
return [...doc.globalAdmins];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this phone may resolve a pending request.
|
||||||
|
* @param {object} grant
|
||||||
|
* @param {string} adminPhone
|
||||||
|
* @param {object} pending
|
||||||
|
*/
|
||||||
|
export function canResolvePending(grant, adminPhone, pending) {
|
||||||
|
const phone = normalizeAccessPhone(adminPhone);
|
||||||
|
if (!phone || pending?.status !== 'pending') return false;
|
||||||
|
return approverPhonesForPending(grant, pending).includes(phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPendingExpired(pending, now = Date.now()) {
|
||||||
|
const created = Date.parse(pending?.createdAt ?? '');
|
||||||
|
if (!Number.isFinite(created)) return true;
|
||||||
|
return now - created > ACCESS_PENDING_TTL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an open pending matching identity+scene (dedupe).
|
||||||
|
* @param {object} grant
|
||||||
|
* @param {{ kind: AccessGrantScene, groupJid?: string, phone?: string, lid?: string }} query
|
||||||
|
*/
|
||||||
|
export function findOpenPending(grant, query) {
|
||||||
|
const doc = normalizeAccessGrant(grant) ?? emptyAccessGrant();
|
||||||
|
const phone = normalizeAccessPhone(query.phone);
|
||||||
|
return doc.pending.find((entry) => {
|
||||||
|
if (entry.status !== 'pending' || isPendingExpired(entry)) return false;
|
||||||
|
if (entry.kind !== query.kind) return false;
|
||||||
|
if (query.kind === 'group' && entry.groupJid !== query.groupJid) return false;
|
||||||
|
if (phone && entry.phone === phone) return true;
|
||||||
|
if (query.lid && entry.lid === query.lid) return true;
|
||||||
|
return false;
|
||||||
|
}) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a contact observation into the grant document (immutable return).
|
||||||
|
* @param {object} grant
|
||||||
|
* @param {{ phone?: string, lid?: string, pushName?: string, scene: AccessGrantScene, groupJid?: string, at?: string }} sighting
|
||||||
|
*/
|
||||||
|
export function upsertAccessContact(grant, sighting) {
|
||||||
|
const doc = validateAccessGrant(grant, { requireGlobalAdmin: false });
|
||||||
|
const phone = normalizeAccessPhone(sighting.phone);
|
||||||
|
const lid = typeof sighting.lid === 'string' ? sighting.lid.trim() : '';
|
||||||
|
const at = sighting.at ?? new Date().toISOString();
|
||||||
|
const pushName = typeof sighting.pushName === 'string' ? sighting.pushName.trim() : '';
|
||||||
|
const scene = sighting.scene === 'group' ? 'group' : 'direct';
|
||||||
|
const groupJid = scene === 'group' && sighting.groupJid
|
||||||
|
? validateGroupJid(sighting.groupJid) : null;
|
||||||
|
|
||||||
|
const contacts = [...doc.contacts];
|
||||||
|
let index = contacts.findIndex((contact) => (
|
||||||
|
(phone && contact.phone === phone)
|
||||||
|
|| (lid && contact.lids.includes(lid))
|
||||||
|
));
|
||||||
|
if (index < 0) {
|
||||||
|
contacts.unshift(validateContact({
|
||||||
|
phone,
|
||||||
|
lids: lid ? [lid] : [],
|
||||||
|
pushName,
|
||||||
|
lastSeenAt: at,
|
||||||
|
scenes: [scene],
|
||||||
|
groupJids: groupJid ? [groupJid] : [],
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const prev = contacts[index];
|
||||||
|
const lids = [...new Set([...(prev.lids ?? []), ...(lid ? [lid] : [])])].slice(0, 8);
|
||||||
|
const scenes = [...new Set([...(prev.scenes ?? []), scene])];
|
||||||
|
const groupJids = [...new Set([
|
||||||
|
...(prev.groupJids ?? []),
|
||||||
|
...(groupJid ? [groupJid] : []),
|
||||||
|
])].slice(0, 32);
|
||||||
|
contacts[index] = validateContact({
|
||||||
|
phone: phone ?? prev.phone,
|
||||||
|
lids,
|
||||||
|
pushName: pushName || prev.pushName,
|
||||||
|
lastSeenAt: at,
|
||||||
|
scenes,
|
||||||
|
groupJids,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return validateAccessGrant({
|
||||||
|
...doc,
|
||||||
|
contacts: contacts.slice(0, 500),
|
||||||
|
}, { requireGlobalAdmin: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a group bucket exists (for titles / admin config).
|
||||||
|
* @param {object} grant
|
||||||
|
* @param {string} groupJid
|
||||||
|
* @param {{ title?: string }} [patch]
|
||||||
|
*/
|
||||||
|
export function ensureGroupBucket(grant, groupJid, patch = {}) {
|
||||||
|
const doc = validateAccessGrant(grant, { requireGlobalAdmin: false });
|
||||||
|
const jid = validateGroupJid(groupJid);
|
||||||
|
const existing = doc.groups[jid] ?? { admins: [], members: [] };
|
||||||
|
const title = typeof patch.title === 'string' && patch.title.trim()
|
||||||
|
? patch.title.trim().slice(0, 128)
|
||||||
|
: existing.title;
|
||||||
|
const groups = {
|
||||||
|
...doc.groups,
|
||||||
|
[jid]: validateGroupGrant({
|
||||||
|
...existing,
|
||||||
|
...(title ? { title } : {}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return validateAccessGrant({ ...doc, groups }, { requireGlobalAdmin: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or reuse a pending request.
|
||||||
|
* @param {object} grant
|
||||||
|
* @param {object} request
|
||||||
|
* @param {{ idFactory?: () => string }} [options]
|
||||||
|
*/
|
||||||
|
export function enqueueAccessPending(grant, request, { idFactory } = {}) {
|
||||||
|
const doc = validateAccessGrant(grant, { requireGlobalAdmin: false });
|
||||||
|
const existing = findOpenPending(doc, request);
|
||||||
|
if (existing) return { grant: doc, pending: existing, created: false };
|
||||||
|
|
||||||
|
const pending = validatePending({
|
||||||
|
id: idFactory?.() ?? `p_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
kind: request.kind,
|
||||||
|
groupJid: request.groupJid,
|
||||||
|
phone: request.phone ?? '',
|
||||||
|
lid: request.lid,
|
||||||
|
pushName: request.pushName,
|
||||||
|
requestText: request.requestText,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
unresolved: !normalizeAccessPhone(request.phone),
|
||||||
|
status: 'pending',
|
||||||
|
notifyRefs: [],
|
||||||
|
});
|
||||||
|
const nextPending = [pending, ...doc.pending.filter((entry) => (
|
||||||
|
entry.status === 'pending' && !isPendingExpired(entry)
|
||||||
|
))].slice(0, 200);
|
||||||
|
return {
|
||||||
|
grant: validateAccessGrant({ ...doc, pending: nextPending }, { requireGlobalAdmin: false }),
|
||||||
|
pending,
|
||||||
|
created: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach notify message ids after DMing admins.
|
||||||
|
* @param {object} grant
|
||||||
|
* @param {string} pendingId
|
||||||
|
* @param {Array<{ adminPhone: string, providerMessageId: string }>} refs
|
||||||
|
*/
|
||||||
|
export function attachPendingNotifyRefs(grant, pendingId, refs) {
|
||||||
|
const doc = validateAccessGrant(grant, { requireGlobalAdmin: false });
|
||||||
|
const pending = doc.pending.map((entry) => {
|
||||||
|
if (entry.id !== pendingId) return entry;
|
||||||
|
return validatePending({
|
||||||
|
...entry,
|
||||||
|
notifyRefs: [...(entry.notifyRefs ?? []), ...refs],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return validateAccessGrant({ ...doc, pending }, { requireGlobalAdmin: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a pending request into the correct grant bucket.
|
||||||
|
* @param {object} grant
|
||||||
|
* @param {{ pendingId: string, action: AccessPendingAction, resolvedByPhone: string }} input
|
||||||
|
*/
|
||||||
|
export function resolveAccessPending(grant, { pendingId, action, resolvedByPhone }) {
|
||||||
|
const doc = validateAccessGrant(grant, { requireGlobalAdmin: false });
|
||||||
|
const adminPhone = validateAccessPhone(resolvedByPhone);
|
||||||
|
const index = doc.pending.findIndex((entry) => entry.id === pendingId);
|
||||||
|
if (index < 0) {
|
||||||
|
const error = new Error('找不到待批申请。');
|
||||||
|
error.code = 'pending-not-found';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const entry = doc.pending[index];
|
||||||
|
if (entry.status !== 'pending' || isPendingExpired(entry)) {
|
||||||
|
const error = new Error('该申请已失效。');
|
||||||
|
error.code = 'pending-expired';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!canResolvePending(doc, adminPhone, entry)) {
|
||||||
|
const error = new Error('无权审批该申请。');
|
||||||
|
error.code = 'pending-forbidden';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (action !== 'approve' && action !== 'deny') {
|
||||||
|
invalidGrant('审批动作无效。');
|
||||||
|
}
|
||||||
|
if (action === 'approve' && entry.unresolved) {
|
||||||
|
const error = new Error('申请人电话尚未解析,请在设置中补全后再批准。');
|
||||||
|
error.code = 'pending-unresolved';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let next = { ...doc };
|
||||||
|
if (action === 'approve') {
|
||||||
|
if (entry.kind === 'direct') {
|
||||||
|
if (!next.directMembers.some((m) => m.phone === entry.phone)) {
|
||||||
|
next.directMembers = [
|
||||||
|
...next.directMembers,
|
||||||
|
{ phone: entry.phone, canExecuteCommands: true },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const group = next.groups[entry.groupJid] ?? { admins: [], members: [] };
|
||||||
|
if (!group.members.some((m) => m.phone === entry.phone)
|
||||||
|
&& !group.admins.includes(entry.phone)) {
|
||||||
|
next.groups = {
|
||||||
|
...next.groups,
|
||||||
|
[entry.groupJid]: {
|
||||||
|
...group,
|
||||||
|
members: [...group.members, { phone: entry.phone, canExecuteCommands: true }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = [...doc.pending];
|
||||||
|
pending[index] = validatePending({
|
||||||
|
...entry,
|
||||||
|
status: action === 'approve' ? 'approved' : 'denied',
|
||||||
|
resolvedByPhone: adminPhone,
|
||||||
|
resolvedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
next.pending = pending;
|
||||||
|
return {
|
||||||
|
grant: validateAccessGrant(next),
|
||||||
|
pending: pending[index],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find pending by notify stanza (quote-reply).
|
||||||
|
* @param {object} grant
|
||||||
|
* @param {string} providerMessageId
|
||||||
|
*/
|
||||||
|
export function findPendingByNotifyMessageId(grant, providerMessageId) {
|
||||||
|
const id = String(providerMessageId ?? '').trim();
|
||||||
|
if (!id) return null;
|
||||||
|
const doc = normalizeAccessGrant(grant) ?? emptyAccessGrant();
|
||||||
|
return doc.pending.find((entry) => (
|
||||||
|
entry.status === 'pending'
|
||||||
|
&& !isPendingExpired(entry)
|
||||||
|
&& (entry.notifyRefs ?? []).some((ref) => ref.providerMessageId === id)
|
||||||
|
)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AFFIRMATIVE = new Set([
|
||||||
|
'yes', 'y', 'approve', 'allow', 'add', 'ok', 'grant', 'whitelist',
|
||||||
|
'同意', '可以', '是', '添加', '通过',
|
||||||
|
]);
|
||||||
|
const NEGATIVE = new Set([
|
||||||
|
'no', 'n', 'deny', 'reject', 'ignore',
|
||||||
|
'拒绝', '不', '否', '忽略',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse admin quote-reply intent.
|
||||||
|
* @param {string} text
|
||||||
|
* @returns {AccessPendingAction|null}
|
||||||
|
*/
|
||||||
|
export function parseApprovalIntent(text) {
|
||||||
|
const raw = String(text ?? '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
const first = raw.split(/\s+/u)[0]?.toLowerCase?.() ?? '';
|
||||||
|
if (AFFIRMATIVE.has(first) || /添加白名单|add to whitelist/iu.test(raw)) return 'approve';
|
||||||
|
if (NEGATIVE.has(first)) return 'deny';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse pending id from notify body (`请求编号: xxx` / `Request: xxx`).
|
||||||
|
* @param {string} text
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function parsePendingIdFromNotifyText(text) {
|
||||||
|
const raw = String(text ?? '');
|
||||||
|
const match = raw.match(/(?:请求编号|Request)\s*[::]\s*([A-Za-z0-9_-]{6,64})/u);
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notify / ack copy (zh). */
|
||||||
|
export const ACCESS_GRANT_COPY = Object.freeze({
|
||||||
|
pendingAckDirect: '已提交私聊访问申请,请等待全局管理员审批。',
|
||||||
|
pendingAckGroup: '已提交本群访问申请,请等待管理员审批。',
|
||||||
|
pendingUnresolved: '已记录访问申请,但尚未解析到电话号码;请管理员在设置中补全后再批准。',
|
||||||
|
notifyTitle: '[dsh-im-ops] 访问申请',
|
||||||
|
approvedDirect: '私聊访问已批准,现在可以直接对话。',
|
||||||
|
approvedGroup: '本群访问已批准,请在本群 @ 机器人继续。',
|
||||||
|
denied: '访问申请未通过。',
|
||||||
|
adminApproved: '已批准该访问申请。',
|
||||||
|
adminDenied: '已拒绝该访问申请。',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} pending
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatPendingNotifyBody(pending) {
|
||||||
|
const scene = pending.kind === 'group'
|
||||||
|
? `群聊 ${pending.groupJid ?? ''}`
|
||||||
|
: '私聊';
|
||||||
|
const who = pending.pushName
|
||||||
|
? `${pending.pushName} (${pending.phone || pending.lid || '未知'})`
|
||||||
|
: (pending.phone || pending.lid || '未知');
|
||||||
|
const text = pending.requestText ? `\n原文:${pending.requestText.slice(0, 200)}` : '';
|
||||||
|
return [
|
||||||
|
ACCESS_GRANT_COPY.notifyTitle,
|
||||||
|
`场景:${scene}`,
|
||||||
|
`申请人:${who}`,
|
||||||
|
`请求编号:${pending.id}`,
|
||||||
|
'请引用本条消息回复「同意」或「拒绝」。',
|
||||||
|
text,
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,12 @@ import {
|
||||||
normalizeGroupSessionScope,
|
normalizeGroupSessionScope,
|
||||||
validateGroupSessionScope,
|
validateGroupSessionScope,
|
||||||
} from './session-scope.mjs';
|
} from './session-scope.mjs';
|
||||||
|
import {
|
||||||
|
emptyAccessGrant,
|
||||||
|
normalizeAccessGrant,
|
||||||
|
resolveAccessPending,
|
||||||
|
validateAccessGrant,
|
||||||
|
} from './access-grant.mjs';
|
||||||
import { CONNECTION_TEST_STATE_IDENTITY } from './connection-test.mjs';
|
import { CONNECTION_TEST_STATE_IDENTITY } from './connection-test.mjs';
|
||||||
import {
|
import {
|
||||||
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
|
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
|
||||||
|
|
@ -211,7 +217,17 @@ function normalizeDocument(value) {
|
||||||
const deliveryTargets = normalizeDeliveryTargets(value.deliveryTargets);
|
const deliveryTargets = normalizeDeliveryTargets(value.deliveryTargets);
|
||||||
if (!deliveryTargets) return null;
|
if (!deliveryTargets) return null;
|
||||||
const accessPolicies = normalizeAccessPolicies(value.accessPolicies, workspaces);
|
const accessPolicies = normalizeAccessPolicies(value.accessPolicies, workspaces);
|
||||||
const version = value.accessPolicies === undefined ? value.version : 2;
|
const accessGrants = Object.create(null);
|
||||||
|
if (value.accessGrants && typeof value.accessGrants === 'object'
|
||||||
|
&& !Array.isArray(value.accessGrants)) {
|
||||||
|
for (const [botId, grant] of Object.entries(value.accessGrants)) {
|
||||||
|
if (!/^[A-Za-z0-9_-]{1,128}$/.test(botId)) continue;
|
||||||
|
const normalized = normalizeAccessGrant(grant);
|
||||||
|
if (normalized) accessGrants[botId] = normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const version = value.accessPolicies === undefined && value.accessGrants === undefined
|
||||||
|
? value.version : 2;
|
||||||
return {
|
return {
|
||||||
// A v1 file cannot be emitted with this optional v2 section. If one is
|
// A v1 file cannot be emitted with this optional v2 section. If one is
|
||||||
// recovered from an interrupted/manual edit, retain it on the next write.
|
// recovered from an interrupted/manual edit, retain it on the next write.
|
||||||
|
|
@ -222,6 +238,7 @@ function normalizeDocument(value) {
|
||||||
contextEnhancement,
|
contextEnhancement,
|
||||||
deliveryTargets,
|
deliveryTargets,
|
||||||
accessPolicies,
|
accessPolicies,
|
||||||
|
accessGrants,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -233,6 +250,7 @@ function storedDocument({
|
||||||
contextEnhancement,
|
contextEnhancement,
|
||||||
deliveryTargets,
|
deliveryTargets,
|
||||||
accessPolicies,
|
accessPolicies,
|
||||||
|
accessGrants,
|
||||||
}) {
|
}) {
|
||||||
const document = { version, workspaces };
|
const document = { version, workspaces };
|
||||||
if (Object.keys(agentPresets).length > 0) document.agentPresets = agentPresets;
|
if (Object.keys(agentPresets).length > 0) document.agentPresets = agentPresets;
|
||||||
|
|
@ -246,6 +264,9 @@ function storedDocument({
|
||||||
if (version >= 2 && Object.keys(accessPolicies).length > 0) {
|
if (version >= 2 && Object.keys(accessPolicies).length > 0) {
|
||||||
document.accessPolicies = accessPolicies;
|
document.accessPolicies = accessPolicies;
|
||||||
}
|
}
|
||||||
|
if (version >= 2 && Object.keys(accessGrants).length > 0) {
|
||||||
|
document.accessGrants = accessGrants;
|
||||||
|
}
|
||||||
return document;
|
return document;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -292,6 +313,7 @@ export class BotWorkspaceStore {
|
||||||
#contextEnhancement = {};
|
#contextEnhancement = {};
|
||||||
#deliveryTargets = Object.create(null);
|
#deliveryTargets = Object.create(null);
|
||||||
#accessPolicies = Object.create(null);
|
#accessPolicies = Object.create(null);
|
||||||
|
#accessGrants = Object.create(null);
|
||||||
#generations = new Map();
|
#generations = new Map();
|
||||||
#nextGeneration = 1;
|
#nextGeneration = 1;
|
||||||
#incarnations = new Map();
|
#incarnations = new Map();
|
||||||
|
|
@ -319,6 +341,7 @@ export class BotWorkspaceStore {
|
||||||
this.#contextEnhancement = normalized.contextEnhancement;
|
this.#contextEnhancement = normalized.contextEnhancement;
|
||||||
this.#deliveryTargets = normalized.deliveryTargets;
|
this.#deliveryTargets = normalized.deliveryTargets;
|
||||||
this.#accessPolicies = normalized.accessPolicies;
|
this.#accessPolicies = normalized.accessPolicies;
|
||||||
|
this.#accessGrants = normalized.accessGrants;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.code !== 'ENOENT') throw error;
|
if (error?.code !== 'ENOENT') throw error;
|
||||||
this.#version = 1;
|
this.#version = 1;
|
||||||
|
|
@ -328,6 +351,7 @@ export class BotWorkspaceStore {
|
||||||
this.#contextEnhancement = {};
|
this.#contextEnhancement = {};
|
||||||
this.#deliveryTargets = Object.create(null);
|
this.#deliveryTargets = Object.create(null);
|
||||||
this.#accessPolicies = Object.create(null);
|
this.#accessPolicies = Object.create(null);
|
||||||
|
this.#accessGrants = Object.create(null);
|
||||||
}
|
}
|
||||||
this.#generations.clear();
|
this.#generations.clear();
|
||||||
this.#nextGeneration = 1;
|
this.#nextGeneration = 1;
|
||||||
|
|
@ -381,6 +405,14 @@ export class BotWorkspaceStore {
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accessGrantFor(botId) {
|
||||||
|
const id = botIdOf(botId);
|
||||||
|
if (this.has(id) && Object.hasOwn(this.#accessGrants, id)) {
|
||||||
|
return this.#accessGrants[id];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
listDeliveryTargets(botId) {
|
listDeliveryTargets(botId) {
|
||||||
const id = botIdOf(botId);
|
const id = botIdOf(botId);
|
||||||
if (!this.has(id)) throw deliveryTargetError('unknown-bot', 'Unknown bot');
|
if (!this.has(id)) throw deliveryTargetError('unknown-bot', 'Unknown bot');
|
||||||
|
|
@ -671,6 +703,35 @@ export class BotWorkspaceStore {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setAccessGrant(botId, value, { incarnation } = {}) {
|
||||||
|
const id = botIdOf(botId);
|
||||||
|
if (!this.has(id)
|
||||||
|
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
|
||||||
|
const error = new Error('找不到要修改的机器人。');
|
||||||
|
error.code = 'workspace-bot-not-found';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const grant = validateAccessGrant(value);
|
||||||
|
return this.#enqueue(id, async () => {
|
||||||
|
if (!this.has(id)
|
||||||
|
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
|
||||||
|
const error = new Error('找不到要修改的机器人。');
|
||||||
|
error.code = 'workspace-bot-not-found';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const previous = this.#accessGrants[id] ?? null;
|
||||||
|
this.#accessGrants[id] = grant;
|
||||||
|
try {
|
||||||
|
await this.#persist();
|
||||||
|
} catch (error) {
|
||||||
|
if (previous) this.#accessGrants[id] = previous;
|
||||||
|
else delete this.#accessGrants[id];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return grant;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async bindWorkspaceSession(botId, value, {
|
async bindWorkspaceSession(botId, value, {
|
||||||
conversationKey,
|
conversationKey,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|
@ -832,6 +893,7 @@ export class BotWorkspaceStore {
|
||||||
...Object.keys(this.#contextEnhancement),
|
...Object.keys(this.#contextEnhancement),
|
||||||
...Object.keys(this.#deliveryTargets),
|
...Object.keys(this.#deliveryTargets),
|
||||||
...Object.keys(this.#accessPolicies),
|
...Object.keys(this.#accessPolicies),
|
||||||
|
...Object.keys(this.#accessGrants),
|
||||||
...this.#dirtyRemovals,
|
...this.#dirtyRemovals,
|
||||||
]);
|
]);
|
||||||
for (const botId of candidates) {
|
for (const botId of candidates) {
|
||||||
|
|
@ -851,6 +913,7 @@ export class BotWorkspaceStore {
|
||||||
groupSessionScope: this.groupSessionScopeFor(bot.botId),
|
groupSessionScope: this.groupSessionScopeFor(bot.botId),
|
||||||
contextEnhancement: this.contextEnhancementFor(bot.botId),
|
contextEnhancement: this.contextEnhancementFor(bot.botId),
|
||||||
accessPolicy: this.accessPolicyFor(bot.botId),
|
accessPolicy: this.accessPolicyFor(bot.botId),
|
||||||
|
accessGrant: this.accessGrantFor(bot.botId),
|
||||||
}
|
}
|
||||||
: bot),
|
: bot),
|
||||||
};
|
};
|
||||||
|
|
@ -884,14 +947,16 @@ export class BotWorkspaceStore {
|
||||||
const hadContextEnhancement = Object.hasOwn(this.#contextEnhancement, id);
|
const hadContextEnhancement = Object.hasOwn(this.#contextEnhancement, id);
|
||||||
const hadDeliveryTargets = Object.hasOwn(this.#deliveryTargets, id);
|
const hadDeliveryTargets = Object.hasOwn(this.#deliveryTargets, id);
|
||||||
const hadAccessPolicy = Object.hasOwn(this.#accessPolicies, id);
|
const hadAccessPolicy = Object.hasOwn(this.#accessPolicies, id);
|
||||||
|
const hadAccessGrant = Object.hasOwn(this.#accessGrants, id);
|
||||||
const needsCleanup = hadWorkspace || hadPreset || hadGroupSessionScope || hadContextEnhancement
|
const needsCleanup = hadWorkspace || hadPreset || hadGroupSessionScope || hadContextEnhancement
|
||||||
|| hadDeliveryTargets || hadAccessPolicy || this.#dirtyRemovals.has(id);
|
|| hadDeliveryTargets || hadAccessPolicy || hadAccessGrant || this.#dirtyRemovals.has(id);
|
||||||
delete this.#workspaces[id];
|
delete this.#workspaces[id];
|
||||||
delete this.#agentPresets[id];
|
delete this.#agentPresets[id];
|
||||||
delete this.#groupSessionScopes[id];
|
delete this.#groupSessionScopes[id];
|
||||||
delete this.#contextEnhancement[id];
|
delete this.#contextEnhancement[id];
|
||||||
delete this.#deliveryTargets[id];
|
delete this.#deliveryTargets[id];
|
||||||
delete this.#accessPolicies[id];
|
delete this.#accessPolicies[id];
|
||||||
|
delete this.#accessGrants[id];
|
||||||
this.#generations.delete(id);
|
this.#generations.delete(id);
|
||||||
this.#incarnations.delete(id);
|
this.#incarnations.delete(id);
|
||||||
if (!needsCleanup) return {
|
if (!needsCleanup) return {
|
||||||
|
|
@ -935,6 +1000,7 @@ export class BotWorkspaceStore {
|
||||||
contextEnhancement,
|
contextEnhancement,
|
||||||
deliveryTargets,
|
deliveryTargets,
|
||||||
accessPolicies,
|
accessPolicies,
|
||||||
|
accessGrants: this.#accessGrants,
|
||||||
}));
|
}));
|
||||||
this.#dirtyRemovals.clear();
|
this.#dirtyRemovals.clear();
|
||||||
}
|
}
|
||||||
|
|
@ -945,7 +1011,8 @@ export class BotWorkspaceStore {
|
||||||
|| Object.keys(this.#groupSessionScopes).length > 0
|
|| Object.keys(this.#groupSessionScopes).length > 0
|
||||||
|| Object.keys(this.#contextEnhancement).length > 0
|
|| Object.keys(this.#contextEnhancement).length > 0
|
||||||
|| Object.keys(this.#deliveryTargets).length > 0
|
|| Object.keys(this.#deliveryTargets).length > 0
|
||||||
|| Object.keys(this.#accessPolicies).length > 0) {
|
|| Object.keys(this.#accessPolicies).length > 0
|
||||||
|
|| Object.keys(this.#accessGrants).length > 0) {
|
||||||
await this.#persist();
|
await this.#persist();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1521,6 +1588,54 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const updateAccessGrant = (botId, value, projectStatus) => {
|
||||||
|
const incarnation = workspaces.incarnationFor(botId);
|
||||||
|
const grant = validateAccessGrant(value);
|
||||||
|
return withBotTransition(botId, async () => {
|
||||||
|
const snapshot = await controller.status();
|
||||||
|
if (!snapshot?.bots?.some((bot) => bot?.botId === botId)) {
|
||||||
|
const error = new Error('找不到要修改的机器人。');
|
||||||
|
error.code = 'workspace-bot-not-found';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const catalog = await resolveAgentPresetCatalog(agentPresetCatalog);
|
||||||
|
const decorated = workspaces.decorateStatus(snapshot);
|
||||||
|
const updated = {
|
||||||
|
...decorated,
|
||||||
|
bots: decorated.bots.map((bot) => bot?.botId === botId
|
||||||
|
? { ...bot, accessGrant: grant } : bot),
|
||||||
|
...(catalog ? { agentPresetCatalog: catalog } : {}),
|
||||||
|
};
|
||||||
|
const result = projectStatus ? await projectStatus(updated) : updated;
|
||||||
|
await workspaces.setAccessGrant(botId, grant, { incarnation });
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const resolveAccessPendingProxy = (botId, payload, projectStatus) => {
|
||||||
|
const incarnation = workspaces.incarnationFor(botId);
|
||||||
|
return withBotTransition(botId, async () => {
|
||||||
|
const current = workspaces.accessGrantFor(botId) ?? emptyAccessGrant();
|
||||||
|
const { grant, pending } = resolveAccessPending(current, payload);
|
||||||
|
const snapshot = await controller.status();
|
||||||
|
if (!snapshot?.bots?.some((bot) => bot?.botId === botId)) {
|
||||||
|
const error = new Error('找不到要修改的机器人。');
|
||||||
|
error.code = 'workspace-bot-not-found';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const catalog = await resolveAgentPresetCatalog(agentPresetCatalog);
|
||||||
|
const decorated = workspaces.decorateStatus(snapshot);
|
||||||
|
const updated = {
|
||||||
|
...decorated,
|
||||||
|
bots: decorated.bots.map((bot) => bot?.botId === botId
|
||||||
|
? { ...bot, accessGrant: grant } : bot),
|
||||||
|
...(catalog ? { agentPresetCatalog: catalog } : {}),
|
||||||
|
resolvedPending: pending,
|
||||||
|
};
|
||||||
|
const result = projectStatus ? await projectStatus(updated) : updated;
|
||||||
|
await workspaces.setAccessGrant(botId, grant, { incarnation });
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
};
|
||||||
const deleteWithWorkspace = (botId, invokeDelete) => withBotTransition(botId, async () => {
|
const deleteWithWorkspace = (botId, invokeDelete) => withBotTransition(botId, async () => {
|
||||||
// Fence the old runtime without changing the durable mapping. A crash
|
// Fence the old runtime without changing the durable mapping. A crash
|
||||||
// before the controller removes its config therefore keeps the bot's
|
// before the controller removes its config therefore keeps the bot's
|
||||||
|
|
@ -1563,6 +1678,8 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
|
||||||
if (property === 'updateContextEnhancement') return updateContextEnhancement;
|
if (property === 'updateContextEnhancement') return updateContextEnhancement;
|
||||||
if (property === 'updateAccessPolicy') return updateAccessPolicy;
|
if (property === 'updateAccessPolicy') return updateAccessPolicy;
|
||||||
if (property === 'updateGroupSessionScope') return updateGroupSessionScope;
|
if (property === 'updateGroupSessionScope') return updateGroupSessionScope;
|
||||||
|
if (property === 'updateAccessGrant') return updateAccessGrant;
|
||||||
|
if (property === 'resolveAccessPending') return resolveAccessPendingProxy;
|
||||||
const value = Reflect.get(target, property, target);
|
const value = Reflect.get(target, property, target);
|
||||||
if (typeof value !== 'function') return value;
|
if (typeof value !== 'function') return value;
|
||||||
if (property === 'deleteBot') {
|
if (property === 'deleteBot') {
|
||||||
|
|
|
||||||
293
src/channels/whatsapp/whatsapp-access-gate.mjs
Normal file
293
src/channels/whatsapp/whatsapp-access-gate.mjs
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
/**
|
||||||
|
* WhatsApp ops access gate: phone grants, pending notify, quote approve/deny.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ACCESS_GRANT_COPY,
|
||||||
|
approverPhonesForPending,
|
||||||
|
attachPendingNotifyRefs,
|
||||||
|
emptyAccessGrant,
|
||||||
|
enqueueAccessPending,
|
||||||
|
ensureOwnerGlobalAdmin,
|
||||||
|
evaluateAccessGrant,
|
||||||
|
findPendingByNotifyMessageId,
|
||||||
|
formatPendingNotifyBody,
|
||||||
|
migrateAccessPolicyToGrant,
|
||||||
|
normalizeAccessPhone,
|
||||||
|
parseApprovalIntent,
|
||||||
|
parsePendingIdFromNotifyText,
|
||||||
|
phoneFromWhatsappJid,
|
||||||
|
resolveAccessPending,
|
||||||
|
upsertAccessContact,
|
||||||
|
} from '../shared/access-grant.mjs';
|
||||||
|
import { isSharedLocalCommand } from '../shared/command-permission.mjs';
|
||||||
|
import { isWhatsappLidJid } from './whatsapp-identity.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve canonical phone from an enriched inbound message.
|
||||||
|
* @param {object} message
|
||||||
|
* @returns {{ phone: string|null, lid: string|null }}
|
||||||
|
*/
|
||||||
|
export function resolveInboundPhone(message) {
|
||||||
|
const candidates = [
|
||||||
|
message?.senderId,
|
||||||
|
message?.senderAlternateId,
|
||||||
|
...(Array.isArray(message?.senderAliasIds) ? message.senderAliasIds : []),
|
||||||
|
];
|
||||||
|
let lid = null;
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof candidate !== 'string' || !candidate) continue;
|
||||||
|
if (isWhatsappLidJid(candidate)) {
|
||||||
|
lid = lid ?? candidate;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const phone = phoneFromWhatsappJid(candidate) ?? normalizeAccessPhone(candidate);
|
||||||
|
if (phone) return { phone, lid };
|
||||||
|
}
|
||||||
|
return { phone: null, lid };
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotedNotifyId(raw) {
|
||||||
|
const context = raw?.message?.extendedTextMessage?.contextInfo
|
||||||
|
?? raw?.message?.imageMessage?.contextInfo
|
||||||
|
?? null;
|
||||||
|
const stanzaId = typeof context?.stanzaId === 'string' ? context.stanzaId.trim() : '';
|
||||||
|
return stanzaId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotedNotifyText(raw) {
|
||||||
|
const quoted = raw?.message?.extendedTextMessage?.contextInfo?.quotedMessage;
|
||||||
|
if (!quoted) return '';
|
||||||
|
return quoted.conversation
|
||||||
|
?? quoted.extendedTextMessage?.text
|
||||||
|
?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load or seed the bot access grant document.
|
||||||
|
* @param {{ workspaces: object, botId: string, accountJid: string, accessPolicy?: object|null }} input
|
||||||
|
*/
|
||||||
|
export async function loadWhatsappAccessGrant({
|
||||||
|
workspaces,
|
||||||
|
botId,
|
||||||
|
accountJid,
|
||||||
|
accessPolicy = null,
|
||||||
|
}) {
|
||||||
|
const ownerPhone = phoneFromWhatsappJid(accountJid);
|
||||||
|
let grant = workspaces.accessGrantFor(botId);
|
||||||
|
if (!grant) {
|
||||||
|
grant = migrateAccessPolicyToGrant(
|
||||||
|
accessPolicy ?? workspaces.accessPolicyFor?.(botId),
|
||||||
|
null,
|
||||||
|
ownerPhone,
|
||||||
|
);
|
||||||
|
await workspaces.setAccessGrant(botId, grant);
|
||||||
|
return grant;
|
||||||
|
}
|
||||||
|
const seeded = ensureOwnerGlobalAdmin(grant, ownerPhone);
|
||||||
|
if (seeded !== grant && JSON.stringify(seeded) !== JSON.stringify(grant)) {
|
||||||
|
await workspaces.setAccessGrant(botId, seeded);
|
||||||
|
return seeded;
|
||||||
|
}
|
||||||
|
return grant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist contact sighting and return updated grant.
|
||||||
|
*/
|
||||||
|
export async function rememberWhatsappContact(workspaces, botId, grant, message, { phone, lid }) {
|
||||||
|
const pushName = message.contextSource?.()?.senderName;
|
||||||
|
const next = upsertAccessContact(grant, {
|
||||||
|
phone,
|
||||||
|
lid,
|
||||||
|
pushName,
|
||||||
|
scene: message.kind === 'group' ? 'group' : 'direct',
|
||||||
|
groupJid: message.kind === 'group' ? message.conversationId : undefined,
|
||||||
|
});
|
||||||
|
await workspaces.setAccessGrant(botId, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to handle an admin quote-reply approval before normal chat.
|
||||||
|
* @returns {Promise<boolean>} true if consumed
|
||||||
|
*/
|
||||||
|
export async function tryHandleWhatsappApprovalReply({
|
||||||
|
workspaces,
|
||||||
|
botId,
|
||||||
|
grant,
|
||||||
|
message,
|
||||||
|
raw,
|
||||||
|
sendText,
|
||||||
|
accountJid,
|
||||||
|
}) {
|
||||||
|
const { phone } = resolveInboundPhone(message);
|
||||||
|
if (!phone || message.kind !== 'direct') return false;
|
||||||
|
const intent = parseApprovalIntent(message.content);
|
||||||
|
if (!intent) return false;
|
||||||
|
|
||||||
|
const notifyId = quotedNotifyId(raw);
|
||||||
|
let pending = notifyId ? findPendingByNotifyMessageId(grant, notifyId) : null;
|
||||||
|
if (!pending) {
|
||||||
|
const pendingId = parsePendingIdFromNotifyText(quotedNotifyText(raw));
|
||||||
|
if (pendingId) {
|
||||||
|
pending = (grant.pending ?? []).find((entry) => entry.id === pendingId) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!pending || pending.status !== 'pending') return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { grant: next, pending: resolved } = resolveAccessPending(grant, {
|
||||||
|
pendingId: pending.id,
|
||||||
|
action: intent,
|
||||||
|
resolvedByPhone: phone,
|
||||||
|
});
|
||||||
|
await workspaces.setAccessGrant(botId, next);
|
||||||
|
await sendText(
|
||||||
|
{ jid: message.replyTarget?.jid ?? `${phone}@s.whatsapp.net` },
|
||||||
|
intent === 'approve' ? ACCESS_GRANT_COPY.adminApproved : ACCESS_GRANT_COPY.adminDenied,
|
||||||
|
);
|
||||||
|
const requesterJid = resolved.phone
|
||||||
|
? `${resolved.phone}@s.whatsapp.net`
|
||||||
|
: (resolved.lid ?? null);
|
||||||
|
if (requesterJid) {
|
||||||
|
const body = intent === 'approve'
|
||||||
|
? (resolved.kind === 'group'
|
||||||
|
? ACCESS_GRANT_COPY.approvedGroup
|
||||||
|
: ACCESS_GRANT_COPY.approvedDirect)
|
||||||
|
: ACCESS_GRANT_COPY.denied;
|
||||||
|
try {
|
||||||
|
await sendText({ jid: requesterJid }, body);
|
||||||
|
} catch {
|
||||||
|
// Requester notify is best-effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'pending-forbidden' || error?.code === 'pending-expired'
|
||||||
|
|| error?.code === 'pending-unresolved' || error?.code === 'pending-not-found') {
|
||||||
|
await sendText(
|
||||||
|
{ jid: message.replyTarget?.jid ?? `${phone}@s.whatsapp.net` },
|
||||||
|
error.message || '无法处理该审批。',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate grant; on deny create pending + notify admins.
|
||||||
|
* @returns {Promise<{ allowed: boolean, reason: string, grant: object, accessDecision?: object }>}
|
||||||
|
*/
|
||||||
|
export async function gateWhatsappInbound({
|
||||||
|
workspaces,
|
||||||
|
botId,
|
||||||
|
grant,
|
||||||
|
message,
|
||||||
|
sendText,
|
||||||
|
accountJid,
|
||||||
|
}) {
|
||||||
|
const { phone, lid } = resolveInboundPhone(message);
|
||||||
|
let current = await rememberWhatsappContact(workspaces, botId, grant, message, { phone, lid });
|
||||||
|
|
||||||
|
const scene = message.kind === 'group' ? 'group' : 'direct';
|
||||||
|
const isCommand = isSharedLocalCommand(message.content ?? '', {
|
||||||
|
hasImages: Array.isArray(message.images) && message.images.length > 0,
|
||||||
|
hasFiles: Array.isArray(message.files) && message.files.length > 0,
|
||||||
|
});
|
||||||
|
const decision = evaluateAccessGrant(current, {
|
||||||
|
scene,
|
||||||
|
phone,
|
||||||
|
groupJid: scene === 'group' ? message.conversationId : undefined,
|
||||||
|
isCommand,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (decision.allowed) {
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
reason: decision.reason,
|
||||||
|
grant: current,
|
||||||
|
accessDecision: {
|
||||||
|
allowed: true,
|
||||||
|
reason: decision.reason,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision.reason === 'command-not-allowed') {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: decision.reason,
|
||||||
|
grant: current,
|
||||||
|
accessDecision: {
|
||||||
|
allowed: false,
|
||||||
|
reason: 'command-not-allowed',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unaddressed group spam: do not create pending.
|
||||||
|
if (scene === 'group' && message.addressed !== true) {
|
||||||
|
return { allowed: false, reason: 'group-unaddressed', grant: current };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { grant: withPending, pending, created } = enqueueAccessPending(current, {
|
||||||
|
kind: scene,
|
||||||
|
groupJid: scene === 'group' ? message.conversationId : undefined,
|
||||||
|
phone: phone ?? '',
|
||||||
|
lid: lid ?? undefined,
|
||||||
|
pushName: message.contextSource?.()?.senderName,
|
||||||
|
requestText: message.content,
|
||||||
|
});
|
||||||
|
current = withPending;
|
||||||
|
await workspaces.setAccessGrant(botId, current);
|
||||||
|
|
||||||
|
const ack = !phone
|
||||||
|
? ACCESS_GRANT_COPY.pendingUnresolved
|
||||||
|
: (scene === 'group'
|
||||||
|
? ACCESS_GRANT_COPY.pendingAckGroup
|
||||||
|
: ACCESS_GRANT_COPY.pendingAckDirect);
|
||||||
|
try {
|
||||||
|
await sendText(message.replyTarget, ack);
|
||||||
|
} catch {
|
||||||
|
// Ack is best-effort.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (created) {
|
||||||
|
const admins = approverPhonesForPending(current, pending);
|
||||||
|
const body = formatPendingNotifyBody(pending);
|
||||||
|
const refs = [];
|
||||||
|
for (const adminPhone of admins) {
|
||||||
|
// Never DM the linked bot account as if it were a peer when it is the only self-chat path.
|
||||||
|
const jid = `${adminPhone}@s.whatsapp.net`;
|
||||||
|
try {
|
||||||
|
const result = await sendText({
|
||||||
|
jid,
|
||||||
|
selfChat: adminPhone === phoneFromWhatsappJid(accountJid),
|
||||||
|
}, body);
|
||||||
|
const providerMessageId = result?.providerMessageIds?.[0]
|
||||||
|
?? result?.key?.id
|
||||||
|
?? result?.providerMessageId
|
||||||
|
?? null;
|
||||||
|
if (providerMessageId) {
|
||||||
|
refs.push({ adminPhone, providerMessageId: String(providerMessageId) });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Continue notifying other admins.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (refs.length > 0) {
|
||||||
|
current = attachPendingNotifyRefs(current, pending.id, refs);
|
||||||
|
await workspaces.setAccessGrant(botId, current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: decision.reason,
|
||||||
|
grant: current,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { emptyAccessGrant, loadWhatsappAccessGrant as ensureWhatsappAccessGrant };
|
||||||
|
|
@ -13,6 +13,11 @@ import { ImagePromptError } from '../shared/image-prompt.mjs';
|
||||||
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
|
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
|
||||||
import { createWhatsappBridgeStatus, WhatsappHarnessBridge } from './whatsapp-bridge.mjs';
|
import { createWhatsappBridgeStatus, WhatsappHarnessBridge } from './whatsapp-bridge.mjs';
|
||||||
import { enrichWhatsappInboundIdentities } from './whatsapp-identity.mjs';
|
import { enrichWhatsappInboundIdentities } from './whatsapp-identity.mjs';
|
||||||
|
import {
|
||||||
|
gateWhatsappInbound,
|
||||||
|
loadWhatsappAccessGrant,
|
||||||
|
tryHandleWhatsappApprovalReply,
|
||||||
|
} from './whatsapp-access-gate.mjs';
|
||||||
import {
|
import {
|
||||||
WHATSAPP_ACCESS_MODES,
|
WHATSAPP_ACCESS_MODES,
|
||||||
} from './config-store.mjs';
|
} from './config-store.mjs';
|
||||||
|
|
@ -627,6 +632,8 @@ export class WhatsappRuntime {
|
||||||
#contextEnhancement;
|
#contextEnhancement;
|
||||||
#accessPolicy;
|
#accessPolicy;
|
||||||
#groupSessionScope;
|
#groupSessionScope;
|
||||||
|
#workspaces;
|
||||||
|
#botId;
|
||||||
#logger;
|
#logger;
|
||||||
#replyTimeoutMs;
|
#replyTimeoutMs;
|
||||||
#connectTimeoutMs;
|
#connectTimeoutMs;
|
||||||
|
|
@ -648,6 +655,8 @@ export class WhatsappRuntime {
|
||||||
contextEnhancement,
|
contextEnhancement,
|
||||||
accessPolicy,
|
accessPolicy,
|
||||||
groupSessionScope,
|
groupSessionScope,
|
||||||
|
workspaces,
|
||||||
|
botId,
|
||||||
logger = console,
|
logger = console,
|
||||||
replyTimeoutMs = 600_000,
|
replyTimeoutMs = 600_000,
|
||||||
connectTimeoutMs = 30_000,
|
connectTimeoutMs = 30_000,
|
||||||
|
|
@ -664,6 +673,8 @@ export class WhatsappRuntime {
|
||||||
this.#contextEnhancement = contextEnhancement;
|
this.#contextEnhancement = contextEnhancement;
|
||||||
this.#accessPolicy = accessPolicy;
|
this.#accessPolicy = accessPolicy;
|
||||||
this.#groupSessionScope = groupSessionScope;
|
this.#groupSessionScope = groupSessionScope;
|
||||||
|
this.#workspaces = workspaces;
|
||||||
|
this.#botId = botId;
|
||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
this.#replyTimeoutMs = replyTimeoutMs;
|
this.#replyTimeoutMs = replyTimeoutMs;
|
||||||
this.#connectTimeoutMs = connectTimeoutMs;
|
this.#connectTimeoutMs = connectTimeoutMs;
|
||||||
|
|
@ -724,6 +735,44 @@ export class WhatsappRuntime {
|
||||||
});
|
});
|
||||||
if (!message || outboundIds.has(message.providerMessageId) || !this.#bridge) return;
|
if (!message || outboundIds.has(message.providerMessageId) || !this.#bridge) return;
|
||||||
this.#status.lastCheckedAt = Date.now();
|
this.#status.lastCheckedAt = Date.now();
|
||||||
|
if (this.#workspaces && this.#botId) {
|
||||||
|
const sendText = (target, text) => this.#client.sendText(target, text);
|
||||||
|
let grant = await loadWhatsappAccessGrant({
|
||||||
|
workspaces: this.#workspaces,
|
||||||
|
botId: this.#botId,
|
||||||
|
accountJid: this.#config.accountJid,
|
||||||
|
accessPolicy: this.#accessPolicy?.getSettings?.() ?? null,
|
||||||
|
});
|
||||||
|
const handled = await tryHandleWhatsappApprovalReply({
|
||||||
|
workspaces: this.#workspaces,
|
||||||
|
botId: this.#botId,
|
||||||
|
grant,
|
||||||
|
message,
|
||||||
|
raw,
|
||||||
|
sendText,
|
||||||
|
accountJid: this.#config.accountJid,
|
||||||
|
});
|
||||||
|
if (handled) return;
|
||||||
|
grant = this.#workspaces.accessGrantFor(this.#botId) ?? grant;
|
||||||
|
const gated = await gateWhatsappInbound({
|
||||||
|
workspaces: this.#workspaces,
|
||||||
|
botId: this.#botId,
|
||||||
|
grant,
|
||||||
|
message,
|
||||||
|
sendText,
|
||||||
|
accountJid: this.#config.accountJid,
|
||||||
|
});
|
||||||
|
if (!gated.allowed) {
|
||||||
|
if (gated.accessDecision) {
|
||||||
|
await this.#bridge.accept(message, { accessDecision: gated.accessDecision });
|
||||||
|
} else if (!this.#state.hasSeen(message.messageId)) {
|
||||||
|
await this.#state.markSeen(message.messageId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.#bridge.accept(message, { accessDecision: gated.accessDecision });
|
||||||
|
return;
|
||||||
|
}
|
||||||
await this.#bridge.accept(message);
|
await this.#bridge.accept(message);
|
||||||
},
|
},
|
||||||
onDisconnect: ({ error }) => {
|
onDisconnect: ({ error }) => {
|
||||||
|
|
|
||||||
196
test/channels/shared/access-grant.test.mjs
Normal file
196
test/channels/shared/access-grant.test.mjs
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
approverPhonesForPending,
|
||||||
|
emptyAccessGrant,
|
||||||
|
enqueueAccessPending,
|
||||||
|
ensureOwnerGlobalAdmin,
|
||||||
|
evaluateAccessGrant,
|
||||||
|
findPendingByNotifyMessageId,
|
||||||
|
formatPendingNotifyBody,
|
||||||
|
migrateAccessPolicyToGrant,
|
||||||
|
normalizeAccessPhone,
|
||||||
|
parseApprovalIntent,
|
||||||
|
parsePendingIdFromNotifyText,
|
||||||
|
resolveAccessPending,
|
||||||
|
upsertAccessContact,
|
||||||
|
validateAccessGrant,
|
||||||
|
} from '../../../src/channels/shared/access-grant.mjs';
|
||||||
|
|
||||||
|
const GROUP_A = '120363111111111111@g.us';
|
||||||
|
const GROUP_B = '120363222222222222@g.us';
|
||||||
|
|
||||||
|
function grant(overrides = {}) {
|
||||||
|
return validateAccessGrant({
|
||||||
|
version: 1,
|
||||||
|
globalAdmins: ['8618111111111'],
|
||||||
|
directMembers: [],
|
||||||
|
groups: {},
|
||||||
|
pending: [],
|
||||||
|
contacts: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('normalizeAccessPhone accepts E.164, JIDs, and rejects LID servers', () => {
|
||||||
|
assert.equal(normalizeAccessPhone('+86 181-4238-7786'), '8618142387786');
|
||||||
|
assert.equal(normalizeAccessPhone('8618142387786@s.whatsapp.net'), '8618142387786');
|
||||||
|
assert.equal(normalizeAccessPhone('12345:12@c.us'), '12345');
|
||||||
|
assert.equal(normalizeAccessPhone('123456789012345@lid'), null);
|
||||||
|
assert.equal(normalizeAccessPhone('12'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureOwnerGlobalAdmin seeds linked account and refuses empty admins on validate', () => {
|
||||||
|
const seeded = ensureOwnerGlobalAdmin(null, '8618111111111@s.whatsapp.net');
|
||||||
|
assert.deepEqual(seeded.globalAdmins, ['8618111111111']);
|
||||||
|
assert.throws(
|
||||||
|
() => validateAccessGrant({ ...emptyAccessGrant(), globalAdmins: [] }),
|
||||||
|
/全局管理员/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('migrateAccessPolicyToGrant copies allowlist phones into directMembers only', () => {
|
||||||
|
const migrated = migrateAccessPolicyToGrant({
|
||||||
|
direct: { allowlist: { users: [{ id: '8618222222222@s.whatsapp.net', canExecuteCommands: true }] } },
|
||||||
|
group: { allowlist: { users: [{ id: '+86 183-3333-3333', canExecuteCommands: false }] } },
|
||||||
|
}, null, '8618111111111');
|
||||||
|
assert.deepEqual(migrated.globalAdmins, ['8618111111111']);
|
||||||
|
assert.deepEqual(
|
||||||
|
migrated.directMembers.map((m) => m.phone).sort(),
|
||||||
|
['8618222222222', '8618333333333'],
|
||||||
|
);
|
||||||
|
assert.equal(Object.keys(migrated.groups).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('evaluateAccessGrant keeps DM and group scopes isolated', () => {
|
||||||
|
const doc = grant({
|
||||||
|
directMembers: [{ phone: '8618222222222', canExecuteCommands: true }],
|
||||||
|
groups: {
|
||||||
|
[GROUP_A]: {
|
||||||
|
admins: ['8618333333333'],
|
||||||
|
members: [{ phone: '8618444444444', canExecuteCommands: false }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(evaluateAccessGrant(doc, {
|
||||||
|
scene: 'direct', phone: '8618111111111',
|
||||||
|
}).reason, 'global-admin');
|
||||||
|
assert.equal(evaluateAccessGrant(doc, {
|
||||||
|
scene: 'direct', phone: '8618222222222',
|
||||||
|
}).allowed, true);
|
||||||
|
assert.equal(evaluateAccessGrant(doc, {
|
||||||
|
scene: 'direct', phone: '8618333333333',
|
||||||
|
}).allowed, false);
|
||||||
|
assert.equal(evaluateAccessGrant(doc, {
|
||||||
|
scene: 'group', phone: '8618444444444', groupJid: GROUP_A,
|
||||||
|
}).reason, 'group-member');
|
||||||
|
assert.equal(evaluateAccessGrant(doc, {
|
||||||
|
scene: 'group', phone: '8618444444444', groupJid: GROUP_B,
|
||||||
|
}).allowed, false);
|
||||||
|
assert.equal(evaluateAccessGrant(doc, {
|
||||||
|
scene: 'group', phone: '8618222222222', groupJid: GROUP_A,
|
||||||
|
}).allowed, false);
|
||||||
|
assert.equal(evaluateAccessGrant(doc, {
|
||||||
|
scene: 'group',
|
||||||
|
phone: '8618444444444',
|
||||||
|
groupJid: GROUP_A,
|
||||||
|
isCommand: true,
|
||||||
|
}).reason, 'command-not-allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pending notify falls back to global admins when group has no admins', () => {
|
||||||
|
const doc = grant({
|
||||||
|
groups: {
|
||||||
|
[GROUP_A]: { admins: [], members: [] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
approverPhonesForPending(doc, { kind: 'group', groupJid: GROUP_A }),
|
||||||
|
['8618111111111'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
approverPhonesForPending(grant({
|
||||||
|
groups: { [GROUP_A]: { admins: ['8618555555555'], members: [] } },
|
||||||
|
}), { kind: 'group', groupJid: GROUP_A }),
|
||||||
|
['8618555555555'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enqueue + approve places member into the matching grant bucket', () => {
|
||||||
|
let doc = grant();
|
||||||
|
const { grant: withPending, pending, created } = enqueueAccessPending(doc, {
|
||||||
|
kind: 'group',
|
||||||
|
groupJid: GROUP_A,
|
||||||
|
phone: '8618666666666',
|
||||||
|
pushName: 'Alice',
|
||||||
|
requestText: 'hello',
|
||||||
|
}, { idFactory: () => 'p_test_approve_1' });
|
||||||
|
assert.equal(created, true);
|
||||||
|
assert.equal(pending.id, 'p_test_approve_1');
|
||||||
|
doc = withPending;
|
||||||
|
|
||||||
|
const reused = enqueueAccessPending(doc, {
|
||||||
|
kind: 'group',
|
||||||
|
groupJid: GROUP_A,
|
||||||
|
phone: '8618666666666',
|
||||||
|
});
|
||||||
|
assert.equal(reused.created, false);
|
||||||
|
|
||||||
|
const { grant: approved } = resolveAccessPending(doc, {
|
||||||
|
pendingId: pending.id,
|
||||||
|
action: 'approve',
|
||||||
|
resolvedByPhone: '8618111111111',
|
||||||
|
});
|
||||||
|
assert.equal(approved.groups[GROUP_A].members[0].phone, '8618666666666');
|
||||||
|
assert.equal(approved.directMembers.length, 0);
|
||||||
|
assert.equal(approved.pending[0].status, 'approved');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('contacts upsert merges lid and phone sightings', () => {
|
||||||
|
let doc = grant();
|
||||||
|
doc = upsertAccessContact(doc, {
|
||||||
|
lid: '999@lid',
|
||||||
|
pushName: 'Bob',
|
||||||
|
scene: 'group',
|
||||||
|
groupJid: GROUP_A,
|
||||||
|
});
|
||||||
|
doc = upsertAccessContact(doc, {
|
||||||
|
phone: '8618777777777',
|
||||||
|
lid: '999@lid',
|
||||||
|
pushName: 'Bobby',
|
||||||
|
scene: 'direct',
|
||||||
|
});
|
||||||
|
assert.equal(doc.contacts.length, 1);
|
||||||
|
assert.equal(doc.contacts[0].phone, '8618777777777');
|
||||||
|
assert.deepEqual(doc.contacts[0].lids, ['999@lid']);
|
||||||
|
assert.deepEqual(doc.contacts[0].scenes.slice().sort(), ['direct', 'group']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quote approval helpers parse intent, pending id, and notify refs', () => {
|
||||||
|
assert.equal(parseApprovalIntent('同意 顺便说一声'), 'approve');
|
||||||
|
assert.equal(parseApprovalIntent('拒绝'), 'deny');
|
||||||
|
assert.equal(parseApprovalIntent('maybe later'), null);
|
||||||
|
const body = formatPendingNotifyBody({
|
||||||
|
id: 'p_abc123',
|
||||||
|
kind: 'direct',
|
||||||
|
phone: '8618888888888',
|
||||||
|
pushName: 'Carol',
|
||||||
|
requestText: 'hi',
|
||||||
|
});
|
||||||
|
assert.equal(parsePendingIdFromNotifyText(body), 'p_abc123');
|
||||||
|
const withRef = {
|
||||||
|
...grant(),
|
||||||
|
pending: [{
|
||||||
|
id: 'p_abc123',
|
||||||
|
kind: 'direct',
|
||||||
|
phone: '8618888888888',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
status: 'pending',
|
||||||
|
unresolved: false,
|
||||||
|
notifyRefs: [{ adminPhone: '8618111111111', providerMessageId: 'MSG1' }],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
assert.equal(findPendingByNotifyMessageId(withRef, 'MSG1')?.id, 'p_abc123');
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue