6d08a5b044
Título e textos da página /baileys ficam genéricos ("Monitor de Eventos")
para usuários comuns; "Baileys Engine"/"servidor Baileys" só p/ admin ou
ruibto@gmail.com.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
797 lines
37 KiB
TypeScript
797 lines
37 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import {
|
|
Zap, X, ChevronDown, ChevronRight, Wifi, WifiOff, MessageSquare,
|
|
Send, Image, FileText, Video, Music, Phone, Users, RefreshCw,
|
|
CheckCheck, AlertCircle, QrCode, Link, Unlink, Trash2, Activity,
|
|
ArrowDownLeft, ArrowUpRight, Filter, LayoutGrid, ShieldCheck, Clock
|
|
} from 'lucide-react';
|
|
import { io, Socket } from 'socket.io-client';
|
|
import { useAuthStore } from '../../store/authStore';
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
type EventCategory = 'all' | 'connection' | 'received' | 'sent' | 'media' | 'chat' | 'system';
|
|
|
|
interface BaileysEvent {
|
|
id: string;
|
|
timestamp: number;
|
|
sessionId: string;
|
|
event: string;
|
|
data: any;
|
|
category: EventCategory;
|
|
mediaType?: 'image' | 'video' | 'audio' | 'document' | 'sticker';
|
|
fromMe?: boolean;
|
|
}
|
|
|
|
interface SessionStats {
|
|
received: number;
|
|
sent: number;
|
|
media: number;
|
|
chats: number;
|
|
}
|
|
|
|
interface SessionInfo {
|
|
id: string;
|
|
name?: string;
|
|
status: 'connected' | 'disconnected' | 'connecting' | 'qr';
|
|
lastSeen: number;
|
|
eventCount: number;
|
|
events: BaileysEvent[];
|
|
engine?: 'infinite' | 'official';
|
|
dbStats?: SessionStats; // contagens do banco (carregadas no mount)
|
|
liveStats?: Partial<SessionStats>; // incrementos ao vivo (Socket.IO)
|
|
}
|
|
|
|
// ─── Método de envio: labels para o usuário final ─────────────────────────────
|
|
const METODOS = [
|
|
{
|
|
value: 'infinite' as const,
|
|
label: 'Método 1',
|
|
desc: 'Com botões',
|
|
hint: 'Instável',
|
|
icon: <LayoutGrid size={11} />,
|
|
color: 'text-amber-400',
|
|
bg: 'bg-amber-500/10',
|
|
border: 'border-amber-500/30',
|
|
dot: 'bg-amber-400',
|
|
},
|
|
{
|
|
value: 'official' as const,
|
|
label: 'Método 2',
|
|
desc: 'Sem botões',
|
|
hint: 'Estável',
|
|
icon: <ShieldCheck size={11} />,
|
|
color: 'text-emerald-400',
|
|
bg: 'bg-emerald-500/10',
|
|
border: 'border-emerald-500/30',
|
|
dot: 'bg-emerald-400',
|
|
},
|
|
];
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function fmtCount(n: number): string {
|
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}k`;
|
|
return String(n);
|
|
}
|
|
|
|
function categorizeEvent(event: string, data: any): { category: EventCategory; mediaType?: BaileysEvent['mediaType']; fromMe?: boolean } {
|
|
const e = event.toLowerCase();
|
|
|
|
if (e.includes('connection') || e.includes('qr') || e.includes('connect') || e.includes('disconnect') || e.includes('open') || e.includes('close')) {
|
|
return { category: 'connection' };
|
|
}
|
|
if (e.includes('message') || e.includes('msg')) {
|
|
const fromMe = data?.fromMe ?? data?.key?.fromMe ?? false;
|
|
let mediaType: BaileysEvent['mediaType'] | undefined;
|
|
if (data?.imageMessage || data?.message?.imageMessage) mediaType = 'image';
|
|
else if (data?.videoMessage || data?.message?.videoMessage) mediaType = 'video';
|
|
else if (data?.audioMessage || data?.message?.audioMessage) mediaType = 'audio';
|
|
else if (data?.documentMessage || data?.message?.documentMessage) mediaType = 'document';
|
|
else if (data?.stickerMessage || data?.message?.stickerMessage) mediaType = 'sticker';
|
|
|
|
if (mediaType) return { category: 'media', mediaType, fromMe };
|
|
return { category: fromMe ? 'sent' : 'received', fromMe };
|
|
}
|
|
if (e.includes('chat')) return { category: 'chat' };
|
|
return { category: 'system' };
|
|
}
|
|
|
|
function parseData(raw: any): any {
|
|
if (typeof raw === 'string') {
|
|
try { return JSON.parse(raw); } catch { return raw; }
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
const EVENT_META: Record<EventCategory, { color: string; bg: string; label: string }> = {
|
|
all: { color: 'text-slate-300', bg: 'bg-slate-500/20', label: 'Todos' },
|
|
connection: { color: 'text-emerald-400', bg: 'bg-emerald-500/20', label: 'Conexão' },
|
|
received: { color: 'text-blue-400', bg: 'bg-blue-500/20', label: 'Recebidas' },
|
|
sent: { color: 'text-brand-400', bg: 'bg-brand-500/20', label: 'Enviadas' },
|
|
media: { color: 'text-purple-400', bg: 'bg-purple-500/20', label: 'Mídia' },
|
|
chat: { color: 'text-yellow-400', bg: 'bg-yellow-500/20', label: 'Chats' },
|
|
system: { color: 'text-slate-400', bg: 'bg-slate-500/20', label: 'Sistema' },
|
|
};
|
|
|
|
function EventIcon({ ev }: { ev: BaileysEvent }) {
|
|
const cls = 'w-4 h-4 shrink-0';
|
|
if (ev.category === 'connection') {
|
|
if (ev.event.toLowerCase().includes('qr')) return <QrCode className={`${cls} text-yellow-400`} />;
|
|
if (ev.event.toLowerCase().includes('disconnect') || ev.event.toLowerCase().includes('close')) return <Unlink className={`${cls} text-red-400`} />;
|
|
return <Link className={`${cls} text-emerald-400`} />;
|
|
}
|
|
if (ev.category === 'sent') return <ArrowUpRight className={`${cls} text-brand-400`} />;
|
|
if (ev.category === 'received') return <ArrowDownLeft className={`${cls} text-blue-400`} />;
|
|
if (ev.category === 'media') {
|
|
if (ev.mediaType === 'image') return <Image className={`${cls} text-purple-400`} />;
|
|
if (ev.mediaType === 'video') return <Video className={`${cls} text-pink-400`} />;
|
|
if (ev.mediaType === 'audio') return <Music className={`${cls} text-orange-400`} />;
|
|
if (ev.mediaType === 'document') return <FileText className={`${cls} text-cyan-400`} />;
|
|
return <Image className={`${cls} text-purple-400`} />;
|
|
}
|
|
if (ev.category === 'chat') return <MessageSquare className={`${cls} text-yellow-400`} />;
|
|
return <Activity className={`${cls} text-slate-400`} />;
|
|
}
|
|
|
|
function eventSummary(ev: BaileysEvent): string {
|
|
const d = ev.data;
|
|
if (!d) return ev.event;
|
|
if (ev.category === 'connection') {
|
|
if (d.connection) return `connection → ${d.connection}`;
|
|
if (typeof d === 'string') return d;
|
|
}
|
|
if (ev.category === 'sent' || ev.category === 'received') {
|
|
const text = d.body || d.message?.conversation || d.message?.extendedTextMessage?.text || d.text;
|
|
if (text) return String(text).slice(0, 80);
|
|
const jid = d.key?.remoteJid || d.remoteJid || '';
|
|
return jid ? `→ ${jid}` : ev.event;
|
|
}
|
|
if (ev.category === 'media') {
|
|
const jid = d.key?.remoteJid || d.remoteJid || '';
|
|
return `${ev.mediaType ?? 'media'} ${jid ? `→ ${jid}` : ''}`;
|
|
}
|
|
if (ev.category === 'chat') {
|
|
return d.id || d.jid || ev.event;
|
|
}
|
|
return ev.event;
|
|
}
|
|
|
|
// ─── EventRow ─────────────────────────────────────────────────────────────────
|
|
|
|
function EventRow({ ev, index }: { ev: BaileysEvent; index: number }) {
|
|
const [open, setOpen] = useState(false);
|
|
const meta = EVENT_META[ev.category];
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -4 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.15, delay: index < 20 ? index * 0.01 : 0 }}
|
|
className="border-b border-white/[0.04] last:border-0"
|
|
>
|
|
<button
|
|
onClick={() => setOpen(o => !o)}
|
|
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-white/[0.03] transition-colors text-left group"
|
|
>
|
|
{/* Index */}
|
|
<span className="text-[10px] font-mono text-slate-600 w-6 shrink-0 text-right">{index + 1}</span>
|
|
|
|
{/* Icon */}
|
|
<EventIcon ev={ev} />
|
|
|
|
{/* Event name badge */}
|
|
<span className={`text-[10px] font-black uppercase tracking-wider px-2 py-0.5 rounded-full shrink-0 ${meta.bg} ${meta.color}`}>
|
|
{ev.event}
|
|
</span>
|
|
|
|
{/* Summary */}
|
|
<span className="text-sm text-slate-400 truncate flex-1 font-mono">
|
|
{eventSummary(ev)}
|
|
</span>
|
|
|
|
{/* Time */}
|
|
<span className="text-[10px] text-slate-600 shrink-0">
|
|
{new Date(ev.timestamp).toLocaleTimeString('pt-BR')}
|
|
</span>
|
|
|
|
{/* Chevron */}
|
|
<span className="shrink-0 text-slate-600 group-hover:text-slate-400 transition-colors">
|
|
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
</span>
|
|
</button>
|
|
|
|
<AnimatePresence>
|
|
{open && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="mx-4 mb-3 bg-black/60 rounded-xl border border-white/5 overflow-hidden">
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-white/5 bg-white/[0.02]">
|
|
<span className="text-[10px] font-black uppercase tracking-widest text-slate-500">Payload</span>
|
|
<span className="text-[10px] text-slate-600 ml-auto">{new Date(ev.timestamp).toLocaleString('pt-BR')}</span>
|
|
</div>
|
|
<pre className="p-3 text-[11px] font-mono text-slate-300 overflow-x-auto whitespace-pre-wrap leading-relaxed max-h-60">
|
|
{JSON.stringify(ev.data, null, 2)}
|
|
</pre>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
// ─── Session Modal ─────────────────────────────────────────────────────────────
|
|
|
|
function SessionModal({ session, onClose }: { session: SessionInfo; onClose: () => void }) {
|
|
const [filter, setFilter] = useState<EventCategory>('all');
|
|
|
|
const filtered = filter === 'all' ? session.events : session.events.filter(e => e.category === filter);
|
|
|
|
const counts = session.events.reduce<Record<string, number>>((acc, e) => {
|
|
acc[e.category] = (acc[e.category] || 0) + 1;
|
|
return acc;
|
|
}, {});
|
|
|
|
const categories: EventCategory[] = ['all', 'connection', 'received', 'sent', 'media', 'chat', 'system'];
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
onClick={onClose}
|
|
>
|
|
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" />
|
|
|
|
<motion.div
|
|
initial={{ scale: 0.95, opacity: 0, y: 10 }}
|
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
|
exit={{ scale: 0.95, opacity: 0, y: 10 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="relative w-full max-w-4xl max-h-[85vh] bg-[#0d0f18] border border-white/10 rounded-2xl shadow-2xl shadow-black/60 flex flex-col overflow-hidden"
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3 px-5 py-4 border-b border-white/[0.06] bg-white/[0.02] shrink-0">
|
|
<div className={`w-2.5 h-2.5 rounded-full shrink-0 ${
|
|
session.status === 'connected' ? 'bg-emerald-500 shadow-[0_0_8px_#10b981]' :
|
|
session.status === 'qr' ? 'bg-yellow-400 shadow-[0_0_8px_#facc15]' :
|
|
'bg-red-500'
|
|
}`} />
|
|
<div>
|
|
<h2 className="text-white font-bold text-sm">{session.name || session.id.slice(0, 8)}</h2>
|
|
<p className="text-[10px] text-slate-500 uppercase tracking-widest font-black">
|
|
{session.status} · {session.eventCount} eventos
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="ml-auto p-1.5 hover:bg-white/10 rounded-lg text-slate-500 hover:text-white transition-colors"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filter Tabs */}
|
|
<div className="flex items-center gap-1 px-4 py-2 border-b border-white/[0.06] bg-black/20 overflow-x-auto shrink-0">
|
|
<Filter size={12} className="text-slate-600 mr-1 shrink-0" />
|
|
{categories.map(cat => {
|
|
const meta = EVENT_META[cat];
|
|
const count = cat === 'all' ? session.events.length : (counts[cat] || 0);
|
|
if (count === 0 && cat !== 'all') return null;
|
|
return (
|
|
<button
|
|
key={cat}
|
|
onClick={() => setFilter(cat)}
|
|
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-wider transition-all whitespace-nowrap ${
|
|
filter === cat
|
|
? `${meta.bg} ${meta.color} border border-current/20`
|
|
: 'text-slate-500 hover:text-slate-300 hover:bg-white/5'
|
|
}`}
|
|
>
|
|
{meta.label}
|
|
<span className={`px-1.5 py-0.5 rounded-full text-[9px] ${filter === cat ? 'bg-black/30' : 'bg-white/5'}`}>
|
|
{count}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Events List */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{filtered.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center h-40 text-slate-600">
|
|
<Activity size={24} className="mb-2 opacity-40" />
|
|
<span className="text-sm">Nenhum evento nesta categoria</span>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{[...filtered].reverse().map((ev, i) => (
|
|
<EventRow key={ev.id} ev={ev} index={i} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="px-5 py-2.5 border-t border-white/[0.06] bg-black/20 shrink-0 flex items-center gap-2">
|
|
<span className="text-[10px] text-slate-600 font-mono">
|
|
Última atividade: {new Date(session.lastSeen).toLocaleString('pt-BR')}
|
|
</span>
|
|
<span className="ml-auto text-[10px] text-slate-600">
|
|
Exibindo {filtered.length} de {session.eventCount} eventos
|
|
</span>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
// ─── Session Card ──────────────────────────────────────────────────────────────
|
|
|
|
function SessionCard({
|
|
session,
|
|
onClick,
|
|
onEngineChange,
|
|
}: {
|
|
session: SessionInfo;
|
|
onClick: () => void;
|
|
onEngineChange: (instanceId: string, engine: 'infinite' | 'official') => Promise<void>;
|
|
}) {
|
|
const [switching, setSwitching] = useState(false);
|
|
const [cooldownSec, setCooldownSec] = useState(0);
|
|
|
|
// Soma contagens do banco (base) + eventos ao vivo da sessão atual
|
|
const liveCounts = session.events.reduce<Record<string, number>>((acc, e) => {
|
|
acc[e.category] = (acc[e.category] || 0) + 1;
|
|
return acc;
|
|
}, {});
|
|
|
|
const db = session.dbStats;
|
|
const counts = {
|
|
received: (db?.received ?? 0) + (liveCounts.received ?? 0),
|
|
sent: (db?.sent ?? 0) + (liveCounts.sent ?? 0),
|
|
media: (db?.media ?? 0) + (liveCounts.media ?? 0),
|
|
chat: (db?.chats ?? 0) + (liveCounts.chat ?? 0),
|
|
};
|
|
|
|
const statusColor = session.status === 'connected'
|
|
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20'
|
|
: session.status === 'qr'
|
|
? 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20'
|
|
: 'text-red-400 bg-red-500/10 border-red-500/20';
|
|
|
|
const dotColor = session.status === 'connected'
|
|
? 'bg-emerald-500 shadow-[0_0_8px_#10b981] animate-pulse'
|
|
: session.status === 'qr'
|
|
? 'bg-yellow-400 shadow-[0_0_8px_#facc15] animate-pulse'
|
|
: 'bg-red-500';
|
|
|
|
const currentEngine = session.engine ?? 'infinite';
|
|
|
|
const handleMethod = async (e: React.MouseEvent, value: 'infinite' | 'official') => {
|
|
e.stopPropagation();
|
|
if (value === currentEngine || switching || cooldownSec > 0) return;
|
|
setSwitching(true);
|
|
try {
|
|
await onEngineChange(session.id, value);
|
|
} catch (err: any) {
|
|
// Extrai segundos do cooldown se retornou 429
|
|
const match = String(err?.message ?? '').match(/(\d+)s/);
|
|
if (match) {
|
|
let secs = parseInt(match[1]);
|
|
setCooldownSec(secs);
|
|
const tick = setInterval(() => {
|
|
secs--;
|
|
setCooldownSec(secs);
|
|
if (secs <= 0) clearInterval(tick);
|
|
}, 1000);
|
|
}
|
|
} finally {
|
|
setSwitching(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
whileHover={{ y: -2, borderColor: 'rgba(255,255,255,0.12)' }}
|
|
className="w-full text-left bg-white/[0.03] border border-white/[0.06] rounded-xl p-4 hover:bg-white/[0.05] transition-all group"
|
|
>
|
|
{/* Header — clicável para abrir modal */}
|
|
<button onClick={onClick} className="w-full text-left">
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<div className={`w-2 h-2 rounded-full shrink-0 ${dotColor}`} />
|
|
<span className="text-white font-bold text-sm truncate">{session.name || session.id.slice(0, 8)}</span>
|
|
</div>
|
|
<span className={`text-[9px] font-black uppercase tracking-widest px-2 py-0.5 rounded-full border shrink-0 ml-2 ${statusColor}`}>
|
|
{session.status}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Stats row */}
|
|
<div className="grid grid-cols-4 gap-1.5 mb-3">
|
|
{[
|
|
{ icon: <ArrowDownLeft size={10} />, val: counts.received, color: 'text-blue-400' },
|
|
{ icon: <ArrowUpRight size={10} />, val: counts.sent, color: 'text-brand-400' },
|
|
{ icon: <Image size={10} />, val: counts.media, color: 'text-purple-400' },
|
|
{ icon: <MessageSquare size={10} />, val: counts.chat, color: 'text-yellow-400' },
|
|
].map((s, i) => (
|
|
<div key={i} className="bg-black/40 rounded-lg py-1.5 px-2 flex flex-col items-center gap-0.5">
|
|
<span className={s.color}>{s.icon}</span>
|
|
<span className="text-[11px] font-black text-white">{fmtCount(s.val)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</button>
|
|
|
|
{/* Método de envio */}
|
|
<div className="mt-2">
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-slate-500 mb-2">
|
|
Modo de envio
|
|
</p>
|
|
<div className="flex gap-2">
|
|
{METODOS.map(m => {
|
|
const isActive = currentEngine === m.value;
|
|
return (
|
|
<button
|
|
key={m.value}
|
|
onClick={(e) => handleMethod(e, m.value)}
|
|
disabled={switching || cooldownSec > 0}
|
|
title={isActive ? 'Método atual' : `Trocar para ${m.label}`}
|
|
className={`flex-1 flex flex-col gap-1 px-3 py-2.5 rounded-xl border text-left transition-all
|
|
${isActive
|
|
? `${m.bg} ${m.border} ${m.color}`
|
|
: 'bg-white/[0.02] border-white/[0.06] text-slate-500 hover:border-white/20 hover:text-slate-300'
|
|
}
|
|
${(switching || cooldownSec > 0) ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}
|
|
`}
|
|
>
|
|
<div className="flex items-center gap-1.5">
|
|
{switching && !isActive
|
|
? <RefreshCw size={12} className="animate-spin" />
|
|
: m.icon
|
|
}
|
|
<span className="text-[11px] font-black leading-none">{m.label}</span>
|
|
{isActive && (
|
|
<span className={`w-1.5 h-1.5 rounded-full ${m.dot} ml-auto shrink-0`} />
|
|
)}
|
|
</div>
|
|
<span className="text-[11px] leading-tight font-semibold">{m.desc}</span>
|
|
<span className={`text-[10px] leading-tight ${isActive ? 'opacity-90 font-bold' : 'opacity-50'}`}>
|
|
{m.hint}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Cooldown warning */}
|
|
{cooldownSec > 0 && (
|
|
<div className="mt-2 flex items-center gap-1.5 text-[10px] text-amber-500/80">
|
|
<Clock size={10} />
|
|
<span>Aguarde {cooldownSec}s para trocar novamente</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-between mt-2 pt-2 border-t border-white/[0.04]">
|
|
<span className="text-[10px] text-slate-600 font-mono">
|
|
{session.eventCount} eventos
|
|
</span>
|
|
<span className="text-[10px] text-slate-600 font-mono">
|
|
{new Date(session.lastSeen).toLocaleTimeString('pt-BR')}
|
|
</span>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
// ─── Main Page ─────────────────────────────────────────────────────────────────
|
|
|
|
export default function BaileysPage() {
|
|
const user = useAuthStore((s) => s.user);
|
|
// Termo técnico "Baileys" só para admin ou ruibto@gmail.com.
|
|
const canSeeTech = user?.role === 'ADMIN' || user?.email === 'ruibto@gmail.com';
|
|
const [sessions, setSessions] = useState<Map<string, SessionInfo>>(new Map());
|
|
const [selectedSession, setSelectedSession] = useState<SessionInfo | null>(null);
|
|
const [listening, setListening] = useState(true);
|
|
const socketRef = useRef<Socket | null>(null);
|
|
|
|
const handleEngineChange = useCallback(async (instanceId: string, engine: 'infinite' | 'official') => {
|
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8008';
|
|
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
|
const res = await fetch(`${API_URL}/api/instances/${instanceId}/engine`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
body: JSON.stringify({ engine }),
|
|
});
|
|
if (res.status === 429) {
|
|
const data = await res.json().catch(() => ({}));
|
|
throw new Error(data.error ?? 'Cooldown ativo');
|
|
}
|
|
if (!res.ok) throw new Error('Falha ao trocar engine');
|
|
// Atualiza engine na sessão local imediatamente
|
|
setSessions(prev => {
|
|
const next = new Map(prev);
|
|
const existing = next.get(instanceId);
|
|
if (existing) next.set(instanceId, { ...existing, engine });
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const addEvent = useCallback((raw: Omit<BaileysEvent, 'id' | 'category' | 'mediaType' | 'fromMe'> & { data: any }) => {
|
|
const data = parseData(raw.data);
|
|
const { category, mediaType, fromMe } = categorizeEvent(raw.event, data);
|
|
const ev: BaileysEvent = {
|
|
// crypto.randomUUID garante unicidade — evita colisões nos keys do React
|
|
id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`,
|
|
timestamp: raw.timestamp ?? Date.now(),
|
|
sessionId: raw.sessionId,
|
|
event: raw.event,
|
|
data,
|
|
category,
|
|
mediaType,
|
|
fromMe,
|
|
};
|
|
|
|
setSessions(prev => {
|
|
const next = new Map(prev);
|
|
const existing = next.get(ev.sessionId) ?? {
|
|
id: ev.sessionId,
|
|
status: 'connecting' as const,
|
|
lastSeen: ev.timestamp,
|
|
eventCount: 0,
|
|
events: [],
|
|
};
|
|
|
|
let status = existing.status;
|
|
if (category === 'connection') {
|
|
const e = ev.event.toLowerCase();
|
|
const connVal = data?.connection;
|
|
if (connVal === 'open' || e.includes('connected')) status = 'connected';
|
|
else if (connVal === 'close' || e.includes('disconnected')) status = 'disconnected';
|
|
else if (e.includes('qr')) status = 'qr';
|
|
}
|
|
|
|
const events = [...existing.events, ev].slice(-300);
|
|
const updated: SessionInfo = {
|
|
...existing,
|
|
status,
|
|
lastSeen: ev.timestamp,
|
|
eventCount: existing.eventCount + 1,
|
|
events,
|
|
};
|
|
next.set(ev.sessionId, updated);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// Mantém o modal sincronizado com o estado mais recente da sessão selecionada.
|
|
// Feito via useEffect (fora do updater de setSessions) para evitar o anti-pattern
|
|
// de chamar setState dentro de outro setState updater.
|
|
useEffect(() => {
|
|
if (!selectedSession) return;
|
|
setSelectedSession(sel => {
|
|
if (!sel) return sel;
|
|
const fresh = sessions.get(sel.id);
|
|
return fresh ?? sel;
|
|
});
|
|
}, [sessions]);
|
|
|
|
// Carrega estado inicial das instâncias via API
|
|
useEffect(() => {
|
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8008';
|
|
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
|
if (!token) return;
|
|
|
|
const headers = { Authorization: `Bearer ${token}` };
|
|
|
|
fetch(`${API_URL}/api/instances`, { headers })
|
|
.then(r => r.json())
|
|
.then(async (instances: any[]) => {
|
|
if (!Array.isArray(instances)) return;
|
|
|
|
// Busca stats de cada instância em paralelo
|
|
const statsResults = await Promise.allSettled(
|
|
instances.map(inst =>
|
|
fetch(`${API_URL}/api/instances/${inst.id}/stats`, { headers })
|
|
.then(r => r.ok ? r.json() : null)
|
|
.catch(() => null)
|
|
)
|
|
);
|
|
|
|
setSessions(prev => {
|
|
const next = new Map(prev);
|
|
const statusMap: Record<string, SessionInfo['status']> = {
|
|
CONNECTED: 'connected', DISCONNECTED: 'disconnected',
|
|
CONNECTING: 'connecting', QR_PENDING: 'qr', BANNED: 'disconnected',
|
|
};
|
|
instances.forEach((inst, idx) => {
|
|
const existing = next.get(inst.id);
|
|
const engine: 'infinite' | 'official' = inst.engine === 'official' ? 'official' : 'infinite';
|
|
const statsResult = statsResults[idx];
|
|
const dbStats: SessionStats | undefined =
|
|
statsResult.status === 'fulfilled' && statsResult.value
|
|
? statsResult.value
|
|
: undefined;
|
|
if (existing) {
|
|
next.set(inst.id, {
|
|
...existing,
|
|
...(inst.name ? { name: inst.name } : {}),
|
|
engine,
|
|
...(dbStats ? { dbStats } : {}),
|
|
});
|
|
} else {
|
|
next.set(inst.id, {
|
|
id: inst.id,
|
|
name: inst.name || undefined,
|
|
status: statusMap[inst.status] ?? 'disconnected',
|
|
lastSeen: Date.now(),
|
|
eventCount: 0,
|
|
events: [],
|
|
engine,
|
|
dbStats,
|
|
});
|
|
}
|
|
});
|
|
return next;
|
|
});
|
|
})
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!listening) return;
|
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8008';
|
|
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
|
if (!token) return;
|
|
|
|
let socket: ReturnType<typeof io> | null = null;
|
|
|
|
// Defer connection so React StrictMode's synchronous unmount/remount
|
|
// cycle clears the timer before the WebSocket handshake starts.
|
|
const timer = setTimeout(() => {
|
|
const socketUrl = (() => {
|
|
try { return new URL(API_URL).origin } catch { return API_URL }
|
|
})()
|
|
socket = io(socketUrl, {
|
|
transports: ['websocket', 'polling'],
|
|
auth: { token },
|
|
});
|
|
socketRef.current = socket;
|
|
|
|
socket.on('connect_error', (err) => {
|
|
console.warn('[Baileys] Socket connect error:', err.message);
|
|
});
|
|
|
|
socket.on('baileys:log', (log: any) => {
|
|
addEvent({
|
|
timestamp: log.timestamp ? new Date(log.timestamp).getTime() : Date.now(),
|
|
sessionId: log.sessionId || 'unknown',
|
|
event: log.event || 'unknown',
|
|
data: log.data,
|
|
});
|
|
});
|
|
}, 0);
|
|
|
|
return () => {
|
|
clearTimeout(timer);
|
|
socket?.disconnect();
|
|
socketRef.current = null;
|
|
};
|
|
}, [listening, addEvent]);
|
|
|
|
const sessionList = Array.from(sessions.values()).sort((a, b) => b.lastSeen - a.lastSeen);
|
|
|
|
return (
|
|
<div className="p-8 max-w-7xl mx-auto h-full overflow-y-auto pb-20">
|
|
{/* Header */}
|
|
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="flex items-center justify-between mb-8">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-12 h-12 rounded-2xl bg-brand-500/10 border border-brand-500/20 flex items-center justify-center">
|
|
<Zap size={24} className="text-brand-400" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-black text-white tracking-tight">{canSeeTech ? 'Baileys Engine' : 'Monitor de Eventos'}</h1>
|
|
<p className="text-slate-500 text-xs mt-0.5">
|
|
{sessionList.length} {sessionList.length === 1 ? 'sessão' : 'sessões'} monitoradas
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => { setSessions(new Map()); setSelectedSession(null); }}
|
|
className="p-2 hover:bg-white/5 rounded-lg text-slate-500 hover:text-white transition-colors"
|
|
title="Limpar"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
<button
|
|
onClick={() => setListening(l => !l)}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-[11px] font-black uppercase tracking-wider border transition-all ${
|
|
listening
|
|
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20'
|
|
: 'bg-red-500/10 text-red-400 border-red-500/20'
|
|
}`}
|
|
>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${listening ? 'bg-emerald-400 animate-pulse' : 'bg-red-400'}`} />
|
|
{listening ? 'Live' : 'Paused'}
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Sessions Grid */}
|
|
{sessionList.length === 0 ? (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
className="flex flex-col items-center justify-center py-24 text-slate-600"
|
|
>
|
|
<div className="w-16 h-16 rounded-2xl bg-white/[0.02] border border-white/5 flex items-center justify-center mb-4">
|
|
<Zap size={28} className="opacity-30" />
|
|
</div>
|
|
<p className="text-sm font-semibold mb-1">Nenhuma sessão detectada</p>
|
|
<p className="text-xs text-slate-700">Aguardando eventos do servidor{canSeeTech ? ' Baileys' : ''}...</p>
|
|
</motion.div>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-4">
|
|
<AnimatePresence>
|
|
{sessionList.map(session => (
|
|
<SessionCard
|
|
key={session.id}
|
|
session={session}
|
|
onClick={() => setSelectedSession(session)}
|
|
onEngineChange={handleEngineChange}
|
|
/>
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
)}
|
|
|
|
{/* Legend */}
|
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3 }} className="mt-8 flex flex-wrap gap-3">
|
|
{[
|
|
{ icon: <ArrowDownLeft size={12} />, label: 'Recebida', color: 'text-blue-400' },
|
|
{ icon: <ArrowUpRight size={12} />, label: 'Enviada', color: 'text-brand-400' },
|
|
{ icon: <Image size={12} />, label: 'Imagem', color: 'text-purple-400' },
|
|
{ icon: <Video size={12} />, label: 'Vídeo', color: 'text-pink-400' },
|
|
{ icon: <Music size={12} />, label: 'Áudio', color: 'text-orange-400' },
|
|
{ icon: <FileText size={12} />, label: 'Documento', color: 'text-cyan-400' },
|
|
{ icon: <MessageSquare size={12} />, label: 'Chat', color: 'text-yellow-400' },
|
|
{ icon: <Link size={12} />, label: 'Conexão', color: 'text-emerald-400' },
|
|
].map((item, i) => (
|
|
<div key={i} className={`flex items-center gap-1.5 text-[11px] ${item.color} bg-white/[0.03] px-2.5 py-1 rounded-full border border-white/5`}>
|
|
{item.icon}
|
|
<span className="font-semibold">{item.label}</span>
|
|
</div>
|
|
))}
|
|
</motion.div>
|
|
|
|
{/* Modal */}
|
|
<AnimatePresence>
|
|
{selectedSession && (
|
|
<SessionModal
|
|
session={selectedSession}
|
|
onClose={() => setSelectedSession(null)}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|