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
borja 2 months ago
parent 7a901c9d95
commit 3ff63f1503

@ -7,6 +7,7 @@ import { TaskService } from './tasks/service';
import { WebhookManager } from './services/webhook-manager'; import { WebhookManager } from './services/webhook-manager';
import { normalizeWhatsAppId } from './utils/whatsapp'; import { normalizeWhatsAppId } from './utils/whatsapp';
import { ensureUserExists, db, initializeDatabase } from './db'; import { ensureUserExists, db, initializeDatabase } from './db';
import { ContactsService } from './services/contacts';
// Bun is available globally when running under Bun runtime // Bun is available globally when running under Bun runtime
declare global { declare global {
@ -95,6 +96,13 @@ export class WebhookServer {
} }
await WebhookServer.handleMessageUpsert(payload.data); await WebhookServer.handleMessageUpsert(payload.data);
break; 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 // Other events will be added later
} }

@ -3,6 +3,7 @@ import { db, ensureUserExists } from '../db';
import { normalizeWhatsAppId } from '../utils/whatsapp'; import { normalizeWhatsAppId } from '../utils/whatsapp';
import { TaskService } from '../tasks/service'; import { TaskService } from '../tasks/service';
import { GroupSyncService } from './group-sync'; import { GroupSyncService } from './group-sync';
import { ContactsService } from './contacts';
type CommandContext = { type CommandContext = {
sender: string; // normalized user id (digits only), but accept raw too 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 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 = const resp =
`✅ Tarea ${taskId} creada: "${description || '(sin descripción)'}"` + `✅ Tarea ${taskId} creada: "${description || '(sin descripción)'}"` +
(dueDate ? ` (vence ${dueDate})` : '') + (dueDate ? ` (vence ${dueDate})` : '') +

@ -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…
Cancel
Save