// Shim do socketService do motor sobre o túnel WS do satélite // (/api/nw/v1/stream?token=). Traduz os eventos do ext stream para os nomes que // as páginas do motor escutam. joinInstance/joinChat/leaveChat são no-ops: o // stream já é do tenant inteiro; as páginas filtram por instanceId/chatId no // cliente. Mantém a MESMA interface do services/socketService.ts do motor. import { nw, nwToken } from './nwClient'; type Listener = (data: any) => void; // ext status (minúsculo) → enum do motor (maiúsculo). function normStatus(s: string): string { const m: Record = { connected: 'CONNECTED', disconnected: 'DISCONNECTED', connecting: 'CONNECTING', qr: 'QR_PENDING', banned: 'BANNED', }; return m[String(s).toLowerCase()] ?? String(s).toUpperCase(); } class SocketShim { private ws: WebSocket | null = null; private listeners = new Map>(); private retry: ReturnType | null = null; private closedByUser = false; connect(_token?: string) { if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return; this.closedByUser = false; const token = nwToken(); if (!token) return; const base = nw.base(); const abs = base.startsWith('http') ? base : `${window.location.origin}${base}`; let u: URL; try { u = new URL(`${abs}/nw/v1/stream`); } catch { return; } u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:'; u.searchParams.set('token', token); let ws: WebSocket; try { ws = new WebSocket(u.toString()); } catch { return; } this.ws = ws; ws.onopen = () => this._emit('connected', { id: 'nw' }); ws.onerror = () => { try { ws.close(); } catch { /* ignore */ } }; ws.onclose = () => { this._emit('disconnected', {}); if (!this.closedByUser) this.retry = setTimeout(() => this.connect(), 3000); }; ws.onmessage = (ev) => { let env: any; try { env = JSON.parse(ev.data); } catch { return; } const { event, data } = env || {}; if (!event) return; switch (event) { case 'session.status': this._emit('instance:status', { instanceId: data?.instanceId, status: normStatus(data?.status) }); break; case 'session.qr': this._emit('qr', { instanceId: data?.instanceId, qrBase64: data?.qrBase64 }); break; case 'message.new': { // ext envia { instanceId, chatId, message, timestamp }; o chatStore // espera o objeto de mensagem direto (com chatId). // OBS: o motor também emite ext:message.new numa variante SEM `message` // (só { jid, text }) para acionar a Secretária IA — ignoramos aqui, // senão o chatStore sobrescreve o preview do chat com undefined. if (!data?.message) break; const m = { ...data.message, chatId: data.message.chatId ?? data.chatId }; this._emit('message:new', m); break; } case 'message.update': { const m = data?.message ?? data; if (m?.messageId && m?.status) this._emit('message:status', { messageId: m.messageId, status: m.status }); this._emit('message:update', m); break; } case 'conversation.handoff': this._emit('handoff', data); break; case 'conversation.escalated': this._emit('escalated', data); break; default: break; } this._emit('*', { event, data }); }; } disconnect() { this.closedByUser = true; if (this.retry) clearTimeout(this.retry); try { this.ws?.close(); } catch { /* ignore */ } this.ws = null; } // Satélite não envia comandos pelo stream (é read-only); mantidos p/ compat. emit(_event: string, ..._args: any[]) { /* no-op */ } joinInstance(_instanceId: string) { /* no-op — stream é tenant-wide */ } joinChat(_chatId: string) { /* no-op */ } leaveChat(_chatId: string) { /* no-op */ } on(event: string, cb: Listener) { if (!this.listeners.has(event)) this.listeners.set(event, new Set()); this.listeners.get(event)!.add(cb); } off(event: string, cb: Listener) { this.listeners.get(event)?.delete(cb); } get connected() { return this.ws?.readyState === WebSocket.OPEN; } getSocket() { return this.ws; } private _emit(event: string, data: any) { this.listeners.get(event)?.forEach((cb) => { try { cb(data); } catch { /* ignore */ } }); } } export const socketService = new SocketShim();