feat: añadir ContactsService y usar nombres en menciones de usuarios
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>pull/1/head
parent
7a901c9d95
commit
3ff63f1503
@ -0,0 +1,158 @@
|
|||||||
|
import { normalizeWhatsAppId, isUserJid } from '../utils/whatsapp';
|
||||||
|
|
||||||
|
type CacheEntry = {
|
||||||
|
name: string;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ContactsService {
|
||||||
|
// Caché en memoria: userId(normalizado, solo dígitos) -> nombre
|
||||||
|
private static readonly cache = new Map<string, CacheEntry>();
|
||||||
|
private static readonly TTL_MS = 12 * 60 * 60 * 1000; // 12h
|
||||||
|
|
||||||
|
private static getHeaders(): HeadersInit {
|
||||||
|
return {
|
||||||
|
apikey: process.env.EVOLUTION_API_KEY || '',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static now() {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static extractName(obj: any): string | null {
|
||||||
|
if (!obj || typeof obj !== 'object') return null;
|
||||||
|
// Intentar múltiples campos posibles que suelen aparecer en catálogos/contactos
|
||||||
|
const candidates = [
|
||||||
|
obj.name,
|
||||||
|
obj.pushname,
|
||||||
|
obj.verifiedName,
|
||||||
|
obj.notify,
|
||||||
|
obj.shortName,
|
||||||
|
obj.displayName,
|
||||||
|
obj.formattedName,
|
||||||
|
obj.subject, // a veces para grupos; pero evitaremos grupos abajo
|
||||||
|
obj.contactName
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
const name = candidates.find(v => typeof v === 'string' && v.trim().length > 0);
|
||||||
|
return name ? name.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static updateFromWebhook(data: any): void {
|
||||||
|
try {
|
||||||
|
// Aceptar varios formatos posibles
|
||||||
|
const tryArrays: any[] = [];
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
tryArrays.push(data);
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
for (const key of ['contacts', 'chats', 'data', 'payload', 'updates', 'results']) {
|
||||||
|
if (Array.isArray((data as any)[key])) {
|
||||||
|
tryArrays.push((data as any)[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Algunos eventos pueden traer una sola entrada
|
||||||
|
tryArrays.push([data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const arr of tryArrays) {
|
||||||
|
for (const rec of arr) {
|
||||||
|
const idCandidate = rec?.id ?? rec?.jid ?? rec?.user ?? rec?.remoteJid ?? rec?.wid ?? rec?.wid?._serialized;
|
||||||
|
if (!idCandidate) continue;
|
||||||
|
|
||||||
|
// Evitar grupos
|
||||||
|
if (typeof idCandidate === 'string' && idCandidate.endsWith('@g.us')) continue;
|
||||||
|
|
||||||
|
const normalized = normalizeWhatsAppId(String(idCandidate));
|
||||||
|
if (!normalized) continue;
|
||||||
|
|
||||||
|
const name = this.extractName(rec);
|
||||||
|
if (!name) continue;
|
||||||
|
|
||||||
|
this.cache.set(normalized, { name, expiresAt: this.now() + this.TTL_MS });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
console.warn('ContactsService.updateFromWebhook: parse error', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async maybeFetchFromApi(digitsId: string): Promise<string | null> {
|
||||||
|
const baseUrl = process.env.EVOLUTION_API_URL;
|
||||||
|
const instance = process.env.EVOLUTION_API_INSTANCE;
|
||||||
|
const apikey = process.env.EVOLUTION_API_KEY;
|
||||||
|
|
||||||
|
if (!baseUrl || !instance || !apikey) return null;
|
||||||
|
|
||||||
|
const url = `${baseUrl}/chat/findContacts/${instance}`;
|
||||||
|
const body = {
|
||||||
|
where: { id: `${digitsId}@s.whatsapp.net` }
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
const txt = await res.text().catch(() => '');
|
||||||
|
console.warn('ContactsService.findContacts non-OK:', res.status, txt?.slice(0, 200));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null as any);
|
||||||
|
const arrayCandidates: any[] = Array.isArray(data)
|
||||||
|
? data
|
||||||
|
: Array.isArray((data as any)?.contacts)
|
||||||
|
? (data as any).contacts
|
||||||
|
: Array.isArray((data as any)?.data)
|
||||||
|
? (data as any).data
|
||||||
|
: Array.isArray((data as any)?.result)
|
||||||
|
? (data as any).result
|
||||||
|
: (data && typeof data === 'object')
|
||||||
|
? [data]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
let name: string | null = null;
|
||||||
|
for (const rec of arrayCandidates) {
|
||||||
|
const recId = rec?.id ?? rec?.jid ?? rec?.user ?? rec?.remoteJid ?? rec?.wid ?? rec?.wid?._serialized;
|
||||||
|
if (!recId) continue;
|
||||||
|
const norm = normalizeWhatsAppId(String(recId));
|
||||||
|
if (!norm || norm !== digitsId) continue;
|
||||||
|
name = this.extractName(rec);
|
||||||
|
if (name) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
this.cache.set(digitsId, { name, expiresAt: this.now() + this.TTL_MS });
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
console.warn('ContactsService.findContacts error:', e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getDisplayName(idOrJid: string | null | undefined): Promise<string | null> {
|
||||||
|
const normalized = normalizeWhatsAppId(idOrJid || '');
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
const cached = this.cache.get(normalized);
|
||||||
|
if (cached && cached.expiresAt > this.now()) {
|
||||||
|
return cached.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intento de fetch perezoso
|
||||||
|
const fetched = await this.maybeFetchFromApi(normalized);
|
||||||
|
return fetched || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue