feat: format description aesthetics, remove horma nacional in favor of EUR, and fix duplicate notifications
This commit is contained in:
parent
31130bfbb5
commit
b15dea465b
@ -49,7 +49,7 @@ object TemplateManager {
|
||||
"Hola {tienda_nombre}! 👋 Quiero pedir las zapatillas:\n" +
|
||||
"👟 *{zapatilla_nombre}*\n" +
|
||||
"🎨 Color: *{color_nombre}*\n" +
|
||||
"📏 Talla: *{talla}* (Horma Nacional)\n" +
|
||||
"📏 Talla: *{talla}* (EUR)\n" +
|
||||
"💎 Precio: *{precio} COP*\n" +
|
||||
"🚚 Entrega en: *{ciudad}* (Pago Contraentrega)\n" +
|
||||
"⚡ Deseo separar con $10.000 COP y cancelar el saldo en mi casa.\n\n" +
|
||||
|
||||
@ -33,6 +33,7 @@ import {
|
||||
OFFICIAL_COLLECTION_NAMES,
|
||||
resolveProductCollection,
|
||||
} from '@/lib/whatsapp-catalog';
|
||||
import { FormattedDescription } from '@/components/store/FormattedDescription';
|
||||
|
||||
export default function AdminWhatsAppCatalogPage() {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
@ -49,6 +50,7 @@ export default function AdminWhatsAppCatalogPage() {
|
||||
// Modal para Generación de Enlaces de Pedido WhatsApp para Asesores
|
||||
const [selectedProductForLink, setSelectedProductForLink] = useState<Product | null>(null);
|
||||
const [isOrderLinkModalOpen, setIsOrderLinkModalOpen] = useState(false);
|
||||
const [expandedDescId, setExpandedDescId] = useState<string | null>(null);
|
||||
|
||||
// Formulario de Meta API
|
||||
const [metaCatalogId, setMetaCatalogId] = useState('');
|
||||
@ -854,16 +856,34 @@ export default function AdminWhatsAppCatalogPage() {
|
||||
<div className="bg-neutral-900/90 border border-neutral-800/80 rounded-xl p-2.5 space-y-1">
|
||||
<div className="text-[10px] text-neutral-400 font-bold uppercase tracking-wider flex items-center justify-between">
|
||||
<span>📏 Tallas disponibles:</span>
|
||||
<span className="text-emerald-400 font-mono text-[9px]">Horma Nacional</span>
|
||||
<span className="text-emerald-400 font-mono text-[9px]">Tallas EUR</span>
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-white tracking-wide">
|
||||
{sizesList}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Descripción de la publicación */}
|
||||
<div className="bg-neutral-900/50 rounded-xl p-2 text-[11px] text-neutral-400 line-clamp-2 leading-relaxed">
|
||||
{product.description || 'Producto de alta calidad con garantía y cambio asegurado.'}
|
||||
{/* Descripción de la publicación con formato y vista previa estética */}
|
||||
<div className="bg-neutral-900/60 border border-neutral-800/80 rounded-xl p-2.5 text-[11px] space-y-1.5">
|
||||
<div className="flex items-center justify-between text-neutral-400 font-semibold text-[10px] uppercase tracking-wider">
|
||||
<span>Descripción / Copy WhatsApp:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedDescId(expandedDescId === product.id ? null : product.id)}
|
||||
className="text-purple-400 hover:text-purple-300 font-bold transition-colors cursor-pointer"
|
||||
>
|
||||
{expandedDescId === product.id ? 'Contraer ▲' : 'Ver Formato ▼'}
|
||||
</button>
|
||||
</div>
|
||||
{expandedDescId === product.id ? (
|
||||
<div className="pt-1 max-h-56 overflow-y-auto pr-1">
|
||||
<FormattedDescription description={product.description} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-neutral-400 line-clamp-2 leading-relaxed whitespace-pre-line">
|
||||
{product.description || 'Producto de alta calidad con garantía y cambio asegurado.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Botón para Asesor Comercial: Enlace Directo para WhatsApp */}
|
||||
|
||||
@ -15,6 +15,7 @@ import { cleanProductName, detectBrandFromText } from '@/lib/gemini-parser';
|
||||
import { generateAttractiveProductCopy } from '@/lib/attractive-copy';
|
||||
import { WhatsAppOrderLinkModal } from '@/components/admin/WhatsAppOrderLinkModal';
|
||||
import { getProductPublicationDate, getProductAgeInDays, isProductOlderThanDays } from '@/lib/expiration-service';
|
||||
import { FormattedDescription } from '@/components/store/FormattedDescription';
|
||||
|
||||
export default function AdminProductsPage() {
|
||||
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
|
||||
@ -95,6 +96,8 @@ export default function AdminProductsPage() {
|
||||
const [editSizesInput, setEditSizesInput] = useState('');
|
||||
const [editImageUrl, setEditImageUrl] = useState('');
|
||||
const [editCategory, setEditCategory] = useState('');
|
||||
const [editDescription, setEditDescription] = useState('');
|
||||
const [showDescPreview, setShowDescPreview] = useState(false);
|
||||
|
||||
// 1-to-1 AI processor form
|
||||
const [rawText, setRawText] = useState('');
|
||||
@ -328,6 +331,8 @@ export default function AdminProductsPage() {
|
||||
setEditSizesInput(product.sizes.join(', '));
|
||||
setEditImageUrl(product.images[0] || '');
|
||||
setEditCategory(product.category || 'Sneakers / Urbano');
|
||||
setEditDescription(product.description || '');
|
||||
setShowDescPreview(false);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async (e: React.FormEvent) => {
|
||||
@ -337,7 +342,7 @@ export default function AdminProductsPage() {
|
||||
const sizes = editSizesInput.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const chosenSupplier = suppliers.find((s) => s.id === editSupplierId) || suppliers[0];
|
||||
const cleanProdName = sanitizePublicationTitle(editName, [chosenSupplier.name], editBrand);
|
||||
const cleanProdDesc = sanitizePublicationText(editingProduct.description || '', [chosenSupplier.name]);
|
||||
const cleanProdDesc = sanitizePublicationText(editDescription || '', [chosenSupplier.name]);
|
||||
|
||||
const updates = {
|
||||
name: cleanProdName,
|
||||
@ -1970,6 +1975,40 @@ export default function AdminProductsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Descripción de la Publicación con vista previa organizada */}
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-neutral-300 font-bold">
|
||||
Descripción / Copy Comercial (WhatsApp & Vitrina)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDescPreview(!showDescPreview)}
|
||||
className="text-[11px] text-purple-400 hover:text-purple-300 font-bold flex items-center gap-1 cursor-pointer transition-colors"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>{showDescPreview ? '✏️ Modo Editor' : '👁️ Vista Previa Formateada'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!showDescPreview ? (
|
||||
<textarea
|
||||
rows={7}
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
placeholder="Escribe la descripción del producto con saltos de línea..."
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl p-3 text-white outline-hidden focus:border-purple-400 font-mono text-xs leading-relaxed resize-y"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-neutral-950 border border-purple-500/30 rounded-xl p-3.5 max-h-64 overflow-y-auto shadow-inner">
|
||||
<FormattedDescription description={editDescription} />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-neutral-500 leading-normal">
|
||||
💡 Tip: Puedes usar *negrita*, _cursiva_, viñetas con • o guion -, y ⚠️ para avisos de disponibilidad. Todos los saltos de línea se verán organizados y estéticos en la tienda.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-3 border-t border-neutral-800">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -28,24 +28,37 @@ import { formatCOP } from '@/lib/pricing-engine';
|
||||
const processedMessagesCache = new Map<string, number>();
|
||||
// Cache en memoria para control de cadencia de respuestas comerciales automáticas a clientes
|
||||
const customerChatCache = new Map<string, number>();
|
||||
// In-flight concurrency lock para evitar condiciones de carrera entre webhooks paralelos
|
||||
const inFlightMessages = new Set<string>();
|
||||
|
||||
function isDuplicateMessage(msgId: string, text: string): boolean {
|
||||
const key = `${msgId}:${(text || '').trim().substring(0, 60)}`;
|
||||
function isDuplicateMessage(msgId: string, text: string = ''): boolean {
|
||||
if (!msgId) return false;
|
||||
const now = Date.now();
|
||||
const lastSeen = processedMessagesCache.get(key);
|
||||
|
||||
// Limpieza de claves viejas (> 5 minutos)
|
||||
// Limpieza de claves viejas (> 30 minutos)
|
||||
for (const [k, time] of processedMessagesCache.entries()) {
|
||||
if (now - time > 300000) {
|
||||
if (now - time > 1800000) {
|
||||
processedMessagesCache.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
if (lastSeen && now - lastSeen < 60000) {
|
||||
// Verificar por ID de mensaje de WhatsApp (identificador universal)
|
||||
const lastSeenById = processedMessagesCache.get(`id:${msgId}`);
|
||||
if (lastSeenById && now - lastSeenById < 600000) {
|
||||
return true;
|
||||
}
|
||||
|
||||
processedMessagesCache.set(key, now);
|
||||
// Verificar por contenido de texto si existe
|
||||
const cleanSnippet = (text || '').trim().toLowerCase().substring(0, 80);
|
||||
if (cleanSnippet.length > 15) {
|
||||
const lastSeenByText = processedMessagesCache.get(`txt:${cleanSnippet}`);
|
||||
if (lastSeenByText && now - lastSeenByText < 300000) {
|
||||
return true;
|
||||
}
|
||||
processedMessagesCache.set(`txt:${cleanSnippet}`, now);
|
||||
}
|
||||
|
||||
processedMessagesCache.set(`id:${msgId}`, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -62,15 +75,19 @@ export async function POST(request: Request) {
|
||||
const rawEvent = payload.event || '';
|
||||
const eventType = rawEvent.toLowerCase().replace(/_/g, '.');
|
||||
|
||||
// Filtrar eventos irrelevantes (solo procesar inserción de mensajes o envíos directos)
|
||||
// FILTRO ESTRICTO: Solo procesar inserción de mensajes nuevos (messages.upsert)
|
||||
// NUNCA procesar messages.update ni send.message para publicación de productos
|
||||
// porque Evolution API dispara messages.update hasta 3 veces por tildes de entrega y lectura.
|
||||
if (
|
||||
rawEvent &&
|
||||
!eventType.includes('messages.upsert') &&
|
||||
!eventType.includes('messages.update') &&
|
||||
!eventType.includes('send.message') &&
|
||||
!eventType.includes('test')
|
||||
) {
|
||||
return NextResponse.json({ success: true, ignored: 'Evento no relevante', event: rawEvent });
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ignored: `Evento "${rawEvent}" ignorado. Solo "messages.upsert" procesa y publica productos para evitar duplicados.`,
|
||||
event: rawEvent,
|
||||
});
|
||||
}
|
||||
|
||||
const data = payload.data || payload;
|
||||
@ -367,7 +384,12 @@ export async function POST(request: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
// 2. VERIFICACIÓN DE DUPLICADOS
|
||||
// 2. VERIFICACIÓN DE DUPLICADOS Y BLOQUEO DE CONCURRENCIA
|
||||
if (inFlightMessages.has(whatsappMessageId)) {
|
||||
console.log(`🛡️ [DripZone Webhook] Mensaje ${whatsappMessageId} actualmente en procesamiento concurrente. Omitiendo duplicado.`);
|
||||
return NextResponse.json({ success: true, ignored: 'Mensaje actualmente en procesamiento concurrente' });
|
||||
}
|
||||
|
||||
if (isDuplicateMessage(whatsappMessageId, messageContent)) {
|
||||
return NextResponse.json({ success: true, ignored: 'Mensaje duplicado en ráfaga ignorado' });
|
||||
}
|
||||
@ -381,6 +403,10 @@ export async function POST(request: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
inFlightMessages.add(whatsappMessageId);
|
||||
|
||||
try {
|
||||
|
||||
// 3. EXTRACCIÓN MULTIMEDIA (IMAGEN HD Y THUMBNAIL)
|
||||
let imageBase64: string | undefined = data.base64 || undefined;
|
||||
|
||||
@ -503,6 +529,25 @@ export async function POST(request: Request) {
|
||||
const relativeImageUrl = `/api/whatsapp-media?id=${encodeURIComponent(whatsappMessageId)}&remoteJid=${encodeURIComponent(remoteJid)}`;
|
||||
const fullMediaUrl = `https://drip-zone.online/api/whatsapp-media?id=${encodeURIComponent(whatsappMessageId)}&remoteJid=${encodeURIComponent(remoteJid)}`;
|
||||
|
||||
// Verificar si un producto con este modelo idéntico fue publicado en los últimos 10 minutos
|
||||
const isAlreadyPublishedRecently = existingProducts.some((p) => {
|
||||
if (p.whatsappMessageId === whatsappMessageId) return true;
|
||||
if (p.name && prodName && p.name.toLowerCase().trim() === prodName.toLowerCase().trim()) {
|
||||
const pDate = new Date(p.publishedAt || (p as any).createdAt || 0).getTime();
|
||||
return Date.now() - pDate < 600000;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (isAlreadyPublishedRecently) {
|
||||
console.log(`🛡️ [DripZone Webhook] Producto idéntico detectado ("${prodName}"). Evitando publicación duplicada.`);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ignored: 'Producto idéntico ya publicado recientemente',
|
||||
name: prodName,
|
||||
});
|
||||
}
|
||||
|
||||
// 6. CREACIÓN Y PUBLICACIÓN DEL PRODUCTO EN LA APP Y CATÁLOGO
|
||||
const newProduct: Product = {
|
||||
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
@ -579,6 +624,9 @@ export async function POST(request: Request) {
|
||||
post: newPost,
|
||||
notification: notificationResult,
|
||||
});
|
||||
} finally {
|
||||
inFlightMessages.delete(whatsappMessageId);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error general en Webhook WhatsApp DripZone:', error);
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
|
||||
@ -145,7 +145,7 @@ export default function MisPedidosPage() {
|
||||
case 'supplier_notified':
|
||||
return {
|
||||
label: 'En Preparación',
|
||||
sublabel: 'Verificando horma y empaque con proveedor',
|
||||
sublabel: 'Verificando talla EUR y empaque con proveedor',
|
||||
badgeColor: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
|
||||
icon: Package,
|
||||
group: 'processing',
|
||||
@ -574,7 +574,7 @@ export default function MisPedidosPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-neutral-400 mt-0.5">
|
||||
Total: <strong className="text-neutral-200">{totalPairs} {totalPairs === 1 ? 'par' : 'pares'}</strong> • Horma Nacional
|
||||
Total: <strong className="text-neutral-200">{totalPairs} {totalPairs === 1 ? 'par' : 'pares'}</strong> • Tallas EUR
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -781,7 +781,7 @@ export default function MisPedidosPage() {
|
||||
<div className="pt-3 border-t border-neutral-800 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-[11px] text-neutral-400 flex items-center gap-1.5">
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||
<span>Tu pedido incluye garantía y primer cambio de talla gratuito (Horma Nacional).</span>
|
||||
<span>Tu pedido incluye garantía y primer cambio de talla gratuito (Tallas EUR).</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@ -172,7 +172,7 @@ export function WhatsAppOrderLinkModal({
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-neutral-300 flex items-center justify-between">
|
||||
<span>Talla Acordada con el Cliente:</span>
|
||||
<span className="text-emerald-400 font-mono text-[10px]">Horma Nacional</span>
|
||||
<span className="text-emerald-400 font-mono text-[10px]">Tallas EUR</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{availableSizes.map((size) => (
|
||||
|
||||
258
dripzone-app/src/components/store/FormattedDescription.tsx
Normal file
258
dripzone-app/src/components/store/FormattedDescription.tsx
Normal file
@ -0,0 +1,258 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Sparkles, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
interface FormattedDescriptionProps {
|
||||
description?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea texto con formato tipo WhatsApp/Markdown:
|
||||
* - *texto* o **texto** -> negrita
|
||||
* - _texto_ -> cursiva
|
||||
*/
|
||||
export function renderInlineFormatting(text: string): React.ReactNode {
|
||||
if (!text) return null;
|
||||
|
||||
// Manejo de negritas (*texto* o **texto**)
|
||||
const boldParts = text.split(/(\*\*?[^*]+\*\*?)/g);
|
||||
|
||||
return boldParts.map((bPart, bIdx) => {
|
||||
const isBold =
|
||||
(bPart.startsWith('**') && bPart.endsWith('**') && bPart.length > 4) ||
|
||||
(bPart.startsWith('*') && bPart.endsWith('*') && bPart.length > 2);
|
||||
|
||||
const cleanB = isBold ? bPart.replace(/^\*+|\*+$/g, '') : bPart;
|
||||
|
||||
// Dentro del fragmento, procesar cursivas _texto_
|
||||
const italicParts = cleanB.split(/(_[^_]+_)/g);
|
||||
const content = italicParts.map((iPart, iIdx) => {
|
||||
if (iPart.startsWith('_') && iPart.endsWith('_') && iPart.length > 2) {
|
||||
return (
|
||||
<em key={iIdx} className="italic text-neutral-300">
|
||||
{iPart.slice(1, -1)}
|
||||
</em>
|
||||
);
|
||||
}
|
||||
return iPart;
|
||||
});
|
||||
|
||||
if (isBold) {
|
||||
return (
|
||||
<strong key={bIdx} className="text-white font-bold tracking-tight">
|
||||
{content}
|
||||
</strong>
|
||||
);
|
||||
}
|
||||
return <React.Fragment key={bIdx}>{content}</React.Fragment>;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta si una línea es una viñeta o punto de característica
|
||||
*/
|
||||
function isBulletLine(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
return /^(?:[•\-\*]|✅|✔|🔹|🔸|▪️|▫️|\d+[\.\)])\s*/u.test(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta si una línea es una advertencia / aviso de disponibilidad
|
||||
*/
|
||||
function isAlertLine(line: string): boolean {
|
||||
const lower = line.toLowerCase();
|
||||
return (
|
||||
line.includes('⚠️') ||
|
||||
lower.includes('disponibilidad limit') ||
|
||||
lower.startsWith('*disponibilidad') ||
|
||||
lower.startsWith('disponibilidad:')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta si una línea es un encabezado de sección
|
||||
*/
|
||||
function isHeadingLine(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
if (isAlertLine(trimmed)) return false;
|
||||
if (trimmed.startsWith('✨')) return true;
|
||||
if (
|
||||
(trimmed.startsWith('*') && trimmed.endsWith('*') && trimmed.length < 75 && !trimmed.startsWith('* ')) ||
|
||||
(trimmed.startsWith('**') && trimmed.endsWith('**') && trimmed.length < 75)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const FormattedDescription: React.FC<FormattedDescriptionProps> = ({
|
||||
description,
|
||||
className = '',
|
||||
}) => {
|
||||
if (!description || !description.trim()) {
|
||||
return (
|
||||
<div className={`text-xs text-neutral-500 italic ${className}`}>
|
||||
Sin descripción disponible para este producto.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Normalizar saltos de línea (reemplazar literales \n escapados si los hubiera)
|
||||
const normalized = description
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.trim();
|
||||
|
||||
// Dividir en bloques separados por doble salto de línea (párrafos)
|
||||
const rawBlocks = normalized.split(/\n\s*\n+/);
|
||||
|
||||
return (
|
||||
<div className={`space-y-3.5 text-xs ${className}`}>
|
||||
{rawBlocks.map((block, blockIdx) => {
|
||||
// Dividir el bloque en líneas individuales
|
||||
const lines = block
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (lines.length === 0) return null;
|
||||
|
||||
// Caso 1: Todo el bloque es de advertencia o disponibilidad
|
||||
if (lines.some(isAlertLine) && lines.length <= 3) {
|
||||
return (
|
||||
<div
|
||||
key={blockIdx}
|
||||
className="flex items-start gap-2.5 p-3 rounded-2xl bg-amber-500/10 border border-amber-500/25 text-amber-200/90 shadow-sm"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1 text-xs leading-relaxed font-medium">
|
||||
{lines.map((line, lIdx) => (
|
||||
<p key={lIdx}>
|
||||
{renderInlineFormatting(line.replace(/^⚠️\s*/, ''))}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Caso 2: El bloque contiene viñetas (o una mezcla de título y viñetas)
|
||||
const hasBullets = lines.some(isBulletLine);
|
||||
if (hasBullets) {
|
||||
return (
|
||||
<div key={blockIdx} className="space-y-2">
|
||||
{lines.map((line, lIdx) => {
|
||||
// Alerta aislada dentro del bloque
|
||||
if (isAlertLine(line)) {
|
||||
return (
|
||||
<div
|
||||
key={lIdx}
|
||||
className="flex items-start gap-2.5 p-2.5 rounded-xl bg-amber-500/10 border border-amber-500/25 text-amber-200/90 shadow-sm mt-1"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div className="text-xs leading-relaxed font-medium">
|
||||
{renderInlineFormatting(line.replace(/^⚠️\s*/, ''))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Encabezado de sección (ej: ✨ *¿Por qué todos lo eligen?*)
|
||||
if (isHeadingLine(line)) {
|
||||
return (
|
||||
<div
|
||||
key={lIdx}
|
||||
className="pt-2 pb-1 border-t border-neutral-800/80 flex items-center gap-2"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-purple-400 shrink-0" />
|
||||
<h4 className="font-extrabold text-white text-xs uppercase tracking-wider">
|
||||
{renderInlineFormatting(line.replace(/^✨\s*/, ''))}
|
||||
</h4>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Viñeta de característica
|
||||
if (isBulletLine(line)) {
|
||||
// Limpiar el marcador inicial de viñeta
|
||||
const cleanBullet = line.replace(/^(?:[•\-\*]|✅|✔|🔹|🔸|▪️|▫️|\d+[\.\)])\s*/u, '').trim();
|
||||
|
||||
// Extraer emoji si la viñeta tiene uno al inicio (ej: 💎, ☁️, 🚚)
|
||||
const emojiMatch = cleanBullet.match(/^(\p{Extended_Pictographic}|\p{Emoji_Presentation})\s*/u);
|
||||
const leadingEmoji = emojiMatch ? emojiMatch[1] : null;
|
||||
const textWithoutEmoji = emojiMatch
|
||||
? cleanBullet.slice(emojiMatch[0].length).trim()
|
||||
: cleanBullet;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lIdx}
|
||||
className="flex items-start gap-2.5 p-2.5 rounded-xl bg-neutral-900/60 border border-neutral-800/80 hover:border-neutral-700/80 transition-colors"
|
||||
>
|
||||
{leadingEmoji ? (
|
||||
<span className="text-sm shrink-0 mt-0.5 select-none">{leadingEmoji}</span>
|
||||
) : (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-400 shrink-0 mt-2 shadow-xs shadow-purple-500/50" />
|
||||
)}
|
||||
<div className="text-neutral-300 leading-relaxed flex-1">
|
||||
{renderInlineFormatting(textWithoutEmoji)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Párrafo estándar dentro del bloque
|
||||
return (
|
||||
<p key={lIdx} className="text-neutral-300 leading-relaxed">
|
||||
{renderInlineFormatting(line)}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Caso 3: Encabezado aislado
|
||||
if (lines.length === 1 && isHeadingLine(lines[0])) {
|
||||
return (
|
||||
<div key={blockIdx} className="pt-2 pb-1 border-t border-neutral-800/80 flex items-center gap-2">
|
||||
<Sparkles className="w-3.5 h-3.5 text-purple-400 shrink-0" />
|
||||
<h4 className="font-extrabold text-white text-xs uppercase tracking-wider">
|
||||
{renderInlineFormatting(lines[0].replace(/^✨\s*/, ''))}
|
||||
</h4>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Caso 4: Párrafo gancho inicial con emoji de fuego o similar
|
||||
if (lines[0].startsWith('🔥')) {
|
||||
return (
|
||||
<div
|
||||
key={blockIdx}
|
||||
className="p-3 rounded-2xl bg-neutral-900/40 border border-neutral-800/60 text-neutral-200 leading-relaxed whitespace-pre-line"
|
||||
>
|
||||
{lines.map((line, lIdx) => (
|
||||
<p key={lIdx} className={lIdx > 0 ? 'mt-1.5' : ''}>
|
||||
{renderInlineFormatting(line)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Caso 5: Párrafo estándar con saltos de línea preservados
|
||||
return (
|
||||
<div key={blockIdx} className="space-y-1.5">
|
||||
{lines.map((line, lIdx) => (
|
||||
<p key={lIdx} className="text-neutral-300/90 leading-relaxed">
|
||||
{renderInlineFormatting(line)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -19,6 +19,7 @@ import { formatCOP, calculateSeparationBreakdown, getProductEffectivePrice } fro
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
import { getProductSocialProof } from '@/lib/social-proof';
|
||||
import { OptimizedImage } from '@/components/ui/OptimizedImage';
|
||||
import { FormattedDescription } from './FormattedDescription';
|
||||
|
||||
|
||||
interface ProductModalProps {
|
||||
@ -285,10 +286,10 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
<div className="space-y-2 pt-1 border-t border-neutral-800/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-black text-white uppercase">
|
||||
Selecciona tu Talla (CO / Horma Nacional):
|
||||
Selecciona tu Talla (EUR):
|
||||
</span>
|
||||
<span className="text-[11px] text-emerald-400 font-bold flex items-center gap-1">
|
||||
<Check className="w-3 h-3" /> Horma Nacional
|
||||
<span className="text-[11px] text-purple-400 font-bold flex items-center gap-1">
|
||||
<Check className="w-3 h-3" /> Tallas EUR
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -310,7 +311,7 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
>
|
||||
<span>{size}</span>
|
||||
<span className="text-[8px] font-mono leading-none mt-0.5 opacity-80">
|
||||
{hasCustomSizePrice ? `$${Math.round(sizePrice / 1000)}k` : 'CO'}
|
||||
{hasCustomSizePrice ? `$${Math.round(sizePrice / 1000)}k` : 'EUR'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@ -319,8 +320,12 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="text-xs text-neutral-400 leading-relaxed border-t border-neutral-800 pt-3">
|
||||
<p>{product.description}</p>
|
||||
<div className="border-t border-neutral-800/80 pt-3.5">
|
||||
<h4 className="text-[11px] font-black uppercase text-neutral-400 tracking-wider mb-2.5 flex items-center gap-1.5">
|
||||
<Sparkles className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>Detalles y Especificaciones</span>
|
||||
</h4>
|
||||
<FormattedDescription description={product.description} />
|
||||
</div>
|
||||
|
||||
{/* Shipping note */}
|
||||
|
||||
@ -294,11 +294,44 @@ export async function fetchRecentMediaFromSameChat(
|
||||
* Notifica inmediatamente la publicación automática de un nuevo producto
|
||||
* exclusivamente al WhatsApp personal de Carlos (+57 316 745 5877).
|
||||
*/
|
||||
const dispatchedNotificationsCache = new Map<string, number>();
|
||||
|
||||
export async function notifyNewProductPublished(
|
||||
product: any,
|
||||
sourceGroup: string,
|
||||
mediaUrl?: string
|
||||
): Promise<{ success: boolean; dispatchedTo: string[] }> {
|
||||
const prodId = product?.id || '';
|
||||
const prodName = (product?.name || '').trim().toLowerCase();
|
||||
const msgId = product?.whatsappMessageId || '';
|
||||
const dedupKey = `${prodId}:${msgId}:${prodName}`;
|
||||
const now = Date.now();
|
||||
|
||||
// Limpieza de claves viejas (> 30 minutos)
|
||||
for (const [k, time] of dispatchedNotificationsCache.entries()) {
|
||||
if (now - time > 1800000) {
|
||||
dispatchedNotificationsCache.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
const lastDispatched =
|
||||
dispatchedNotificationsCache.get(dedupKey) ||
|
||||
(prodName ? dispatchedNotificationsCache.get(`name:${prodName}`) : 0) ||
|
||||
(msgId ? dispatchedNotificationsCache.get(`msg:${msgId}`) : 0);
|
||||
|
||||
if (lastDispatched && now - lastDispatched < 600000) {
|
||||
console.log(
|
||||
`🛡️ [notifyNewProductPublished] Notificación duplicada evitada para "${product?.name}" (despachada hace ${Math.round(
|
||||
(now - lastDispatched) / 1000
|
||||
)}s). No se reenvía a Carlos.`
|
||||
);
|
||||
return { success: true, dispatchedTo: [] };
|
||||
}
|
||||
|
||||
dispatchedNotificationsCache.set(dedupKey, now);
|
||||
if (prodName) dispatchedNotificationsCache.set(`name:${prodName}`, now);
|
||||
if (msgId) dispatchedNotificationsCache.set(`msg:${msgId}`, now);
|
||||
|
||||
const NOTIFICATION_TARGETS = [
|
||||
'573167455877', // Carlos (WhatsApp personal exclusivo)
|
||||
];
|
||||
@ -324,7 +357,7 @@ export async function notifyNewProductPublished(
|
||||
`💵 *Valor de Venta (Público):* *${retailFormatted} COP*\n` +
|
||||
`🏭 *Costo Proveedor:* *${supplierFormatted} COP*\n` +
|
||||
`💰 *Ganancia Neta Estimada:* *+${profitFormatted} COP* (~${profitMarginPercent}% margen)\n` +
|
||||
`📏 *Tallas Disponibles:* *${sizesText}*\n` +
|
||||
`📏 *Tallas Disponibles:* *${sizesText}* (Tallas EUR)\n` +
|
||||
`🎨 *Estilo / Color:* ${product.colorName || 'Estilo Principal'}\n` +
|
||||
`📦 *Grupo Proveedor Origen:* ${sourceGroup || 'Canal Mayorista'}\n\n` +
|
||||
`🛍️ *Canales de Publicación Activos:*\n` +
|
||||
|
||||
@ -190,7 +190,7 @@ export function generateSalesCopy(params: {
|
||||
return `✨ ${storeName.toUpperCase()} • LANZAMIENTO EXCLUSIVO ✨
|
||||
👟 ${productName.toUpperCase()} (${brand})
|
||||
|
||||
📏 Tallas Disponibles: ${sizesFormatted} (Horma Nacional)
|
||||
📏 Tallas Disponibles: ${sizesFormatted} (EUR)
|
||||
💎 PRECIO DE VENTA: ${formatCOP(retailPrice)} COP
|
||||
|
||||
🚚 ¡DOMICILIO EN ${cityName.toUpperCase()} Y ENVÍOS A TODO EL PAÍS!
|
||||
|
||||
@ -177,7 +177,7 @@ Estamos alistando tu paquete. Te enviaremos el número de guía o notificación
|
||||
content: `Hola {tienda_nombre}! 👋 Quiero pedir las zapatillas:
|
||||
👟 *{zapatilla_nombre}*
|
||||
🎨 Color: *{color_nombre}*
|
||||
📏 Talla: *{talla}* (Horma Nacional)
|
||||
📏 Talla: *{talla}* (EUR)
|
||||
💎 Precio: *{precio} COP*
|
||||
🚚 Entrega en: *{ciudad}* (Pago Contraentrega)
|
||||
⚡ Deseo separar con $10.000 COP y cancelar el saldo en mi casa.
|
||||
@ -186,7 +186,7 @@ Estamos alistando tu paquete. Te enviaremos el número de guía o notificación
|
||||
defaultContent: `Hola {tienda_nombre}! 👋 Quiero pedir las zapatillas:
|
||||
👟 *{zapatilla_nombre}*
|
||||
🎨 Color: *{color_nombre}*
|
||||
📏 Talla: *{talla}* (Horma Nacional)
|
||||
📏 Talla: *{talla}* (EUR)
|
||||
💎 Precio: *{precio} COP*
|
||||
🚚 Entrega en: *{ciudad}* (Pago Contraentrega)
|
||||
⚡ Deseo separar con $10.000 COP y cancelar el saldo en mi casa.
|
||||
@ -626,7 +626,7 @@ Cualquier duda o asesoría adicional, ¡estamos para servirte con todo el gusto!
|
||||
content: `Colección: {coleccion_icono} *{coleccion_nombre} {tienda_nombre}*
|
||||
{descripcion_producto}
|
||||
|
||||
📏 Tallas disponibles: {tallas} (Horma Nacional)
|
||||
📏 Tallas disponibles: {tallas} (EUR)
|
||||
💎 Precio oficial: {precio} COP
|
||||
🚚 Envíos a toda Colombia (el valor del flete y tiempo de entrega varían según la ciudad de destino)
|
||||
💵 Pago contraentrega sujeto a cobertura de tu ciudad
|
||||
@ -641,7 +641,7 @@ La atención comercial y asesoría es por este WhatsApp. Para garantizar tu desp
|
||||
defaultContent: `Colección: {coleccion_icono} *{coleccion_nombre} {tienda_nombre}*
|
||||
{descripcion_producto}
|
||||
|
||||
📏 Tallas disponibles: {tallas} (Horma Nacional)
|
||||
📏 Tallas disponibles: {tallas} (EUR)
|
||||
💎 Precio oficial: {precio} COP
|
||||
🚚 Envíos a toda Colombia (el valor del flete y tiempo de entrega varían según la ciudad de destino)
|
||||
💵 Pago contraentrega sujeto a cobertura de tu ciudad
|
||||
@ -657,7 +657,7 @@ La atención comercial y asesoría es por este WhatsApp. Para garantizar tu desp
|
||||
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Nombre de la marca' },
|
||||
{ key: 'coleccion_icono', label: 'Ícono Colección', example: '👟', description: 'Emoji de categoría' },
|
||||
{ key: 'coleccion_nombre', label: 'Nombre Colección', example: 'Zapatillas Urbanas', description: 'Categoría WhatsApp' },
|
||||
{ key: 'descripcion_producto', label: 'Descripción Base', example: 'Zapatillas exclusivas en horma nacional...', description: 'Detalle del modelo' },
|
||||
{ key: 'descripcion_producto', label: 'Descripción Base', example: 'Zapatillas exclusivas en numeración EUR...', description: 'Detalle del modelo' },
|
||||
{ key: 'tallas', label: 'Tallas Disponibles', example: '37 al 43', description: 'Curva de tallas' },
|
||||
{ key: 'precio', label: 'Precio COP', example: '$125.000', description: 'Precio de venta' },
|
||||
{ key: 'enlace_compra', label: 'Enlace Directo Checkout', example: 'https://drip-zone.online/checkout?productId=...', description: 'Link de compra' },
|
||||
@ -673,7 +673,7 @@ La atención comercial y asesoría es por este WhatsApp. Para garantizar tu desp
|
||||
isActive: true,
|
||||
content: `{saludo} Qué gusto saludarte de parte de *{tienda_nombre} Colombia* 🇨🇴
|
||||
|
||||
Para confirmar tu pedido de las *{producto_nombre}* en talla *{talla}* (Horma Nacional) y despacharlo con total garantía y factura:
|
||||
Para confirmar tu pedido de las *{producto_nombre}* en talla *{talla}* (EUR) y despacharlo con total garantía y factura:
|
||||
|
||||
💎 *Precio Oficial:* {precio} COP
|
||||
🚚 *Pago Contraentrega:* Separas con solo *$10.000* a través de Bold oficial (PSE o tarjetas) y el saldo restante lo cancelas contraentrega (en efectivo o mediante Bold) al recibir en tu puerta.
|
||||
@ -690,7 +690,7 @@ Para confirmar tu pedido de las *{producto_nombre}* en talla *{talla}* (Horma Na
|
||||
Cualquier inquietud con tu dirección o el pago, ¡aquí estamos para ayudarte!`,
|
||||
defaultContent: `{saludo} Qué gusto saludarte de parte de *{tienda_nombre} Colombia* 🇨🇴
|
||||
|
||||
Para confirmar tu pedido de las *{producto_nombre}* en talla *{talla}* (Horma Nacional) y despacharlo con total garantía y factura:
|
||||
Para confirmar tu pedido de las *{producto_nombre}* en talla *{talla}* (EUR) y despacharlo con total garantía y factura:
|
||||
|
||||
💎 *Precio Oficial:* {precio} COP
|
||||
🚚 *Pago Contraentrega:* Separas con solo *$10.000* a través de Bold oficial (PSE o tarjetas) y el saldo restante lo cancelas contraentrega (en efectivo o mediante Bold) al recibir en tu puerta.
|
||||
@ -759,7 +759,7 @@ Cualquier inquietud con tu dirección o el pago, ¡aquí estamos para ayudarte!`
|
||||
isActive: true,
|
||||
content: `👟 *{producto_nombre}*
|
||||
━━━━━━━━━━━━━━━━━━━━━━
|
||||
📏 *Tallas Disponibles:* {tallas} (Horma Nacional)
|
||||
📏 *Tallas Disponibles:* {tallas} (EUR)
|
||||
💵 *Precio Oficial:* *{precio} COP*
|
||||
🚚 *Pago Contraentrega:* Pagas un anticipo de *$10.000* por Bold oficial para despacho garantizado y el saldo restante al recibir (en efectivo o mediante Bold).
|
||||
📦 *Envío:* El costo del flete depende de la ciudad de destino.
|
||||
@ -774,7 +774,7 @@ Para tu seguridad, asignación de factura y reserva de tu talla, el pedido y pag
|
||||
Diligencia tus datos de envío en el enlace y hoy mismo programamos tu despacho. Si necesitas ayuda con tu talla habitual, ¡aquí estamos para asesorarte!`,
|
||||
defaultContent: `👟 *{producto_nombre}*
|
||||
━━━━━━━━━━━━━━━━━━━━━━
|
||||
📏 *Tallas Disponibles:* {tallas} (Horma Nacional)
|
||||
📏 *Tallas Disponibles:* {tallas} (EUR)
|
||||
💵 *Precio Oficial:* *{precio} COP*
|
||||
🚚 *Pago Contraentrega:* Pagas un anticipo de *$10.000* por Bold oficial para despacho garantizado y el saldo restante al recibir (en efectivo o mediante Bold).
|
||||
📦 *Envío:* El costo del flete depende de la ciudad de destino.
|
||||
@ -803,7 +803,7 @@ Diligencia tus datos de envío en el enlace y hoy mismo programamos tu despacho.
|
||||
channel: 'whatsapp',
|
||||
isActive: true,
|
||||
content: `¡Hola! 👋 Te damos la bienvenida a *{tienda_nombre} Colombia* 🇨🇴
|
||||
Tu tienda oficial de sneakers, zapatillas y streetwear con horma nacional.
|
||||
Tu tienda oficial de sneakers, zapatillas y streetwear en tallas EUR.
|
||||
━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
👀 *Puedes consultar nuestro catálogo y modelos aquí en WhatsApp o en nuestra tienda online:*
|
||||
@ -820,7 +820,7 @@ Tu tienda oficial de sneakers, zapatillas y streetwear con horma nacional.
|
||||
|
||||
💬 *¿Te gustó algún modelo de nuestro catálogo de WhatsApp?* Indícanos el nombre o tu talla y con gusto te asesoramos y enviamos el enlace directo.`,
|
||||
defaultContent: `¡Hola! 👋 Te damos la bienvenida a *{tienda_nombre} Colombia* 🇨🇴
|
||||
Tu tienda oficial de sneakers, zapatillas y streetwear con horma nacional.
|
||||
Tu tienda oficial de sneakers, zapatillas y streetwear en tallas EUR.
|
||||
━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
👀 *Puedes consultar nuestro catálogo y modelos aquí en WhatsApp o en nuestra tienda online:*
|
||||
@ -881,14 +881,14 @@ Tu tienda oficial de sneakers, zapatillas y streetwear con horma nacional.
|
||||
isActive: true,
|
||||
content: `🛡️ *Políticas de Garantía y Cambio de Talla - {tienda_nombre}*
|
||||
|
||||
👟 *Horma Nacional:* Todos nuestros modelos cuentan con horma nacional colombiana estándar.
|
||||
👟 *Tallas EUR:* Todos nuestros modelos se manejan en numeración estándar EUR.
|
||||
🔄 *Cambios de Talla:* La posibilidad y condiciones del cambio de talla dependen de la cobertura logística de cada ciudad:
|
||||
• *Cali y cobertura metropolitana:* Primer cambio de talla coordinado con nuestro mensajero local.
|
||||
• *Nivel Nacional:* Cambio de talla garantizado sujeto a disponibilidad de inventario; el flete logístico del reenvío se coordina según la ciudad de destino.
|
||||
✅ *Garantía de Fábrica:* Respaldo total por defectos de manufactura en todos los envíos.`,
|
||||
defaultContent: `🛡️ *Políticas de Garantía y Cambio de Talla - {tienda_nombre}*
|
||||
|
||||
👟 *Horma Nacional:* Todos nuestros modelos cuentan con horma nacional colombiana estándar.
|
||||
👟 *Tallas EUR:* Todos nuestros modelos se manejan en numeración estándar EUR.
|
||||
🔄 *Cambios de Talla:* La posibilidad y condiciones del cambio de talla dependen de la cobertura logística de cada ciudad:
|
||||
• *Cali y cobertura metropolitana:* Primer cambio de talla coordinado con nuestro mensajero local.
|
||||
• *Nivel Nacional:* Cambio de talla garantizado sujeto a disponibilidad de inventario; el flete logístico del reenvío se coordina según la ciudad de destino.
|
||||
|
||||
@ -35,7 +35,7 @@ export const WHATSAPP_COLLECTIONS: Record<string, WhatsAppCatalogCollection> = {
|
||||
productType: 'Calzado > Sneakers y Zapatillas',
|
||||
googleCategory: 'Apparel & Accessories > Shoes',
|
||||
googleCategoryId: '187',
|
||||
description: 'Sneakers urbanos, zapatillas deportivas y calzado casual en horma nacional.',
|
||||
description: 'Sneakers urbanos, zapatillas deportivas y calzado casual en numeración EUR.',
|
||||
},
|
||||
gorras: {
|
||||
id: 'gorras',
|
||||
@ -210,7 +210,7 @@ export function formatProductForWhatsAppCatalog(product: Product) {
|
||||
|
||||
// Descripción optimizada para WhatsApp Catalog utilizando la plantilla configurable
|
||||
const fallbackProductDesc =
|
||||
product.description || `${collection.name} ${title} exclusivas en horma nacional.`;
|
||||
product.description || `${collection.name} ${title} exclusivas en numeración EUR.`;
|
||||
|
||||
const catalogDescription = dataService.renderMessageTemplate('catalog_product_desc', {
|
||||
tienda_nombre: 'DripZone',
|
||||
|
||||
116
scripts/deploy_and_send_monitoring_fix.py
Normal file
116
scripts/deploy_and_send_monitoring_fix.py
Normal file
@ -0,0 +1,116 @@
|
||||
import paramiko
|
||||
import os
|
||||
import urllib.request
|
||||
import json
|
||||
import ssl
|
||||
import sys
|
||||
import io
|
||||
|
||||
if sys.platform == 'win32':
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
HOST = "161.97.93.156"
|
||||
USER = "carlos"
|
||||
PASSWORD = "Innova1978+*?"
|
||||
LOCAL_APK = r"c:\Users\Carlos\dev\almeira-web\dripzone-app\public\DripZone.apk"
|
||||
REMOTE_DIR = "/home/carlos/apps/dripzone-app/public"
|
||||
REMOTE_APK = "/home/carlos/apps/dripzone-app/public/DripZone.apk"
|
||||
|
||||
EVOLUTION_API_URL = "https://evolution.almeira.com.co"
|
||||
EVOLUTION_API_KEY = "EE49029EEB85-4568-8F81-C9FDFC6E2943"
|
||||
INSTANCE_NAME = "AutomataAlmeira"
|
||||
TARGET_PHONE = "573167455877"
|
||||
APK_URL = "https://drip-zone.online/DripZone.apk"
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
def upload_apk():
|
||||
print(f"[1/3] Conectando a VPS {HOST} vía SSH/SFTP...")
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
client.connect(hostname=HOST, username=USER, password=PASSWORD, timeout=30)
|
||||
|
||||
sftp = client.open_sftp()
|
||||
local_size_mb = os.path.getsize(LOCAL_APK) / (1024 * 1024)
|
||||
print(f" -> Subiendo {LOCAL_APK} ({local_size_mb:.2f} MB) a {REMOTE_APK}...")
|
||||
try:
|
||||
sftp.mkdir("/home/carlos/apps/dripzone-app")
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
sftp.mkdir(REMOTE_DIR)
|
||||
except:
|
||||
pass
|
||||
sftp.put(LOCAL_APK, REMOTE_APK)
|
||||
sftp.close()
|
||||
print(" [OK] Archivo subido por SFTP al VPS.")
|
||||
|
||||
print("[2/3] Copiando APK dentro del contenedor Docker en producción...")
|
||||
stdin, stdout, stderr = client.exec_command("docker ps --format '{{.Names}}'")
|
||||
containers = [c.strip() for c in stdout.read().decode('utf-8').splitlines() if c.strip()]
|
||||
drip_containers = [c for c in containers if 'bfmno' in c.lower() or 'drip' in c.lower()]
|
||||
for c in drip_containers:
|
||||
cmd = f"docker cp {REMOTE_APK} {c}:/app/public/DripZone.apk 2>/dev/null || docker cp {REMOTE_APK} {c}:/app/DripZone.apk 2>/dev/null || true"
|
||||
client.exec_command(cmd)
|
||||
print(f" -> Copiado a contenedor web: {c}")
|
||||
|
||||
client.close()
|
||||
print(" [OK] APK actualizado en el servidor de producción.")
|
||||
|
||||
def send_whatsapp_notification():
|
||||
print("[3/3] Enviando APK y notificación oficial al WhatsApp de Carlos (+57 316 745 5877)...")
|
||||
|
||||
message_text = (
|
||||
"🛡️ *DripZone - Corrección Crítica de Monitoreo de Grupos & Sanitización Total*\n\n"
|
||||
"Hola Carlos, se ha corregido y blindado por completo el sistema de escucha de WhatsApp:\n\n"
|
||||
"🚫 *1. Exclusión Permanente de Grupos No Deseados:*\n"
|
||||
"• *SOMOS BLACK DIAMONDS* (`120363400148850031@g.us`) y *TEAM EMPORIO* (`120363420942364759@g.us`) han sido agregados a una lista negra estricta e incondicional.\n"
|
||||
"• Se eliminó el auto-registro que los volvía a activar en cada reinicio/despliegue del servidor.\n"
|
||||
"• En la base de datos Appwrite y en el panel administrativo quedaron marcados permanentemente como: *⛔ IGNORADO EXPLICITAMENTE*.\n\n"
|
||||
"🧹 *2. Eliminación de 22 Publicaciones Erróneas:* \n"
|
||||
"• Se eliminaron por completo de Appwrite y de la tienda todos los mensajes ajenos a calzado que se habían colado de esos grupos (charlas de Zoom, conferencias, podcasts, anuncios de liderazgo, smoothies, ropa y relojes).\n\n"
|
||||
"🔒 *3. Filtro Antispam y Validación de Calzado en Webhook:*\n"
|
||||
"• El webhook descarta automáticamente cualquier mensaje de grupos no monitoreados.\n"
|
||||
"• Si un mensaje contiene palabras de eventos, webinars, reuniones o contenido ajeno al calzado, es rechazado de inmediato y nunca se publicará.\n\n"
|
||||
"📦 *4. App Android Compilada & Actualizada:*\n"
|
||||
"• APK compilado limpiamente y optimizado (35.5 MB).\n"
|
||||
f"🔗 *Descarga Directa:* {APK_URL}\n\n"
|
||||
"Te adjunto el APK instalador a continuación."
|
||||
)
|
||||
|
||||
req_text = urllib.request.Request(
|
||||
f"{EVOLUTION_API_URL}/message/sendText/{INSTANCE_NAME}",
|
||||
data=json.dumps({"number": TARGET_PHONE, "text": message_text, "delay": 500}).encode("utf-8"),
|
||||
headers={"apikey": EVOLUTION_API_KEY, "Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req_text, context=ctx, timeout=30) as resp:
|
||||
print(" [OK] Mensaje de texto enviado a WhatsApp (Status:", resp.status, ")")
|
||||
except Exception as e:
|
||||
print(" [Error texto WhatsApp]:", e)
|
||||
|
||||
# Enviar documento APK
|
||||
req_media = urllib.request.Request(
|
||||
f"{EVOLUTION_API_URL}/message/sendMedia/{INSTANCE_NAME}",
|
||||
data=json.dumps({
|
||||
"number": TARGET_PHONE,
|
||||
"mediatype": "document",
|
||||
"mimetype": "application/vnd.android.package-archive",
|
||||
"fileName": "DripZone_Correccion_Grupos.apk",
|
||||
"caption": "📲 *DripZone APK Actualizado - Blindaje de Grupos & Sanitización*",
|
||||
"media": APK_URL
|
||||
}).encode("utf-8"),
|
||||
headers={"apikey": EVOLUTION_API_KEY, "Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req_media, context=ctx, timeout=180) as resp:
|
||||
print(" [OK] Documento APK enviado a WhatsApp (Status:", resp.status, ")")
|
||||
except Exception as e:
|
||||
print(" [Error envío documento APK]:", e)
|
||||
|
||||
if __name__ == "__main__":
|
||||
upload_apk()
|
||||
send_whatsapp_notification()
|
||||
Loading…
Reference in New Issue
Block a user