diff --git a/src/channels/meta.ts b/src/channels/meta.ts index 2b8c4055..b1487ba1 100644 --- a/src/channels/meta.ts +++ b/src/channels/meta.ts @@ -23,7 +23,7 @@ interface MetaMessaging { text?: string; is_echo?: boolean; quick_reply?: { payload?: string }; - attachments?: { type: string; payload?: { url?: string } }[]; + attachments?: { type: string; payload?: { url?: string; title?: string } }[]; }; } @@ -32,6 +32,15 @@ interface MetaWebhookBody { entry?: { id?: string; time?: number; messaging?: MetaMessaging[] }[]; } +// En los DMs de Instagram el lead comparte MUCHO por adjunto: reels, posts, +// historias y videos. Antes se descartaban (el `continue` de más abajo) y la +// conversación parecía vacía ("no me escribió nada"). Los mapeamos a texto/imagen +// para que se vean en la bandeja. Solo Instagram (Messenger queda igual). +const IG_SHARE_RE = /^(ig_reel|reel|share|media_share|ig_post|post|album|story_mention|story)$/i; +function shareLabel(type: string): string { + return /story/i.test(type) ? "Historia" : /reel/i.test(type) ? "Reel" : "Publicación"; +} + /** * Convierte un webhook de Meta en 0..N mensajes entrantes. Un solo POST puede * traer varias entradas y varios eventos; también trae echoes (mensajes que la @@ -55,15 +64,40 @@ export function parseMetaEvents(body: MetaWebhookBody): IncomingMessage[] { if (m.quick_reply) continue; // tap de botón (quick reply), no es texto para el LLM const sender = ev.sender?.id; if (!sender) continue; - const audio = m.attachments?.find((a) => a.type === "audio"); - const image = m.attachments?.find((a) => a.type === "image"); - if (!m.text && !audio && !image) continue; // ignora recibos/postbacks sin contenido + const isIG = channel === "instagram"; + const atts = m.attachments ?? []; + const audio = atts.find((a) => a.type === "audio"); + const image = atts.find((a) => a.type === "image"); + // Solo IG: reels/posts/historias/video que antes se perdían. + const share = isIG ? atts.find((a) => IG_SHARE_RE.test(a.type)) : undefined; + const video = isIG ? atts.find((a) => a.type === "video") : undefined; + let imageUrl = image?.payload?.url; + const extra: string[] = []; + if (share) { + const url = share.payload?.url?.trim(); + const caption = share.payload?.title?.trim(); + const label = shareLabel(share.type); + // Reel/historia: payload.url ES el permalink real (instagram.com/reel/…) → + // se puede abrir. Post: Meta solo manda la imagen del CDN (lookaside…), sin + // link al post — se muestra esa imagen (lo único disponible). + const isPermalink = !!url && /(?:^|\.)instagram\.com\//i.test(url); + if (caption) extra.push(caption); + if (url && isPermalink) extra.push(`🎬 ${label}: ${url}`); + else if (url) { imageUrl = imageUrl ?? url; extra.push(`🖼️ ${label} compartida`); } + else extra.push(`🖼️ ${label} compartida`); + } + if (video && !share) { + const url = video.payload?.url?.trim(); + extra.push(url ? `🎥 Video: ${url}` : "🎥 Video"); + } + const text = [m.text, extra.join("\n")].filter(Boolean).join("\n") || undefined; + if (!text && !audio && !imageUrl) continue; // ignora recibos/postbacks sin contenido out.push({ channel, channelUserId: String(sender), - text: m.text || undefined, + text, audioUrl: audio?.payload?.url, - imageUrl: image?.payload?.url, + imageUrl, isOwnerMessage: false, receivedAt: Date.now(), rawPayload: ev, @@ -73,6 +107,42 @@ export function parseMetaEvents(body: MetaWebhookBody): IncomingMessage[] { return out; } +// Nombre/usuario del contacto de INSTAGRAM (para no mostrar el ID opaco en la +// bandeja). El webhook NO trae el nombre; hay que pedirlo a Graph. Cache por id +// dentro del isolate. Solo IG: Messenger no expone el nombre de un PSID sin +// suscribir la app + App Review, así que ahí no se intenta. Best-effort: si +// falla (o falta el token), devuelve undefined y la conversación queda con el id. +const igNameCache = new Map(); +export async function metaProfileName(env: Env, id: string): Promise { + const token = env.INSTAGRAM_ACCESS_TOKEN; + if (!token) return undefined; + if (igNameCache.has(id)) return igNameCache.get(id) || undefined; + try { + const r = await egressFetch( + `https://graph.instagram.com/${GRAPH_VERSION}/${encodeURIComponent(id)}` + + `?fields=name,username&access_token=${encodeURIComponent(token)}`, + ); + if (!r.ok) { + // code 230 "User consent is required": IG no expone el nombre de quien no te + // ha escrito/consentido. Se cachea vacío; cuando responda, se resuelve solo. + igNameCache.set(id, ""); + return undefined; + } + const j = (await r.json()) as { name?: string; username?: string }; + // El `name` a veces es el nombre de negocio; el @usuario es lo que identifica + // la cuenta. Se muestran juntos cuando hay ambos: "Nombre · @usuario". + const nm = (j.name ?? "").trim(); + const un = (j.username ?? "").trim(); + const name = nm && un ? `${nm} · @${un}` : nm || (un ? `@${un}` : ""); + // Si aún no llegó el username (a veces tarda en propagarse), no lo cacheamos: + // el próximo mensaje reintenta y lo enriquece. + if (un || !name) igNameCache.set(id, name); + return name || undefined; + } catch { + return undefined; + } +} + /** * Valida la firma HMAC-SHA256 (`X-Hub-Signature-256: sha256=`) que Meta * pone en cada POST, usando el App Secret. Comparación en tiempo constante. diff --git a/src/index.ts b/src/index.ts index 45c61d68..f4473cd2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import type { ChannelAdapter } from "./channels/shared"; import { telegramAdapter } from "./channels/telegram"; import { manychatAdapter } from "./channels/manychat"; import { twilioAdapter } from "./channels/twilio"; -import { parseMetaEvents, verifyMetaSignature } from "./channels/meta"; +import { parseMetaEvents, verifyMetaSignature, metaProfileName } from "./channels/meta"; import { parseWhatsAppEvents, serveWhatsAppMedia } from "./channels/whatsapp"; import { adminApp } from "./admin/routes"; import { purgeOldMessages } from "./crons/purgeOldMessages"; @@ -131,6 +131,11 @@ app.post("/webhooks/meta", async (c) => { // (si no, cada DM se procesa DOBLE: 2x LLM, 2x respuestas al lead y // colisiones de rate limit en ráfagas de historias). if (msg.channel === "instagram" && c.env.IG_DM_SOURCE === "manychat") continue; + // Instagram: el webhook no trae el nombre, así que la conversación se crearía + // con el id opaco. Lo resolvemos a "Nombre · @usuario" (best-effort, cacheado). + if (msg.channel === "instagram" && !msg.displayName) { + msg.displayName = await metaProfileName(c.env, msg.channelUserId); + } await getAgentStub(c.env, `${msg.channel}:${msg.channelUserId}`).ingest(msg); } return c.text("EVENT_RECEIVED", 200); diff --git a/test/channels/meta.test.ts b/test/channels/meta.test.ts new file mode 100644 index 00000000..252b999e --- /dev/null +++ b/test/channels/meta.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { parseMetaEvents } from "../../src/channels/meta"; + +function igBody(attachments: { type: string; payload?: Record }[], text?: string) { + return { + object: "instagram", + entry: [{ messaging: [{ sender: { id: "17841400000000000" }, message: { mid: "m1", text, attachments } }] }], + }; +} +function fbBody(attachments: { type: string; payload?: Record }[], text?: string) { + return { + object: "page", + entry: [{ messaging: [{ sender: { id: "PSID123" }, message: { mid: "m1", text, attachments } }] }], + }; +} + +describe("parseMetaEvents · Instagram shares (antes se perdían)", () => { + it("un reel compartido llega como texto con el permalink (no se descarta)", () => { + const url = "https://www.instagram.com/reel/ABC123/"; + const [msg] = parseMetaEvents(igBody([{ type: "ig_reel", payload: { url, title: "mira esto" } }])); + expect(msg).toBeDefined(); + expect(msg.channel).toBe("instagram"); + expect(msg.text).toContain("mira esto"); + expect(msg.text).toContain(url); + }); + + it("un post compartido muestra su imagen del CDN (sin permalink, es lo único que da Meta)", () => { + const url = "https://lookaside.fbsbx.com/ig_messaging_cdn/foto.jpg"; + const [msg] = parseMetaEvents(igBody([{ type: "ig_post", payload: { url } }])); + expect(msg).toBeDefined(); + expect(msg.imageUrl).toBe(url); + expect(msg.text).toContain("Publicación"); + }); + + it("un video llega como texto con su url", () => { + const url = "https://cdn.example/video.mp4"; + const [msg] = parseMetaEvents(igBody([{ type: "video", payload: { url } }])); + expect(msg).toBeDefined(); + expect(msg.text).toContain(url); + }); + + it("imagen y audio siguen mapeando a imageUrl/audioUrl", () => { + const [img] = parseMetaEvents(igBody([{ type: "image", payload: { url: "https://x/i.jpg" } }])); + expect(img.imageUrl).toBe("https://x/i.jpg"); + const [aud] = parseMetaEvents(igBody([{ type: "audio", payload: { url: "https://x/a.mp3" } }])); + expect(aud.audioUrl).toBe("https://x/a.mp3"); + }); +}); + +describe("parseMetaEvents · Messenger sin cambios (solo IG se enriquece)", () => { + it("un share por Messenger se sigue descartando (no hay contenido de texto/imagen/audio)", () => { + const out = parseMetaEvents(fbBody([{ type: "ig_reel", payload: { url: "https://instagram.com/reel/X/" } }])); + expect(out).toHaveLength(0); + }); + + it("texto por Messenger pasa igual que siempre", () => { + const [msg] = parseMetaEvents(fbBody([], "hola")); + expect(msg.channel).toBe("messenger"); + expect(msg.text).toBe("hola"); + }); +});