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

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 }) => {