8ea8faa4c2
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>
151 lines
6.2 KiB
TypeScript
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();
|