feat(newwhats): frontend do plugin (Inbox/Sessions/Secretária) em tela cheia
- /wa-inbox, /wa-sessions e /wa-secretaria rodam sem a sidebar do scoreodonto (isStandaloneView); cada tela tem seu "Voltar" (Secretária ganhou botão na ThinNav). - SessionsView: banner "Você já possui acesso ao WhatsApp" quando há sessão ativa. - avatares: usa a URL do CDN (contactAvatarUrl/instance.avatar) direto; Avatar exibe sem depender de version; self-heal no onError re-busca a foto atual. - deps: framer-motion, immer. Dev override com HMR (docker-compose.dev.yml). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
// 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;
|
||||
|
||||
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();
|
||||
Reference in New Issue
Block a user