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:
oliver 2026-09-05 16:45:21 +08:00
parent e5e1e6573a
commit 9f97c44031
15 changed files with 3146 additions and 327 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
{
"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.",
"keywords": [
"deepseek-harness",

View 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 ? '正在保存…' : '保存分级访问设置')));
}

View file

@ -1,6 +1,7 @@
import * as React from 'react';
import { AccessPolicySettingsPage } from './access-policy-settings.js';
import { AccessGrantSettingsPage } from './access-grant-settings.js';
import { h, isEnglish, localizeText } from './i18n.js';
export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
@ -557,11 +558,13 @@ export function DeliveryTargetSettingsPage({
const [saving, setSaving] = React.useState(false);
const [botCopyState, setBotCopyState] = React.useState(null);
const [accessPolicy, setAccessPolicy] = React.useState(account.accessPolicy);
const [accessGrant, setAccessGrant] = React.useState(account.accessGrant);
const mounted = React.useRef(true);
React.useEffect(() => {
setAccessPolicy(account.accessPolicy);
}, [account.botId, account.accessPolicy]);
setAccessGrant(account.accessGrant);
}, [account.botId, account.accessPolicy, account.accessGrant]);
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
if (typeof rpcCall !== 'function') throw new Error('投递目标设置暂不可用。');
@ -706,12 +709,23 @@ export function DeliveryTargetSettingsPage({
'aria-labelledby': activeTabDomId,
},
activeTab.id === 'access'
? h(AccessPolicySettingsPage, {
? (channel === 'whatsapp'
? h(AccessGrantSettingsPage, {
channel,
account: {
...account,
accessGrant,
groupSessionScope: account.groupSessionScope,
},
rpcCall: accessRpcCall,
onSaved: setAccessGrant,
})
: h(AccessPolicySettingsPage, {
channel,
account: { ...account, accessPolicy },
rpcCall: accessRpcCall,
onSaved: setAccessPolicy,
})
}))
: h(React.Fragment, null,
h('section', { className: 'dim-deliveryIdentity', 'aria-labelledby': 'dim-delivery-bot-title' },
h('div', { className: 'dim-deliveryIdentityHeading' },

View file

@ -11,6 +11,49 @@ const EN = Object.freeze({
'机器人设置页签': 'Bot settings tabs',
'投递设置': 'Delivery 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',
'允许所有用户': 'Allow all users',
'仅白名单用户': 'Allowlisted users only',

View file

@ -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="error"] { color: var(--dsw-alias-state-error-primary, #d54941); }
.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 .ddt-headingCopy { display: none; }
.dim-panel .ddt-qrFrame, .dim-panel .ddt-countdown { width: min(270px, 100%); }

View 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] } : {}),
})),
};
}

View file

@ -123,6 +123,8 @@ export async function createProductionController(ctx, config = {}, internals = {
channel: 'whatsapp', config: botConfig, equals: whatsappAccessPolicyIdsEqual,
}),
groupSessionScope: { getScope: () => workspaces.groupSessionScopeFor(botId) },
workspaces,
botId,
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
connectTimeoutMs: config.connectTimeoutMs ?? 30_000,
createSession,

View file

@ -2,6 +2,12 @@ import QRCode from 'qrcode';
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.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_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
@ -17,6 +23,8 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setAccessPolicy: SET_ACCESS_POLICY_ENDPOINT,
setAccessGrant: SET_ACCESS_GRANT_ENDPOINT,
resolveAccessPending: RESOLVE_ACCESS_PENDING_ENDPOINT,
setGroupSessionScope: SET_GROUP_SESSION_SCOPE_ENDPOINT,
setWorkspace: SET_WORKSPACE_ENDPOINT,
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
@ -60,6 +68,14 @@ function payloadFailure(endpoint, payload) {
return validAccessPolicyPayload(payload)
? null : '请提交有效的访问设置。';
}
if (endpoint === WHATSAPP_ENDPOINTS.setAccessGrant) {
return validAccessGrantPayload(payload)
? null : '请提交有效的分级访问授权。';
}
if (endpoint === WHATSAPP_ENDPOINTS.resolveAccessPending) {
return validAccessPendingResolvePayload(payload)
? null : '请提交有效的审批请求。';
}
if (endpoint === WHATSAPP_ENDPOINTS.setGroupSessionScope) {
return validGroupSessionScopePayload(payload)
? null : '请提交有效的群会话策略。';
@ -196,6 +212,24 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
payload.policy,
(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) {
if (typeof controller.updateGroupSessionScope !== 'function') throw new Error('Group session scope update is unavailable');
value = await controller.updateGroupSessionScope(

View 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');
}

View file

@ -22,6 +22,12 @@ import {
normalizeGroupSessionScope,
validateGroupSessionScope,
} from './session-scope.mjs';
import {
emptyAccessGrant,
normalizeAccessGrant,
resolveAccessPending,
validateAccessGrant,
} from './access-grant.mjs';
import { CONNECTION_TEST_STATE_IDENTITY } from './connection-test.mjs';
import {
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
@ -211,7 +217,17 @@ function normalizeDocument(value) {
const deliveryTargets = normalizeDeliveryTargets(value.deliveryTargets);
if (!deliveryTargets) return null;
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 {
// 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.
@ -222,6 +238,7 @@ function normalizeDocument(value) {
contextEnhancement,
deliveryTargets,
accessPolicies,
accessGrants,
};
}
@ -233,6 +250,7 @@ function storedDocument({
contextEnhancement,
deliveryTargets,
accessPolicies,
accessGrants,
}) {
const document = { version, workspaces };
if (Object.keys(agentPresets).length > 0) document.agentPresets = agentPresets;
@ -246,6 +264,9 @@ function storedDocument({
if (version >= 2 && Object.keys(accessPolicies).length > 0) {
document.accessPolicies = accessPolicies;
}
if (version >= 2 && Object.keys(accessGrants).length > 0) {
document.accessGrants = accessGrants;
}
return document;
}
@ -292,6 +313,7 @@ export class BotWorkspaceStore {
#contextEnhancement = {};
#deliveryTargets = Object.create(null);
#accessPolicies = Object.create(null);
#accessGrants = Object.create(null);
#generations = new Map();
#nextGeneration = 1;
#incarnations = new Map();
@ -319,6 +341,7 @@ export class BotWorkspaceStore {
this.#contextEnhancement = normalized.contextEnhancement;
this.#deliveryTargets = normalized.deliveryTargets;
this.#accessPolicies = normalized.accessPolicies;
this.#accessGrants = normalized.accessGrants;
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
this.#version = 1;
@ -328,6 +351,7 @@ export class BotWorkspaceStore {
this.#contextEnhancement = {};
this.#deliveryTargets = Object.create(null);
this.#accessPolicies = Object.create(null);
this.#accessGrants = Object.create(null);
}
this.#generations.clear();
this.#nextGeneration = 1;
@ -381,6 +405,14 @@ export class BotWorkspaceStore {
: null;
}
accessGrantFor(botId) {
const id = botIdOf(botId);
if (this.has(id) && Object.hasOwn(this.#accessGrants, id)) {
return this.#accessGrants[id];
}
return null;
}
listDeliveryTargets(botId) {
const id = botIdOf(botId);
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, {
conversationKey,
sessionId,
@ -832,6 +893,7 @@ export class BotWorkspaceStore {
...Object.keys(this.#contextEnhancement),
...Object.keys(this.#deliveryTargets),
...Object.keys(this.#accessPolicies),
...Object.keys(this.#accessGrants),
...this.#dirtyRemovals,
]);
for (const botId of candidates) {
@ -851,6 +913,7 @@ export class BotWorkspaceStore {
groupSessionScope: this.groupSessionScopeFor(bot.botId),
contextEnhancement: this.contextEnhancementFor(bot.botId),
accessPolicy: this.accessPolicyFor(bot.botId),
accessGrant: this.accessGrantFor(bot.botId),
}
: bot),
};
@ -884,14 +947,16 @@ export class BotWorkspaceStore {
const hadContextEnhancement = Object.hasOwn(this.#contextEnhancement, id);
const hadDeliveryTargets = Object.hasOwn(this.#deliveryTargets, id);
const hadAccessPolicy = Object.hasOwn(this.#accessPolicies, id);
const hadAccessGrant = Object.hasOwn(this.#accessGrants, id);
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.#agentPresets[id];
delete this.#groupSessionScopes[id];
delete this.#contextEnhancement[id];
delete this.#deliveryTargets[id];
delete this.#accessPolicies[id];
delete this.#accessGrants[id];
this.#generations.delete(id);
this.#incarnations.delete(id);
if (!needsCleanup) return {
@ -935,6 +1000,7 @@ export class BotWorkspaceStore {
contextEnhancement,
deliveryTargets,
accessPolicies,
accessGrants: this.#accessGrants,
}));
this.#dirtyRemovals.clear();
}
@ -945,7 +1011,8 @@ export class BotWorkspaceStore {
|| Object.keys(this.#groupSessionScopes).length > 0
|| Object.keys(this.#contextEnhancement).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();
return;
}
@ -1521,6 +1588,54 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
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 () => {
// Fence the old runtime without changing the durable mapping. A crash
// 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 === 'updateAccessPolicy') return updateAccessPolicy;
if (property === 'updateGroupSessionScope') return updateGroupSessionScope;
if (property === 'updateAccessGrant') return updateAccessGrant;
if (property === 'resolveAccessPending') return resolveAccessPendingProxy;
const value = Reflect.get(target, property, target);
if (typeof value !== 'function') return value;
if (property === 'deleteBot') {

View 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 };

View file

@ -13,6 +13,11 @@ import { ImagePromptError } from '../shared/image-prompt.mjs';
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
import { createWhatsappBridgeStatus, WhatsappHarnessBridge } from './whatsapp-bridge.mjs';
import { enrichWhatsappInboundIdentities } from './whatsapp-identity.mjs';
import {
gateWhatsappInbound,
loadWhatsappAccessGrant,
tryHandleWhatsappApprovalReply,
} from './whatsapp-access-gate.mjs';
import {
WHATSAPP_ACCESS_MODES,
} from './config-store.mjs';
@ -627,6 +632,8 @@ export class WhatsappRuntime {
#contextEnhancement;
#accessPolicy;
#groupSessionScope;
#workspaces;
#botId;
#logger;
#replyTimeoutMs;
#connectTimeoutMs;
@ -648,6 +655,8 @@ export class WhatsappRuntime {
contextEnhancement,
accessPolicy,
groupSessionScope,
workspaces,
botId,
logger = console,
replyTimeoutMs = 600_000,
connectTimeoutMs = 30_000,
@ -664,6 +673,8 @@ export class WhatsappRuntime {
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#groupSessionScope = groupSessionScope;
this.#workspaces = workspaces;
this.#botId = botId;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
@ -724,6 +735,44 @@ export class WhatsappRuntime {
});
if (!message || outboundIds.has(message.providerMessageId) || !this.#bridge) return;
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);
},
onDisconnect: ({ error }) => {

View 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');
});