import { useEffect, useRef, useCallback, useState } from 'react'; import { io, Socket } from 'socket.io-client'; import { warnIfInsecureHttpUrl } from '../utils/urlSecurity'; interface SessionStatusEvent { sessionId: string; status: string; timestamp: string; } interface QRCodeEvent { sessionId: string; qrCode: string; timestamp: string; } interface MessageEvent { sessionId: string; message: Record; timestamp: string; } interface MessageAckEvent { sessionId: string; id: string; messageId: string; // Neutral delivery status emitted by the backend (engine-agnostic), not a raw wwebjs ack integer. status: 'pending' | 'sent' | 'delivered' | 'read' | 'failed'; // Deprecated legacy numeric ack kept for backward compatibility; prefer `status`. ack?: number; timestamp?: string; } interface MessageReactionEvent { sessionId: string; messageId: string; chatId: string; reaction: string; senderId: string; reactions: Record; timestamp: string; } interface MessageEditedEvent { sessionId: string; messageId: string; chatId: string; body: string; timestamp: number; } interface MessageRevokedEvent { sessionId: string; id: string; /** * Id of the ORIGINAL deleted message. Optional: whatsapp-web.js can only resolve it when the * original is still in its local store, and Baileys sets it identical to `id`. */ revokedId?: string; chatId: string; from: string; to: string; body: string; type: string; timestamp: number; } interface WebSocketEvents { onSessionStatus?: (event: SessionStatusEvent) => void; onQRCode?: (event: QRCodeEvent) => void; onMessage?: (event: MessageEvent) => void; onMessageAck?: (event: MessageAckEvent) => void; onMessageReaction?: (event: MessageReactionEvent) => void; onMessageRevoked?: (event: MessageRevokedEvent) => void; onMessageEdited?: (event: MessageEditedEvent) => void; } // Shape of the server -> client event envelope produced by the NestJS gateway. interface ServerEventEnvelope { type: string; timestamp: string; payload?: { event: string; sessionId: string; data: Record; }; } // Use current origin for WebSocket (goes through nginx proxy in Docker) // Falls back to env var or localhost for development const SOCKET_URL = import.meta.env.VITE_WS_URL || window.location.origin; // Warn when the WebSocket origin is an insecure http:// URL on a non-localhost host. warnIfInsecureHttpUrl(SOCKET_URL, 'VITE_WS_URL'); export function useWebSocket(events: WebSocketEvents = {}) { const socketRef = useRef(null); const [isConnected, setIsConnected] = useState(false); // True once Socket.IO exhausts its reconnection attempts and permanently gives up — lets the // UI show a "connection lost" indicator + a manual retry instead of silently going stale. const [connectionFailed, setConnectionFailed] = useState(false); const connect = useCallback(() => { if (socketRef.current?.connected) return; // Get API key from sessionStorage (same as api.ts) const apiKey = sessionStorage.getItem('openwa_api_key'); if (!apiKey) { console.warn('[WebSocket] No API key found, skipping connection'); return; } socketRef.current = io(`${SOCKET_URL}/events`, { autoConnect: true, reconnection: true, reconnectionAttempts: 5, reconnectionDelay: 1000, // Send the key via `auth` (and a header for proxies). NOT via `query` — a key in the // handshake URL leaks into access logs / Referer. The gateway reads auth first. auth: { apiKey, }, extraHeaders: { 'X-API-Key': apiKey, }, }); socketRef.current.on('connect', () => { setIsConnected(true); setConnectionFailed(false); }); socketRef.current.on('disconnect', () => { setIsConnected(false); }); socketRef.current.on('connect_error', error => { console.warn('[WebSocket] Connection error:', error.message); }); // `reconnect_failed` is emitted on the Manager once all reconnectionAttempts are exhausted. socketRef.current.io.on('reconnect_failed', () => { console.warn('[WebSocket] Reconnection failed after max attempts'); setConnectionFailed(true); }); }, []); // Manual retry after the socket permanently gave up: tear down the dead socket and reconnect. const reconnect = useCallback(() => { setConnectionFailed(false); if (socketRef.current) { socketRef.current.disconnect(); socketRef.current = null; } connect(); }, [connect]); const subscribe = useCallback((sessionId: string, eventsList: string[]) => { if (socketRef.current?.connected) { socketRef.current.emit('message', { type: 'subscribe', sessionId, events: eventsList, }); } }, []); const unsubscribe = useCallback((sessionId: string) => { if (socketRef.current?.connected) { socketRef.current.emit('message', { type: 'unsubscribe', sessionId, }); } }, []); useEffect(() => { connect(); return () => { if (socketRef.current) { socketRef.current.disconnect(); socketRef.current = null; } }; }, [connect]); // Register the single envelope handler and fan out to the typed callbacks. useEffect(() => { if (!socketRef.current) return; const socket = socketRef.current; const handleIncomingMessage = (msg: ServerEventEnvelope) => { if (!msg || msg.type !== 'event' || !msg.payload) return; const { event, sessionId, data } = msg.payload; switch (event) { case 'session.status': events.onSessionStatus?.({ sessionId, status: String(data.status), timestamp: msg.timestamp }); break; case 'session.qr': events.onQRCode?.({ sessionId, qrCode: String(data.qrCode), timestamp: msg.timestamp }); break; case 'message.received': case 'message.sent': events.onMessage?.({ sessionId, message: data, timestamp: msg.timestamp }); break; case 'message.ack': events.onMessageAck?.({ sessionId, id: String(data.id), messageId: String(data.messageId), status: data.status as MessageAckEvent['status'], ack: typeof data.ack === 'number' ? data.ack : undefined, timestamp: msg.timestamp, }); break; case 'message.reaction': events.onMessageReaction?.({ sessionId, messageId: String(data.messageId), chatId: String(data.chatId), reaction: String(data.reaction), senderId: String(data.senderId), reactions: (data.reactions as Record) || {}, timestamp: msg.timestamp, }); break; case 'message.revoked': events.onMessageRevoked?.({ sessionId, id: String(data.id), // Not String()-coerced like its neighbours: the field is optional on the wire, and // String(undefined) would yield the truthy literal "undefined" and defeat the fallback. revokedId: typeof data.revokedId === 'string' ? data.revokedId : undefined, chatId: String(data.chatId), from: String(data.from), to: String(data.to), body: String(data.body ?? ''), type: String(data.type), timestamp: Number(data.timestamp), }); break; case 'message.edited': // Keep optional/malformed wire fields from becoming the truthy strings "undefined"/"null" // and accidentally matching an unrelated cached row. if ( typeof data.messageId !== 'string' || !data.messageId || typeof data.chatId !== 'string' || typeof data.body !== 'string' ) { break; } events.onMessageEdited?.({ sessionId, messageId: data.messageId, chatId: data.chatId, body: data.body, timestamp: Number(data.timestamp), }); break; default: break; } }; socket.on('message', handleIncomingMessage); return () => { socket.off('message', handleIncomingMessage); }; }, [events]); return { isConnected, connectionFailed, reconnect, subscribe, unsubscribe }; }