// Modified in the independent hardening fork; see docs/hardening/README.md. // Modified for AgentDeskFixes: bind realtime work to the current session and sanitize diagnostics. "use client" import { useEffect, useRef } from "react" import { toast } from "sonner" import { useSession } from "@/components/session-provider" import { realtimeLogContext } from "@/lib/realtime-log-context" import { createAdminWebSocketUrl } from "@/lib/api/admin" import { type AgentMessage } from "@/lib/api/agent" import { shouldReloadConversationListForRealtimePatch } from "@/lib/agent-conversation-realtime" import { readSession } from "@/lib/auth" import { normalizeRealtimeMessage, type RealtimeConversationPatch, type RealtimeMessageCreatedPayload, } from "@/lib/im-realtime-state" import { createRealtimeConnectionManager } from "@/lib/realtime-connection" import { getNotificationBody, showNotification } from "@/lib/services/notification" import { playIncomingMessageSound } from "@/lib/sound-alert" import { useAgentConversationsStore } from "@/lib/stores/agent-conversations" export { useAgentConversationsStore } import { useI18n } from "@/i18n/provider" import { type RealtimeAgentActivityPayload } from "@/lib/im-realtime" import { type OperatorPresencePayload } from "@/lib/operator-presence-state" type AgentRealtimeConnection = ReturnType type AgentRealtimeEnvelope = { eventId?: string type?: string data?: RealtimeMessageCreatedPayload & RealtimeConversationPatch & RealtimeAgentActivityPayload & OperatorPresencePayload & { messageId?: number recalledAt?: string sendStatus?: number } } let activeRealtimeSocket: (() => WebSocket | null) | null = null export function sendOperatorPresence( conversationId: number, action: "viewing" | "typing" | "left" ) { const socket = activeRealtimeSocket?.() if (socket && socket.readyState === WebSocket.OPEN && conversationId > 0) { socket.send(JSON.stringify({ type: "presence", conversationId, action })) } } export function useAgentConversationRealtime() { const t = useI18n() const { session } = useSession() const accessToken = session?.accessToken const selectedConversationId = useAgentConversationsStore( (state) => state.selectedConversationId ) const setRealtimeStatus = useAgentConversationsStore( (state) => state.setRealtimeStatus ) const realtimeRef = useRef(null) const subscribedConversationIdRef = useRef(null) const selectedConversationIdRef = useRef(selectedConversationId) const currentUserId = session?.user.id ?? 0 useEffect(() => { selectedConversationIdRef.current = selectedConversationId }, [selectedConversationId]) useEffect(() => { if (!accessToken) return const isCurrentSession = () => readSession()?.accessToken === accessToken const realtime = createRealtimeConnectionManager({ createSocket: () => new WebSocket(createAdminWebSocketUrl()), onStatusChange: (status) => { if (isCurrentSession()) setRealtimeStatus(status) }, onOpen: (socket) => { if (!isCurrentSession()) return console.info("[agent-realtime] websocket connected", { ...realtimeLogContext(socket.url), }) const conversationId = selectedConversationIdRef.current if (conversationId) { socket.send( JSON.stringify({ type: "subscribe", topics: [`conversation:${conversationId}`], }) ) socket.send( JSON.stringify({ type: "presence", conversationId, action: "viewing", }) ) subscribedConversationIdRef.current = conversationId } else { subscribedConversationIdRef.current = null } }, onMessage: (event, socket) => { if (!isCurrentSession()) return try { const envelope = JSON.parse(event.data) as AgentRealtimeEnvelope const eventType = envelope.type ?? "" const payload = envelope.data const conversationId = payload?.conversationId ?? 0 const eventId = envelope.eventId?.trim() ?? "" if ( eventType === "" || eventType === "connected" || eventType === "pong" || eventType === "subscribed" || eventType === "unsubscribed" ) { return } if (eventId && socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ type: "ack", eventId })) } const store = useAgentConversationsStore.getState() if (eventType === "resyncRequired") { void store.resyncRealtimeData(conversationId).catch((error) => { toast.error(error instanceof Error ? error.message : t("conversation.syncConversationDataFailed")) }) return } if (eventType === "agent.activity" && payload) { store.applyRealtimeAgentActivity(payload) return } if (eventType === "operator.presence" && payload) { store.applyRealtimeOperatorPresence(payload, currentUserId) return } if (eventType === "message.created") { const message = normalizeRealtimeMessage(payload) if (!message) { void store.resyncRealtimeData(conversationId).catch((error) => { toast.error(error instanceof Error ? error.message : t("conversation.syncMessagesFailed")) }) return } store.applyRealtimeMessageCreated(message) const isCustomerMsg = message.senderType === "customer" const isAssignedToMe = (payload?.currentAssigneeId ?? 0) === currentUserId const isUnassigned = (payload?.currentAssigneeId ?? 0) === 0 const isCurrentActive = message.conversationId === selectedConversationIdRef.current if (isCustomerMsg && (isAssignedToMe || isUnassigned || isCurrentActive)) { playIncomingMessageSound() if (typeof document !== "undefined" && document.visibilityState !== "visible") { showNotification(t("conversation.newMessage"), getNotificationBody(message), () => { void store.selectConversation(message.conversationId) }) } } return } if (eventType === "message.recalled" && payload?.messageId) { store.applyRealtimeMessageRecalled(payload.messageId, { sendStatus: payload.sendStatus, recalledAt: payload.recalledAt, }) return } if (eventType.startsWith("conversation.") && payload) { store.applyRealtimeConversationChanged(payload) if (shouldReloadConversationListForRealtimePatch(payload)) { void store.resyncRealtimeData(conversationId).catch((error) => { toast.error(error instanceof Error ? error.message : t("conversation.syncConversationListFailed")) }) } } } catch { // ignore invalid ws payload } }, onClose: (event, socket) => { console.log("[agent-realtime] websocket closed", { ...realtimeLogContext(socket.url), readyState: socket.readyState, code: event.code, wasClean: event.wasClean, }) subscribedConversationIdRef.current = null }, onError: (_event, socket) => { console.log("[agent-realtime] websocket error", { ...realtimeLogContext(socket.url), readyState: socket.readyState, }) }, onConnectError: (error) => { toast.error(error instanceof Error ? error.message : t("conversation.realtimeConnectFailed")) }, }) realtimeRef.current = realtime activeRealtimeSocket = () => realtimeRef.current?.getSocket() ?? null realtime.connect() return () => { activeRealtimeSocket = null const socket = realtimeRef.current?.getSocket() if ( socket && socket.readyState === WebSocket.OPEN && subscribedConversationIdRef.current ) { socket.send( JSON.stringify({ type: "presence", conversationId: subscribedConversationIdRef.current, action: "left", }) ) } realtimeRef.current = null realtime.disconnect() subscribedConversationIdRef.current = null } }, [accessToken, currentUserId, setRealtimeStatus, t]) useEffect(() => { const socket = realtimeRef.current?.getSocket() if (!socket || socket.readyState !== WebSocket.OPEN) { return } const previousConversationId = subscribedConversationIdRef.current const nextConversationId = selectedConversationId ?? null if (previousConversationId && previousConversationId !== nextConversationId) { socket.send( JSON.stringify({ type: "presence", conversationId: previousConversationId, action: "left", }) ) socket.send( JSON.stringify({ type: "unsubscribe", topics: [`conversation:${previousConversationId}`], }) ) } if (nextConversationId && nextConversationId !== previousConversationId) { socket.send( JSON.stringify({ type: "subscribe", topics: [`conversation:${nextConversationId}`], }) ) socket.send( JSON.stringify({ type: "presence", conversationId: nextConversationId, action: "viewing", }) ) subscribedConversationIdRef.current = nextConversationId return } if (!nextConversationId) { subscribedConversationIdRef.current = null } }, [selectedConversationId]) useEffect(() => { const interval = setInterval(() => { useAgentConversationsStore.getState().purgeExpiredOperatorPresences() }, 5000) return () => { clearInterval(interval) } }, []) }