diff --git a/src/server.ts b/src/server.ts index d62d236..31488b0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,7 @@ import { TaskService } from './tasks/service'; import { WebhookManager } from './services/webhook-manager'; import { normalizeWhatsAppId } from './utils/whatsapp'; import { ensureUserExists, db, initializeDatabase } from './db'; +import { ContactsService } from './services/contacts'; // Bun is available globally when running under Bun runtime declare global { @@ -95,6 +96,13 @@ export class WebhookServer { } await WebhookServer.handleMessageUpsert(payload.data); break; + case 'contacts.update': + case 'chats.update': + if (process.env.NODE_ENV !== 'test') { + console.log('ℹ️ Handling contacts/chats update event:', { rawEvent: evt }); + } + ContactsService.updateFromWebhook(payload.data); + break; // Other events will be added later } diff --git a/src/services/command.ts b/src/services/command.ts index f610fb5..87adc72 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -3,6 +3,7 @@ import { db, ensureUserExists } from '../db'; import { normalizeWhatsAppId } from '../utils/whatsapp'; import { TaskService } from '../tasks/service'; import { GroupSyncService } from './group-sync'; +import { ContactsService } from './contacts'; type CommandContext = { sender: string; // normalized user id (digits only), but accept raw too @@ -152,7 +153,13 @@ export class CommandService { const mentionsForSending = assignmentUserIds.map(uid => `${uid}@s.whatsapp.net`); - const assignedList = assignmentUserIds.map(uid => `@${uid}`).join(' '); + const assignedLabels = await Promise.all( + assignmentUserIds.map(async uid => { + const name = await ContactsService.getDisplayName(uid); + return `@${name || uid}`; + }) + ); + const assignedList = assignedLabels.join(' '); const resp = `✅ Tarea ${taskId} creada: "${description || '(sin descripción)'}"` + (dueDate ? ` (vence ${dueDate})` : '') + diff --git a/src/services/contacts.ts b/src/services/contacts.ts new file mode 100644 index 0000000..abfe374 --- /dev/null +++ b/src/services/contacts.ts @@ -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(); + 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 { + 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 { + 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; + } +}