Files
scoreodonto.com/frontend/views/newwhats/services/socketService.ts
T
VPS 4 Builder 8ea8faa4c2 wip: inbox/agenda diversos (trabalho anterior à sessão)
Consolida WIP acumulado que não faz parte das features de hoje: melhorias do
inbox (WhatsChatDrawer, InputBar, MessageBubble, ProtocolPanel, StickerPanel,
LinkPreview, hooks, socketService, chatStore, nwClient), componentes novos
(ConversationSearch, ForwardModal, LabelManagerModal, roleMeta), AgendaPresence,
ComunicacaoInternaModal, Sidebar, GestaoEquipeView, SessionsView, appTime e o
asset chat-bg.png.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 06:08:07 +02:00

151 lines
6.2 KiB
TypeScript

// 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<string, string> = {
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<string, Set<Listener>>();
private retry: ReturnType<typeof setTimeout> | null = null;
private closedByUser = false;
private currentClinica: string | null = null;
// Clínica da conta ativa da inbox (nw:inbox-account). null = conta própria.
private readClinica(): string | null {
try {
const raw = localStorage.getItem('nw:inbox-account');
if (!raw) return null;
return JSON.parse(raw)?.clinicaId ?? null;
} catch { return null; }
}
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);
// Stream por-tenant: p/ a caixa da clínica, o backend usa ?clinica= para reescrever
// a conta-alvo (dono) quando o usuário tem can_inbox. null = conta própria.
const clinica = this.readClinica();
this.currentClinica = clinica;
if (clinica) u.searchParams.set('clinica', clinica);
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 'message.reaction':
// Reação recebida numa mensagem: { messageId, emoji, sender }.
this._emit('message:reaction', { messageId: data?.messageId, emoji: data?.emoji, sender: data?.sender });
break;
case 'message.media_ready':
this._emit('message:media_ready', { messageId: data?.messageId, mediaUrl: data?.mediaUrl });
break;
case 'message.transcription':
this._emit('message:transcription', { messageId: data?.messageId, transcription: data?.transcription });
break;
case 'presence':
// Presença do CONTATO (composing/recording/paused/available) por jid.
this._emit('presence:update', { jid: data?.jid, state: data?.state });
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;
}
// Reabre o stream se a conta ativa (clínica) mudou. O realtime é por-tenant, então
// alternar entre a caixa própria e a da clínica exige reconectar no tenant certo.
syncAccount() {
if (this.readClinica() === this.currentClinica && this.ws) return;
if (this.retry) clearTimeout(this.retry);
try { this.ws?.close(); } catch { /* ignore */ }
this.ws = null;
this.connect();
}
// 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();