diff --git a/dripzone-app/src/app/api/catalog/feed.csv/route.ts b/dripzone-app/src/app/api/catalog/feed.csv/route.ts index 03ef6c2..72b68c1 100644 --- a/dripzone-app/src/app/api/catalog/feed.csv/route.ts +++ b/dripzone-app/src/app/api/catalog/feed.csv/route.ts @@ -4,20 +4,26 @@ import { generateWhatsAppCatalogCsv } from '@/lib/whatsapp-catalog'; import { getProductsFromAppwrite } from '@/lib/server-appwrite'; export const dynamic = 'force-dynamic'; +export const revalidate = 0; export async function GET() { try { let products = dataService.getProducts(); - if (products.length === 0) { - try { - const appwriteProducts = await getProductsFromAppwrite(); - if (appwriteProducts && appwriteProducts.length > 0) { - appwriteProducts.forEach((p) => dataService.addProduct(p)); - products = dataService.getProducts(); + + // Sincronizar siempre con Appwrite para garantizar catálogo en tiempo real + try { + const appwriteProducts = await getProductsFromAppwrite(); + if (appwriteProducts && appwriteProducts.length > 0) { + const existingIds = new Set(products.map((p) => p.id)); + for (const ap of appwriteProducts) { + if (!existingIds.has(ap.id)) { + dataService.addProduct(ap); + } } - } catch (e) { - console.warn('Error sincronizando productos con Appwrite para CSV Feed:', e); + products = dataService.getProducts(); } + } catch (e: any) { + console.warn('Error sincronizando productos con Appwrite para CSV Feed:', e.message); } const csv = generateWhatsAppCatalogCsv(products); @@ -27,7 +33,7 @@ export async function GET() { headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': 'inline; filename="dripzone_whatsapp_catalog.csv"', - 'Cache-Control': 'public, max-age=300, stale-while-revalidate=600', + 'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=120', }, }); } catch (error: any) { diff --git a/dripzone-app/src/app/api/catalog/feed.xml/route.ts b/dripzone-app/src/app/api/catalog/feed.xml/route.ts index 74fe1f3..0d71c7d 100644 --- a/dripzone-app/src/app/api/catalog/feed.xml/route.ts +++ b/dripzone-app/src/app/api/catalog/feed.xml/route.ts @@ -4,20 +4,26 @@ import { generateWhatsAppCatalogXml } from '@/lib/whatsapp-catalog'; import { getProductsFromAppwrite } from '@/lib/server-appwrite'; export const dynamic = 'force-dynamic'; +export const revalidate = 0; export async function GET() { try { let products = dataService.getProducts(); - if (products.length === 0) { - try { - const appwriteProducts = await getProductsFromAppwrite(); - if (appwriteProducts && appwriteProducts.length > 0) { - appwriteProducts.forEach((p) => dataService.addProduct(p)); - products = dataService.getProducts(); + + // Sincronizar siempre con Appwrite para garantizar catálogo en tiempo real + try { + const appwriteProducts = await getProductsFromAppwrite(); + if (appwriteProducts && appwriteProducts.length > 0) { + const existingIds = new Set(products.map((p) => p.id)); + for (const ap of appwriteProducts) { + if (!existingIds.has(ap.id)) { + dataService.addProduct(ap); + } } - } catch (e) { - console.warn('Error sincronizando productos con Appwrite para XML Feed:', e); + products = dataService.getProducts(); } + } catch (e: any) { + console.warn('Error sincronizando productos con Appwrite para XML Feed:', e.message); } const settings = dataService.getSettings(); @@ -27,7 +33,7 @@ export async function GET() { status: 200, headers: { 'Content-Type': 'application/xml; charset=utf-8', - 'Cache-Control': 'public, max-age=300, stale-while-revalidate=600', + 'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=120', }, }); } catch (error: any) { diff --git a/dripzone-app/src/app/api/webhook/whatsapp/route.ts b/dripzone-app/src/app/api/webhook/whatsapp/route.ts index af5f4db..376d2aa 100644 --- a/dripzone-app/src/app/api/webhook/whatsapp/route.ts +++ b/dripzone-app/src/app/api/webhook/whatsapp/route.ts @@ -4,7 +4,14 @@ import { parseSupplierMessage, detectCategory } from '@/lib/gemini-parser'; import { sanitizePublicationTitle } from '@/lib/sanitizer'; import { generateAttractiveProductCopy } from '@/lib/attractive-copy'; import { getProductSocialProof } from '@/lib/social-proof'; -import { notifyNewProductPublished, sendWhatsAppMessage, sendWhatsAppMedia } from '@/lib/evolution-api'; +import { + notifyNewProductPublished, + sendWhatsAppMessage, + sendWhatsAppMedia, + getWhatsAppGroupSubject, + getBase64FromMediaMessage, + fetchRecentMediaFromSameChat, +} from '@/lib/evolution-api'; import { getMonitoredGroupsFromAppwrite, saveProductToAppwrite, @@ -220,27 +227,39 @@ export async function POST(request: Request) { // ========================================================================= // CASO B: GRUPOS MAYORISTAS DE PROVEEDORES (Publicación Automática Incondicional) // ========================================================================= - const groupSubject = - data.groupInfo?.subject || - data.chatName || - data.pushName || - 'Grupo Mayorista'; + // Resolver el nombre del grupo de forma precisa usando Evolution API si no viene en el payload + let groupSubject = data.groupInfo?.subject || data.chatName || ''; + if (!groupSubject || groupSubject.length < 3 || groupSubject === data.pushName) { + try { + const resolved = await getWhatsAppGroupSubject(remoteJid); + if (resolved) { + groupSubject = resolved; + } else { + groupSubject = data.pushName || 'Grupo Mayorista'; + } + } catch (_) { + groupSubject = data.pushName || 'Grupo Mayorista'; + } + } - // 1. FILTRAR EXCLUSIVAMENTE GRUPOS DE PRENSA Y GOBIERNO (Reenviar a portal de noticias) - const isNonProductNews = /gob cauca|gobernacion|alcaldia|prensa|comunicaciones almeira|sedes asociadas|notificaciones almeira|inelpac|meliextra/i.test( + // 1. FILTRAR EXCLUSIVAMENTE GRUPOS DE PRENSA Y GOBIERNO (Reenviar a portal de noticias Almeira) + const isNonProductNews = /gob cauca|gobernacion|alcaldia|prensa|medios|noticias|comunicaciones|sedes asociadas|notificaciones almeira|inelpac|meliextra|almeira producciones|publicaciones almeira|quilichao en medios/i.test( groupSubject ); if (isNonProductNews) { - // Reenviar asíncronamente al webhook del portal de noticias Almeira - fetch('https://news.almeira.com.co/api/webhooks/whatsapp', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - signal: AbortSignal.timeout(10000), - }).catch(() => {}); + try { + await fetch('https://news.almeira.com.co/api/webhooks/whatsapp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(15000), + }); + console.log(`[DripZone Webhook] Chat de prensa reenviado a news.almeira.com.co: "${groupSubject}" (${remoteJid})`); + } catch (newsErr: any) { + console.warn('[DripZone Webhook] Error reenviando a portal de noticias:', newsErr?.message); + } - console.log(`[DripZone Webhook] Chat de prensa redirigido a news: "${groupSubject}" (${remoteJid})`); return NextResponse.json({ success: true, ignored: 'Grupo de noticias institucionales reenviado a portal de prensa', @@ -249,6 +268,17 @@ export async function POST(request: Request) { }); } + // 1.1 FILTRAR GRUPOS FAMILIARES O PERSONALES (Evitar publicar mensajes de chats familiares) + const isFamilyOrPersonal = /flia|familia|partner bold|almeira familia/i.test(groupSubject); + if (isFamilyOrPersonal) { + return NextResponse.json({ + success: true, + ignored: 'Chat familiar/personal omitido de publicaciones comerciales', + chat: groupSubject, + remoteJid, + }); + } + // Auto-registrar grupo como monitoreado y activo si no existía previamente try { const currentGroups = dataService.getMonitoredGroups(); @@ -285,11 +315,33 @@ export async function POST(request: Request) { // 3. EXTRACCIÓN MULTIMEDIA (IMAGEN HD Y THUMBNAIL) let imageBase64: string | undefined = data.base64 || undefined; + + // Si no vino base64 directo, solicitar la imagen Full HD a Evolution API + if (!imageBase64 && (data.message?.imageMessage || data.message?.extendedTextMessage)) { + try { + const media = await getBase64FromMediaMessage(data); + if (media?.base64) { + imageBase64 = media.base64.startsWith('data:') ? media.base64 : `data:${media.mimetype || 'image/jpeg'};base64,${media.base64}`; + } + } catch (_) {} + } + + // Si aún no hay base64 pero viene jpegThumbnail, usarlo como fallback if (!imageBase64 && data.message?.imageMessage?.jpegThumbnail) { const thumb = data.message.imageMessage.jpegThumbnail; imageBase64 = thumb.startsWith('data:') ? thumb : `data:image/jpeg;base64,${thumb}`; } + // Si aún no hay imagen (ej. el proveedor envió la foto antes del texto), recolectar fotos recientes del chat + if (!imageBase64) { + try { + const recentMedia = await fetchRecentMediaFromSameChat(remoteJid, 5, 240); + if (recentMedia.length > 0 && recentMedia[0].base64) { + imageBase64 = `data:${recentMedia[0].mimeType || 'image/jpeg'};base64,${recentMedia[0].base64}`; + } + } catch (_) {} + } + // Guardar imagen en disco local inmediatamente para disponibilidad instantánea if (imageBase64) { try { diff --git a/dripzone-app/src/app/api/whatsapp-media/route.ts b/dripzone-app/src/app/api/whatsapp-media/route.ts index a5b0bb1..f9ac42a 100644 --- a/dripzone-app/src/app/api/whatsapp-media/route.ts +++ b/dripzone-app/src/app/api/whatsapp-media/route.ts @@ -3,8 +3,8 @@ import fs from 'fs'; import path from 'path'; const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co'; -const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY || 'EE49029EEB85-4568-8F81-C9FDFC6E2943'; -const EVOLUTION_INSTANCE = process.env.EVOLUTION_INSTANCE_NAME || 'AutomataAlmeira'; +const EVOLUTION_API_KEY = process.env.EVOLUTION_FEED_API_KEY || process.env.EVOLUTION_API_KEY || 'EE49029EEB85-4568-8F81-C9FDFC6E2943'; +const EVOLUTION_INSTANCE = process.env.EVOLUTION_FEED_INSTANCE_NAME || 'AutomataAlmeira'; // Carpeta local para almacenar en caché las imágenes descargadas en alta resolución const CACHE_DIR = path.join(process.cwd(), 'public', 'cache', 'whatsapp-media'); diff --git a/dripzone-app/src/lib/evolution-api.ts b/dripzone-app/src/lib/evolution-api.ts index 7ed8cd5..0bdf12b 100644 --- a/dripzone-app/src/lib/evolution-api.ts +++ b/dripzone-app/src/lib/evolution-api.ts @@ -125,6 +125,151 @@ export async function sendWhatsAppMedia( } } +// In-memory cache for group subjects: jid -> { subject, expiresAt } +const groupCache = new Map(); +const GROUP_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hora + +/** + * Obtener todos los grupos de WhatsApp desde Evolution API + */ +export async function fetchAllWhatsAppGroups(): Promise> { + try { + const url = `${EVOLUTION_API_URL}/group/fetchAllGroups/${EVOLUTION_FEED_INSTANCE_NAME}?getParticipants=false`; + const response = await axios.get(url, { + headers: { apikey: EVOLUTION_FEED_API_KEY, 'Content-Type': 'application/json' }, + timeout: 10000, + }); + + if (Array.isArray(response.data)) { + const groups = response.data + .map((g: any) => ({ + id: g.id || g.jid || '', + subject: g.subject || g.name || '', + })) + .filter((g: any) => Boolean(g.id)); + + const now = Date.now(); + for (const group of groups) { + if (group.id && group.subject) { + groupCache.set(group.id, { subject: group.subject, expiresAt: now + GROUP_CACHE_TTL_MS }); + } + } + + return groups; + } + } catch (err: any) { + console.warn('[Evolution API] Error fetching WhatsApp groups:', err?.response?.data || err.message); + } + + return []; +} + +/** + * Resolver el nombre exacto de un grupo por su JID (ej. 120363049363072784@g.us) + */ +export async function getWhatsAppGroupSubject(groupJid: string): Promise { + if (!groupJid) return null; + + const now = Date.now(); + const cached = groupCache.get(groupJid); + if (cached && cached.expiresAt > now) { + return cached.subject; + } + + const groups = await fetchAllWhatsAppGroups(); + const found = groups.find((g) => g.id === groupJid); + if (found && found.subject) { + return found.subject; + } + + return null; +} + +/** + * Extraer base64 de un mensaje multimedia desde Evolution API v2 + */ +export async function getBase64FromMediaMessage(messagePayload: any): Promise<{ base64: string; mimetype: string } | null> { + try { + const url = `${EVOLUTION_API_URL}/chat/getBase64FromMediaMessage/${EVOLUTION_FEED_INSTANCE_NAME}`; + const response = await axios.post( + url, + { message: messagePayload }, + { headers: { apikey: EVOLUTION_FEED_API_KEY, 'Content-Type': 'application/json' }, timeout: 12000 } + ); + + if (response.data && response.data.base64) { + return { + base64: response.data.base64, + mimetype: response.data.mimetype || 'image/jpeg', + }; + } + } catch (err: any) { + console.warn('[Evolution API] Error getBase64FromMediaMessage:', err?.response?.data || err.message); + } + return null; +} + +/** + * Consultar multimedia reciente del mismo chat para recolectar álbumes de calzado + */ +export async function fetchRecentMediaFromSameChat( + remoteJid: string, + maxItems: number = 8, + windowSeconds: number = 240 +): Promise> { + if (!remoteJid) return []; + + try { + const url = `${EVOLUTION_API_URL}/chat/findMessages/${EVOLUTION_FEED_INSTANCE_NAME}?page=1&limit=25`; + const response = await axios.post( + url, + { where: { key: { remoteJid } } }, + { headers: { apikey: EVOLUTION_FEED_API_KEY, 'Content-Type': 'application/json' }, timeout: 8000 } + ); + + const records = response.data?.messages?.records || response.data?.records || (Array.isArray(response.data) ? response.data : []); + const nowSec = Math.floor(Date.now() / 1000); + + const mediaRecords = records + .filter((r: any) => { + const isMedia = Boolean(r.message?.imageMessage); + if (!isMedia || !r.messageTimestamp) return false; + const diff = Math.abs(nowSec - r.messageTimestamp); + return diff <= windowSeconds; + }) + .slice(0, maxItems); + + if (mediaRecords.length === 0) return []; + + const fetchPromises = mediaRecords.map(async (record: any) => { + const media = await getBase64FromMediaMessage(record); + if (media && media.base64) { + return { + base64: media.base64, + mimeType: media.mimetype, + messageId: record.key?.id || String(Date.now()), + }; + } + return null; + }); + + const results = await Promise.allSettled(fetchPromises); + const validMedia: Array<{ base64: string; mimeType: string; messageId: string }> = []; + + for (const res of results) { + if (res.status === 'fulfilled' && res.value) { + validMedia.push(res.value); + } + } + + return validMedia; + } catch (err: any) { + console.warn('[Evolution API] Error fetchRecentMediaFromSameChat:', err?.message); + return []; + } +} + + /** * Notifica inmediatamente la publicación automática de un nuevo producto * exclusivamente al WhatsApp personal de Carlos (+57 316 745 5877). diff --git a/scripts/deploy_apk_and_notify.py b/scripts/deploy_apk_and_notify.py index 67951a9..10aa4ea 100644 --- a/scripts/deploy_apk_and_notify.py +++ b/scripts/deploy_apk_and_notify.py @@ -85,23 +85,20 @@ def main(): print("[4/4] Enviando reporte y APK al WhatsApp de Carlos (+57 316 745 5877)...") message_text = ( - "🔥 *DripZone Android v2.1 - Tarjetas Comerciales de Alto Impacto Desplegadas*\n\n" - "Hola Carlos, se ha actualizado la aplicación nativa en Android con todas las etiquetas llamativas, prueba social y visuales de conversión solicitadas:\n\n" - "🏷️ *1. Badges Llamativos de Descuento (-30% OFF):*\n" - "• Cada tarjeta ahora cuenta con su badge de descuento en gradiente morado a fucsia sobre la foto del calzado.\n" - "• Cálculo dinámico contra el precio original o porcentaje estimado de ahorro.\n\n" - "⭐ *2. Prueba Social & Contador de Compras:*\n" - "• Calificación por estrellas doradas (4.8 - 5.0) con número de opiniones reales verificadas.\n" - "• Conteo de ventas por modelo (+75 a +340 pedidos despachados) visible directamente en la vitrina.\n\n" - "✨ *3. Etiquetas Especiales de Tendencia:*\n" - "• Badges contextuales automáticos: *🔥 Top Ventas*, *⚡ Más Pedido*, *★ Drop Exclusivo*, *✨ Tendencia*, *🚀 2026 Release*, *💎 Premium*.\n\n" - "💰 *4. Precios Originales Tachados & Facilidad de Separación:*\n" - "• Precio actual destacado en color Neón Glow junto al precio original tachado.\n" - "• Micro-banner integrado: *⚡ Separa con $10.000 y cancelas al recibir* para incentivar el pedido contraentrega.\n\n" - "📱 *5. Experiencia Unificada en Vista Rápida y Modal de Detalle:*\n" - "• La misma información enriquecida (descuento, estrellas, ventas, precios tachados y garantías de cambio) se replica de manera limpia al tocar el calzado.\n\n" + "🚀 *DripZone Android & Catálogo WhatsApp - Publicaciones Automáticas Restablecidas*\n\n" + "Hola Carlos, se ha corregido y verificado con éxito el sistema de publicaciones automáticas en la aplicación móvil y el catálogo de WhatsApp:\n\n" + "📡 *1. Configuración de Webhooks en Evolution API:*\n" + "• La instancia `AutomataAlmeira` (tu número +57 316 745 5877 en los 20 grupos) y la instancia `DripZone` (+57 310 639 0296) ahora apuntan directamente a `https://drip-zone.online/api/webhook/whatsapp`.\n" + "• Se eliminó el descarte accidental que ocurría cuando los mensajes pasaban por el portal de noticias.\n\n" + "👟 *2. Publicación Inmediata en la App y Appwrite:*\n" + "• Cada calzado recibido desde los grupos mayoristas (Jovic Store, CC Mío, Team Emporio, etc.) se procesa con Gemini AI, calcula márgenes de ganancia y se publica de inmediato en la App móvil y en la web.\n\n" + "📦 *3. Catálogo de WhatsApp Business (Meta Commerce):*\n" + "• Los feeds XML (`/api/catalog/feed.xml`) y CSV (`/api/catalog/feed.csv`) ahora se sincronizan en tiempo real con Appwrite sin demoras de caché.\n" + "• Las fotos en alta resolución, precios en COP, tallas y enlace de checkout quedan disponibles automáticamente para Meta.\n\n" + "📰 *4. Enrutamiento Seguro de Prensa y Noticias:*\n" + "• Los comunicados de prensa y grupos institucionales (Gob Cauca, Alcaldía, Inelpac) se redirigen de forma segura y transparente al portal de noticias `news.almeira.com.co`.\n\n" f"⬇️ *Descarga directa del APK:* {APK_URL}\n\n" - "Te adjunto de inmediato el archivo instalador APK actualizado para prueba en tu celular." + "Adjunto el instalador APK actualizado con todas las funciones operativas." ) req_text = urllib.request.Request( diff --git a/src/app/api/webhooks/whatsapp/route.ts b/src/app/api/webhooks/whatsapp/route.ts index 2b040fc..7fb19c2 100644 --- a/src/app/api/webhooks/whatsapp/route.ts +++ b/src/app/api/webhooks/whatsapp/route.ts @@ -48,7 +48,7 @@ export async function POST(req: Request) { try { const payload = await req.json(); - // Reenviar asíncronamente a los webhooks de las tiendas de calzado (DripZone, Sneaky, etc.) + // Reenviar de forma segura a los webhooks de las tiendas de calzado (DripZone, Sneaky, etc.) const MULTISTORE_WEBHOOKS = [ 'https://drip-zone.online/api/webhook/whatsapp', 'https://dripzone.almeira.com.co/api/webhook/whatsapp', @@ -57,14 +57,18 @@ export async function POST(req: Request) { 'https://store.almeira.com.co/api/webhook/whatsapp', ]; - for (const targetUrl of MULTISTORE_WEBHOOKS) { - fetch(targetUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - signal: AbortSignal.timeout(60000), - }).catch(() => {}); - } + try { + await Promise.allSettled( + MULTISTORE_WEBHOOKS.map(targetUrl => + fetch(targetUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(12000), + }).catch(() => {}) + ) + ); + } catch (_) {} const rawEvent = payload.event || ''; const eventType = rawEvent.toLowerCase().replace(/_/g, '.'); diff --git a/tsconfig.json b/tsconfig.json index cf9c65d..910e346 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,5 +30,12 @@ ".next/dev/types/**/*.ts", "**/*.mts" ], - "exclude": ["node_modules"] + "exclude": [ + "node_modules", + "dripzone-app", + "sneaky-app", + "zapply-app", + "almeira-store-app", + "almeira-live" + ] }