fix(publications): restablecer publicaciones automaticas en app y catalogo whatsapp, corregir webhooks y sincronizacion

This commit is contained in:
AI Bot 2026-09-10 09:47:13 -05:00
parent 2aa0b8560d
commit ff95bbd98a
8 changed files with 279 additions and 62 deletions

View File

@ -4,20 +4,26 @@ import { generateWhatsAppCatalogCsv } from '@/lib/whatsapp-catalog';
import { getProductsFromAppwrite } from '@/lib/server-appwrite'; import { getProductsFromAppwrite } from '@/lib/server-appwrite';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export const revalidate = 0;
export async function GET() { export async function GET() {
try { try {
let products = dataService.getProducts(); let products = dataService.getProducts();
if (products.length === 0) {
try { // Sincronizar siempre con Appwrite para garantizar catálogo en tiempo real
const appwriteProducts = await getProductsFromAppwrite(); try {
if (appwriteProducts && appwriteProducts.length > 0) { const appwriteProducts = await getProductsFromAppwrite();
appwriteProducts.forEach((p) => dataService.addProduct(p)); if (appwriteProducts && appwriteProducts.length > 0) {
products = dataService.getProducts(); const existingIds = new Set(products.map((p) => p.id));
for (const ap of appwriteProducts) {
if (!existingIds.has(ap.id)) {
dataService.addProduct(ap);
}
} }
} catch (e) { products = dataService.getProducts();
console.warn('Error sincronizando productos con Appwrite para CSV Feed:', e);
} }
} catch (e: any) {
console.warn('Error sincronizando productos con Appwrite para CSV Feed:', e.message);
} }
const csv = generateWhatsAppCatalogCsv(products); const csv = generateWhatsAppCatalogCsv(products);
@ -27,7 +33,7 @@ export async function GET() {
headers: { headers: {
'Content-Type': 'text/csv; charset=utf-8', 'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'inline; filename="dripzone_whatsapp_catalog.csv"', '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) { } catch (error: any) {

View File

@ -4,20 +4,26 @@ import { generateWhatsAppCatalogXml } from '@/lib/whatsapp-catalog';
import { getProductsFromAppwrite } from '@/lib/server-appwrite'; import { getProductsFromAppwrite } from '@/lib/server-appwrite';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export const revalidate = 0;
export async function GET() { export async function GET() {
try { try {
let products = dataService.getProducts(); let products = dataService.getProducts();
if (products.length === 0) {
try { // Sincronizar siempre con Appwrite para garantizar catálogo en tiempo real
const appwriteProducts = await getProductsFromAppwrite(); try {
if (appwriteProducts && appwriteProducts.length > 0) { const appwriteProducts = await getProductsFromAppwrite();
appwriteProducts.forEach((p) => dataService.addProduct(p)); if (appwriteProducts && appwriteProducts.length > 0) {
products = dataService.getProducts(); const existingIds = new Set(products.map((p) => p.id));
for (const ap of appwriteProducts) {
if (!existingIds.has(ap.id)) {
dataService.addProduct(ap);
}
} }
} catch (e) { products = dataService.getProducts();
console.warn('Error sincronizando productos con Appwrite para XML Feed:', e);
} }
} catch (e: any) {
console.warn('Error sincronizando productos con Appwrite para XML Feed:', e.message);
} }
const settings = dataService.getSettings(); const settings = dataService.getSettings();
@ -27,7 +33,7 @@ export async function GET() {
status: 200, status: 200,
headers: { headers: {
'Content-Type': 'application/xml; charset=utf-8', '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) { } catch (error: any) {

View File

@ -4,7 +4,14 @@ import { parseSupplierMessage, detectCategory } from '@/lib/gemini-parser';
import { sanitizePublicationTitle } from '@/lib/sanitizer'; import { sanitizePublicationTitle } from '@/lib/sanitizer';
import { generateAttractiveProductCopy } from '@/lib/attractive-copy'; import { generateAttractiveProductCopy } from '@/lib/attractive-copy';
import { getProductSocialProof } from '@/lib/social-proof'; 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 { import {
getMonitoredGroupsFromAppwrite, getMonitoredGroupsFromAppwrite,
saveProductToAppwrite, saveProductToAppwrite,
@ -220,27 +227,39 @@ export async function POST(request: Request) {
// ========================================================================= // =========================================================================
// CASO B: GRUPOS MAYORISTAS DE PROVEEDORES (Publicación Automática Incondicional) // CASO B: GRUPOS MAYORISTAS DE PROVEEDORES (Publicación Automática Incondicional)
// ========================================================================= // =========================================================================
const groupSubject = // Resolver el nombre del grupo de forma precisa usando Evolution API si no viene en el payload
data.groupInfo?.subject || let groupSubject = data.groupInfo?.subject || data.chatName || '';
data.chatName || if (!groupSubject || groupSubject.length < 3 || groupSubject === data.pushName) {
data.pushName || try {
'Grupo Mayorista'; 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) // 1. FILTRAR EXCLUSIVAMENTE GRUPOS DE PRENSA Y GOBIERNO (Reenviar a portal de noticias Almeira)
const isNonProductNews = /gob cauca|gobernacion|alcaldia|prensa|comunicaciones almeira|sedes asociadas|notificaciones almeira|inelpac|meliextra/i.test( 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 groupSubject
); );
if (isNonProductNews) { if (isNonProductNews) {
// Reenviar asíncronamente al webhook del portal de noticias Almeira try {
fetch('https://news.almeira.com.co/api/webhooks/whatsapp', { await fetch('https://news.almeira.com.co/api/webhooks/whatsapp', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
signal: AbortSignal.timeout(10000), signal: AbortSignal.timeout(15000),
}).catch(() => {}); });
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({ return NextResponse.json({
success: true, success: true,
ignored: 'Grupo de noticias institucionales reenviado a portal de prensa', 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 // Auto-registrar grupo como monitoreado y activo si no existía previamente
try { try {
const currentGroups = dataService.getMonitoredGroups(); const currentGroups = dataService.getMonitoredGroups();
@ -285,11 +315,33 @@ export async function POST(request: Request) {
// 3. EXTRACCIÓN MULTIMEDIA (IMAGEN HD Y THUMBNAIL) // 3. EXTRACCIÓN MULTIMEDIA (IMAGEN HD Y THUMBNAIL)
let imageBase64: string | undefined = data.base64 || undefined; 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) { if (!imageBase64 && data.message?.imageMessage?.jpegThumbnail) {
const thumb = data.message.imageMessage.jpegThumbnail; const thumb = data.message.imageMessage.jpegThumbnail;
imageBase64 = thumb.startsWith('data:') ? thumb : `data:image/jpeg;base64,${thumb}`; 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 // Guardar imagen en disco local inmediatamente para disponibilidad instantánea
if (imageBase64) { if (imageBase64) {
try { try {

View File

@ -3,8 +3,8 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co'; 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_API_KEY = process.env.EVOLUTION_FEED_API_KEY || process.env.EVOLUTION_API_KEY || 'EE49029EEB85-4568-8F81-C9FDFC6E2943';
const EVOLUTION_INSTANCE = process.env.EVOLUTION_INSTANCE_NAME || 'AutomataAlmeira'; const EVOLUTION_INSTANCE = process.env.EVOLUTION_FEED_INSTANCE_NAME || 'AutomataAlmeira';
// Carpeta local para almacenar en caché las imágenes descargadas en alta resolución // 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'); const CACHE_DIR = path.join(process.cwd(), 'public', 'cache', 'whatsapp-media');

View File

@ -125,6 +125,151 @@ export async function sendWhatsAppMedia(
} }
} }
// In-memory cache for group subjects: jid -> { subject, expiresAt }
const groupCache = new Map<string, { subject: string; expiresAt: number }>();
const GROUP_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hora
/**
* Obtener todos los grupos de WhatsApp desde Evolution API
*/
export async function fetchAllWhatsAppGroups(): Promise<Array<{ id: string; subject: string }>> {
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<string | null> {
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<Array<{ base64: string; mimeType: string; messageId: string }>> {
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 * Notifica inmediatamente la publicación automática de un nuevo producto
* exclusivamente al WhatsApp personal de Carlos (+57 316 745 5877). * exclusivamente al WhatsApp personal de Carlos (+57 316 745 5877).

View File

@ -85,23 +85,20 @@ def main():
print("[4/4] Enviando reporte y APK al WhatsApp de Carlos (+57 316 745 5877)...") print("[4/4] Enviando reporte y APK al WhatsApp de Carlos (+57 316 745 5877)...")
message_text = ( message_text = (
"🔥 *DripZone Android v2.1 - Tarjetas Comerciales de Alto Impacto Desplegadas*\n\n" "🚀 *DripZone Android & Catálogo WhatsApp - Publicaciones Automáticas Restablecidas*\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" "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. Badges Llamativos de Descuento (-30% OFF):*\n" "📡 *1. Configuración de Webhooks en Evolution API:*\n"
"• Cada tarjeta ahora cuenta con su badge de descuento en gradiente morado a fucsia sobre la foto del calzado.\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"
"• Cálculo dinámico contra el precio original o porcentaje estimado de ahorro.\n\n" "• Se eliminó el descarte accidental que ocurría cuando los mensajes pasaban por el portal de noticias.\n\n"
"⭐ *2. Prueba Social & Contador de Compras:*\n" "👟 *2. Publicación Inmediata en la App y Appwrite:*\n"
"• Calificación por estrellas doradas (4.8 - 5.0) con número de opiniones reales verificadas.\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"
"• Conteo de ventas por modelo (+75 a +340 pedidos despachados) visible directamente en la vitrina.\n\n" "📦 *3. Catálogo de WhatsApp Business (Meta Commerce):*\n"
"✨ *3. Etiquetas Especiales de Tendencia:*\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"
"• Badges contextuales automáticos: *🔥 Top Ventas*, *⚡ Más Pedido*, *★ Drop Exclusivo*, *✨ Tendencia*, *🚀 2026 Release*, *💎 Premium*.\n\n" "• Las fotos en alta resolución, precios en COP, tallas y enlace de checkout quedan disponibles automáticamente para Meta.\n\n"
"💰 *4. Precios Originales Tachados & Facilidad de Separación:*\n" "📰 *4. Enrutamiento Seguro de Prensa y Noticias:*\n"
"• Precio actual destacado en color Neón Glow junto al precio original tachado.\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"
"• 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"
f"⬇️ *Descarga directa del APK:* {APK_URL}\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( req_text = urllib.request.Request(

View File

@ -48,7 +48,7 @@ export async function POST(req: Request) {
try { try {
const payload = await req.json(); 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 = [ const MULTISTORE_WEBHOOKS = [
'https://drip-zone.online/api/webhook/whatsapp', 'https://drip-zone.online/api/webhook/whatsapp',
'https://dripzone.almeira.com.co/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', 'https://store.almeira.com.co/api/webhook/whatsapp',
]; ];
for (const targetUrl of MULTISTORE_WEBHOOKS) { try {
fetch(targetUrl, { await Promise.allSettled(
method: 'POST', MULTISTORE_WEBHOOKS.map(targetUrl =>
headers: { 'Content-Type': 'application/json' }, fetch(targetUrl, {
body: JSON.stringify(payload), method: 'POST',
signal: AbortSignal.timeout(60000), headers: { 'Content-Type': 'application/json' },
}).catch(() => {}); body: JSON.stringify(payload),
} signal: AbortSignal.timeout(12000),
}).catch(() => {})
)
);
} catch (_) {}
const rawEvent = payload.event || ''; const rawEvent = payload.event || '';
const eventType = rawEvent.toLowerCase().replace(/_/g, '.'); const eventType = rawEvent.toLowerCase().replace(/_/g, '.');

View File

@ -30,5 +30,12 @@
".next/dev/types/**/*.ts", ".next/dev/types/**/*.ts",
"**/*.mts" "**/*.mts"
], ],
"exclude": ["node_modules"] "exclude": [
"node_modules",
"dripzone-app",
"sneaky-app",
"zapply-app",
"almeira-store-app",
"almeira-live"
]
} }