import { crmRequest, CrmError } from "./client.ts"; import type { CrmCtx } from "./ctx.ts"; export interface CrmConversation { id: string; contactId?: string; fullName?: string; contactName?: string; email?: string; phone?: string; lastMessageBody?: string; lastMessageType?: string; lastMessageDate?: string | number; unreadCount?: number; type?: string | number; } export interface CrmMessage { id: string; body?: string; direction?: "inbound" | "outbound"; messageType?: string; status?: string | null; dateAdded?: string; contactId?: string; conversationId?: string; } /** * El canal llega como cadena (`TYPE_SMS`) desde el buscador y como número desde * `GET /conversations/{id}`. * * MEDIDO (hallazgo 24). Es la misma información con dos formas, y mezclarlas * produce una bandeja que etiqueta mal los hilos. Un tipo que no se reconozca se * deja pasar tal cual en vez de esconderlo: si el CRM añade un canal, se verá. */ const POR_NUMERO: Record = { 1: "Phone", 2: "Email", 3: "FB", 4: "Review", 5: "SMS", }; /** Los canales conocidos se rinden con su nombre propio; el resto pasa tal cual. */ const POR_NOMBRE: Record = { SMS: "SMS", EMAIL: "Email", CALL: "Llamada", VOICEMAIL: "Buzón de voz", WHATSAPP: "WhatsApp", FB: "Facebook", IG: "Instagram", INSTAGRAM: "Instagram", FACEBOOK: "Facebook", GMB: "Google Business", WEBCHAT: "Chat web", // Los TYPE_ACTIVITY_* no son mensajes de la clienta: son notas que el propio // CRM escribe en el hilo cuando pasa algo (se creó una oportunidad, se agendó // una cita). Se etiquetan como actividad para poder distinguirlos en la // bandeja en vez de mostrarlos como si alguien los hubiera escrito. ACTIVITY_OPPORTUNITY: "Actividad", ACTIVITY_APPOINTMENT: "Actividad", ACTIVITY_CONTACT: "Actividad", ACTIVITY: "Actividad", REVIEW: "Reseña", LIVE_CHAT: "Chat en vivo", CUSTOM: "Otro", }; export function normalizarTipo(t: string | number | undefined | null): string { if (typeof t === "number") return POR_NUMERO[t] ?? "Desconocido"; if (typeof t === "string" && t) { const crudo = t.replace(/^TYPE_/, ""); return POR_NOMBRE[crudo] ?? crudo.replace(/_/g, " "); } return "Desconocido"; } /** * MEDIDO (hallazgo 26): la respuesta real es `{ messages: { messages: [...], * lastMessageId, nextPage } }` — anidada dos niveles. * * Esta es la TERCERA convención de paginación de la misma API: contactos usan * `searchAfter`, conversaciones `startAfterDate`, y los mensajes `lastMessageId` * con un booleano `nextPage`. Reciclar una por otra devuelve listas incompletas * sin dar ningún error. */ export function formaDeMensajes(r: any): { mensajes: CrmMessage[]; lastMessageId: string | null; hayMas: boolean; } { const anidado = r?.messages?.messages; if (Array.isArray(anidado)) { return { mensajes: anidado, lastMessageId: r.messages.lastMessageId ?? null, hayMas: Boolean(r.messages.nextPage), }; } const plano = Array.isArray(r?.messages) ? r.messages : []; return { mensajes: plano, lastMessageId: null, hayMas: false }; } /** Una conversación por su id. MEDIDO: los campos vienen en la raíz, sin envoltorio. */ export async function obtenerConversacion( ctx: CrmCtx, id: string ): Promise { try { return await crmRequest("GET", `/conversations/${id}`, { token: ctx.token, }); } catch (e) { if (e instanceof CrmError && e.status === 404) return null; throw e; } } /** Las conversaciones de un contacto. MEDIDO (hallazgo 25): `contactId` es filtro. */ export async function conversacionesDeContacto( ctx: CrmCtx, contactId: string ): Promise { const r = await crmRequest("GET", "/conversations/search", { token: ctx.token, query: { locationId: ctx.locationId, contactId, limit: 50 }, }); return r?.conversations ?? []; } export async function buscarConversaciones( ctx: CrmCtx, opts: { limit?: number; startAfterDate?: number } = {} ): Promise<{ conversations: CrmConversation[]; total: number }> { const r = await crmRequest("GET", "/conversations/search", { token: ctx.token, query: { locationId: ctx.locationId, limit: opts.limit ?? 20, sortBy: "last_message_date", sort: "desc", startAfterDate: opts.startAfterDate, }, }); return { conversations: r?.conversations ?? [], total: r?.total ?? 0 }; } export async function mensajesDeConversacion( ctx: CrmCtx, conversationId: string, opts: { limit?: number; lastMessageId?: string } = {} ) { const r = await crmRequest("GET", `/conversations/${conversationId}/messages`, { token: ctx.token, query: { limit: opts.limit ?? 50, lastMessageId: opts.lastMessageId }, }); return formaDeMensajes(r); } /** Un mensaje suelto por su id. MEDIDO (hallazgo 27): funciona y viene en la raíz. */ export async function obtenerMensaje(ctx: CrmCtx, id: string): Promise { try { const r = await crmRequest("GET", `/conversations/messages/${id}`, { token: ctx.token, }); return (r?.message ?? r) as CrmMessage; } catch (e) { if (e instanceof CrmError && e.status === 404) return null; throw e; } }