mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-08 22:00:50 +08:00
Some checks failed
CI / Test, build, and verify package (push) Has been cancelled
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>
815 lines
28 KiB
JavaScript
815 lines
28 KiB
JavaScript
/**
|
||
* 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');
|
||
}
|