dsh-im-ops/src/channels/shared/access-grant.mjs
oliver bcbe85fdb1
Some checks failed
CI / Test, build, and verify package (push) Has been cancelled
Make WhatsApp member presets follow channel mapping by default.
Empty member/group Agent Preset now resolves to the bot workspace mapping (not Host global); settings label is Follow channel mapping, and /new createSession carries that mapping explicitly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 23:11:33 +08:00

815 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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}(?:-\d{1,32})?@g\.us$/;
const AGENT_PRESET_ID = /^[a-z0-9][a-z0-9-]*$/;
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
/** @typedef {'direct' | 'group'} AccessGrantScene */
/** @typedef {'approve' | 'deny'} AccessPendingAction */
function normalizeOptionalAgentPresetId(value) {
if (typeof value !== 'string') return null;
const id = value.trim();
return AGENT_PRESET_ID.test(id) ? id : null;
}
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, agentPreset?: string }}
*/
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);
const agentPreset = optionalAgentPreset(input.agentPreset);
return Object.freeze({
phone,
canExecuteCommands,
...(agentPreset ? { agentPreset } : {}),
});
}
/**
* Optional Agent Preset override. Empty/null means follow the bot-level preset.
* @param {unknown} value
* @returns {string|undefined}
*/
function optionalAgentPreset(value) {
if (value == null || value === '') return undefined;
const id = normalizeOptionalAgentPresetId(value);
if (!id) invalidGrant('Agent Preset 无效。');
return id;
}
/**
* @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) : '';
const agentPreset = optionalAgentPreset(input.agentPreset);
return Object.freeze({
...(title ? { title } : {}),
...(agentPreset ? { agentPreset } : {}),
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;
}
}
/**
* Resolve a chat-scoped Agent Preset override from the access grant.
* Returns null when the chat should follow the bot/channel mapping preset
* (workspaces.agentPresets[botId]), not the Host catalog default.
* Priority: direct-member or group override > bot mapping (caller applies).
* @param {unknown} grant
* @param {{ kind?: string, phone?: string, senderId?: string, groupJid?: string, conversationId?: string }} [context]
* @returns {string|null}
*/
export function resolveAccessAgentPreset(grant, context = {}) {
const doc = normalizeAccessGrant(grant);
if (!doc) return null;
if (context.kind === 'group') {
const groupJid = typeof context.groupJid === 'string' && context.groupJid.trim()
? context.groupJid.trim()
: typeof context.conversationId === 'string' ? context.conversationId.trim() : '';
if (!groupJid || !ACCESS_GRANT_GROUP_JID_PATTERN.test(groupJid)) return null;
return normalizeOptionalAgentPresetId(doc.groups[groupJid]?.agentPreset) ?? null;
}
const phone = normalizeAccessPhone(context.phone ?? context.senderId);
if (!phone) return null;
const member = doc.directMembers.find((entry) => entry.phone === phone);
return normalizeOptionalAgentPresetId(member?.agentPreset) ?? null;
}
/**
* Member/group override, else the bot's workspace-mapped preset.
* Does not fall through to Host catalog default — that is only used when the
* bot mapping itself is empty (createSession omits agentPreset).
* @param {unknown} grant
* @param {{ kind?: string, phone?: string, senderId?: string, groupJid?: string, conversationId?: string }} [context]
* @param {string|null|undefined} botAgentPreset
* @returns {string|null}
*/
export function resolveChatAgentPreset(grant, context = {}, botAgentPreset = null) {
const fromGrant = resolveAccessAgentPreset(grant, context);
if (fromGrant) return fromGrant;
if (typeof botAgentPreset === 'string') {
const trimmed = botAgentPreset.trim();
if (trimmed) return trimmed;
}
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 keys (Chinese source literals for host `t()`).
* Callers must pass through `t()` after `setImHostLanguage` — do not send raw.
*/
export const ACCESS_GRANT_COPY = Object.freeze({
pendingAckDirect: '已提交私聊访问申请,请等待全局管理员审批。',
pendingAckGroup: '已提交本群访问申请,请等待管理员审批。',
pendingUnresolved: '已记录访问申请,但尚未解析到电话号码;请管理员在设置中补全后再批准。',
notifyTitle: '[dsh-im-ops] 访问申请',
approvedDirect: '私聊访问已批准,现在可以直接对话。',
approvedGroup: '本群访问已批准,请在本群 @ 机器人继续。',
denied: '访问申请未通过。',
adminApproved: '已批准该访问申请。',
adminDenied: '已拒绝该访问申请。',
processFailed: '无法处理该审批。',
});
/** Identity translator that still fills `{name}` placeholders (zh default). */
function fillPlaceholders(text, params) {
if (typeof text !== 'string' || params == null) return text;
return text.replace(/\{(\w+)\}/g, (match, name) =>
Object.hasOwn(params, name) ? String(params[name]) : match);
}
/**
* @param {object} pending
* @param {(text: string, params?: Record<string, unknown>) => string} [translate]
* @returns {string}
*/
export function formatPendingNotifyBody(pending, translate = fillPlaceholders) {
const scene = pending.kind === 'group'
? translate('群聊 {jid}', { jid: pending.groupJid ?? '' })
: translate('私聊');
const unknown = translate('未知');
const who = pending.pushName
? `${pending.pushName} (${pending.phone || pending.lid || unknown})`
: (pending.phone || pending.lid || unknown);
const text = pending.requestText
? `\n${translate('原文:{text}', { text: pending.requestText.slice(0, 200) })}`
: '';
return [
translate(ACCESS_GRANT_COPY.notifyTitle),
translate('场景:{scene}', { scene }),
translate('申请人:{who}', { who }),
translate('请求编号:{id}', { id: pending.id }),
translate('请引用本条消息回复「同意」或「拒绝」。'),
text,
].filter(Boolean).join('\n');
}