// Modified in the independent hardening fork; see docs/hardening/README.md. // Modified for AgentDeskFixes: guard private state, captured recipients, and stale async completions. "use client" import { create } from "zustand" import { fetchAgentConversations, fetchAgentMessages, markAgentMessageRead, recallAgentMessage, sendAgentMessage, uploadAgentConversationAttachment, uploadAgentConversationImage, type AgentAsset, type AgentConversation, type AgentMessage, } from "@/lib/api/agent" import type { RealtimeConnectionStatusValue } from "@/components/realtime-connection-status" import { cursorFromLoadedImMessages, hasMoreAfterLatestImMessageMerge, mergeImMessagesByIdAsc, parseImMessageCursorId, } from "@/lib/im-message-merge" import { markMessagesReadToMessageId, patchConversationList, patchConversationListWithMessage, type RealtimeConversationPatch, } from "@/lib/im-realtime-state" import { summarizeIMMessage } from "@/lib/im-message" import { generateUUID } from "@/lib/utils" import { readSession } from "@/lib/auth" import { resolveAgentActivityState, type AgentActivityInfo, type AgentActivityEventPayload, } from "@/lib/agent-activity-state" import { purgeExpiredOperatorPresences as purgePresences, updateOperatorPresenceList, type OperatorPresencePayload, type OperatorPresenceRecord, } from "@/lib/operator-presence-state" export const agentConversationFilterOptions = [ { value: "active", labelKey: "conversation.filterActive" }, { value: "pending", labelKey: "conversation.filterPending" }, { value: "ai_serving", labelKey: "conversation.filterAiServing" }, { value: "closed", labelKey: "conversation.filterClosed" }, ] as const export type AgentConversationFilterKey = (typeof agentConversationFilterOptions)[number]["value"] function buildConversationQuery(filter: AgentConversationFilterKey, keyword: string) { const query: Record = { filter, keyword: keyword.trim() || undefined, limit: 100, } return query } type LoadMessagesOptions = { forceLoading?: boolean reset?: boolean } function ensureArray(value: T[] | null | undefined): T[] { return Array.isArray(value) ? value : [] } type AgentConversationsStore = { searchKeyword: string conversationFilter: AgentConversationFilterKey conversations: AgentConversation[] conversationsLoading: boolean conversationsLoaded: boolean selectedConversationId: number | null activeAgentActivity: AgentActivityInfo | null messages: AgentMessage[] messagesLoading: boolean messagesLoadingMore: boolean messagesCursor: string messagesHasMore: boolean messagesLoadedConversationId: number | null sending: boolean uploadingAsset: boolean recallingMessageId: number readingMessageId: number realtimeStatus: RealtimeConnectionStatusValue operatorPresences: OperatorPresenceRecord[] setSearchKeyword: (keyword: string) => void setConversationFilter: (filter: AgentConversationFilterKey) => void setRealtimeStatus: (status: RealtimeConnectionStatusValue) => void setConversationTags: ( conversationId: number, tags: AgentConversation["tags"] ) => void loadConversations: () => Promise selectConversation: (conversationId: number) => Promise loadMessages: (conversationId: number, options?: LoadMessagesOptions) => Promise loadOlderMessages: () => Promise syncLatestMessages: (conversationId: number) => Promise markSelectedConversationRead: () => Promise sendMessage: (html: string, conversationId?: number) => Promise uploadImage: (file: File, conversationId?: number) => Promise sendAttachment: (file: File, conversationId?: number) => Promise recallMessage: (messageId: number) => Promise applyRealtimeMessageCreated: (message: AgentMessage) => void applyRealtimeAgentActivity: (payload: AgentActivityEventPayload) => void applyRealtimeOperatorPresence: (payload: OperatorPresencePayload, currentUserId?: number) => void purgeExpiredOperatorPresences: () => void applyRealtimeConversationChanged: (patch: RealtimeConversationPatch) => void applyRealtimeMessageRecalled: (messageId: number, patch: Partial) => void resyncRealtimeData: (conversationId?: number) => Promise } let conversationsRequestSeq = 0 let messagesRequestSeq = 0 let sessionGeneration = 0 function captureSessionScope() { const generation = sessionGeneration const userId = readSession()?.user.id return () => generation === sessionGeneration && Boolean(userId) && readSession()?.user.id === userId } /** Invalidates every outstanding request before removing private dashboard data. */ export function resetAgentConversations() { sessionGeneration += 1 conversationsRequestSeq += 1 messagesRequestSeq += 1 useAgentConversationsStore.setState(useAgentConversationsStore.getInitialState(), true) } export const useAgentConversationsStore = create((set, get) => ({ searchKeyword: "", conversationFilter: "active", conversations: [], conversationsLoading: false, conversationsLoaded: false, selectedConversationId: null, activeAgentActivity: null, messages: [], messagesLoading: false, messagesLoadingMore: false, messagesCursor: "", messagesHasMore: false, messagesLoadedConversationId: null, sending: false, uploadingAsset: false, recallingMessageId: 0, readingMessageId: 0, realtimeStatus: "connecting", operatorPresences: [], setSearchKeyword: (keyword) => { set({ searchKeyword: keyword }) }, setConversationFilter: (filter) => { set({ conversationFilter: filter }) }, setRealtimeStatus: (status) => { set({ realtimeStatus: status }) }, setConversationTags: (conversationId, tags) => { set((state) => ({ conversations: state.conversations.map((item) => item.id === conversationId ? { ...item, tags: tags && tags.length > 0 ? tags : [], } : item ), })) }, loadConversations: async () => { const isCurrentSession = captureSessionScope() if (!isCurrentSession()) return const requestSeq = ++conversationsRequestSeq const store = get() if (!store.conversationsLoaded) { set({ conversationsLoading: true }) } try { const data = await fetchAgentConversations( buildConversationQuery(store.conversationFilter, store.searchKeyword) ) const conversations = ensureArray(data.results) if (requestSeq !== conversationsRequestSeq || !isCurrentSession()) { return } const currentSelectedId = get().selectedConversationId const hasCurrentSelection = currentSelectedId !== null && conversations.some((item) => item.id === currentSelectedId) const nextSelectedId = hasCurrentSelection ? currentSelectedId : (conversations[0]?.id ?? null) const selectionChanged = nextSelectedId !== currentSelectedId set({ conversations, conversationsLoaded: true, conversationsLoading: false, selectedConversationId: nextSelectedId, }) if (nextSelectedId === null) { set({ messages: [], messagesLoading: false, messagesLoadingMore: false, messagesCursor: "", messagesHasMore: false, messagesLoadedConversationId: null, }) return } if (selectionChanged || get().messagesLoadedConversationId === null) { await get().loadMessages(nextSelectedId, { forceLoading: true, reset: true, }) } } catch (error) { if (requestSeq === conversationsRequestSeq && isCurrentSession()) { set({ conversationsLoading: false }) } throw error } }, selectConversation: async (conversationId) => { if (get().selectedConversationId === conversationId) { return } set({ selectedConversationId: conversationId, messages: [], messagesLoading: true, messagesLoadingMore: false, messagesCursor: "", messagesHasMore: false, messagesLoadedConversationId: null, activeAgentActivity: null, }) await get().loadMessages(conversationId, { forceLoading: true, reset: true, }) }, loadMessages: async (conversationId, options = {}) => { const isCurrentSession = captureSessionScope() if (!isCurrentSession() || get().selectedConversationId !== conversationId) return const requestSeq = ++messagesRequestSeq const store = get() const shouldShowLoading = options.forceLoading || store.messagesLoadedConversationId !== conversationId // A fresh snapshot supersedes any older page request in the prior generation. set({ messagesLoadingMore: false }) if (shouldShowLoading) { set({ messagesLoading: true, ...(options.reset ? { messages: [], messagesCursor: "", messagesHasMore: false, } : {}), }) } try { const data = await fetchAgentMessages({ conversationId, limit: 50, }) if (requestSeq !== messagesRequestSeq || !isCurrentSession()) { return } if (get().selectedConversationId !== conversationId) { return } const list = ensureArray(data.results) set({ messages: list, messagesLoading: false, messagesLoadedConversationId: conversationId, messagesCursor: cursorFromLoadedImMessages(list) || (data.cursor ?? ""), messagesHasMore: Boolean(data.hasMore), }) } catch (error) { if (requestSeq === messagesRequestSeq && isCurrentSession()) { set({ messagesLoading: false }) } throw error } }, loadOlderMessages: async () => { const isCurrentSession = captureSessionScope() if (!isCurrentSession()) return const selectionGeneration = messagesRequestSeq const conversationId = get().selectedConversationId if (!conversationId || get().messagesLoadingMore || !get().messagesHasMore) { return } const cursorId = parseImMessageCursorId(get().messagesCursor) if (cursorId <= 0) { return } set({ messagesLoadingMore: true }) try { const data = await fetchAgentMessages({ conversationId, cursor: cursorId, limit: 50, }) if (!isCurrentSession() || selectionGeneration !== messagesRequestSeq || get().selectedConversationId !== conversationId) { return } const incoming = ensureArray(data.results) set((state) => { const merged = mergeImMessagesByIdAsc(state.messages, incoming) return { messages: merged, messagesCursor: cursorFromLoadedImMessages(merged) || (data.cursor ?? state.messagesCursor), messagesHasMore: Boolean(data.hasMore), messagesLoadingMore: false, } }) } catch (error) { if (isCurrentSession() && selectionGeneration === messagesRequestSeq && get().selectedConversationId === conversationId) { set({ messagesLoadingMore: false }) } throw error } }, syncLatestMessages: async (conversationId) => { const isCurrentSession = captureSessionScope() if (!isCurrentSession()) return const selectionGeneration = messagesRequestSeq if (conversationId <= 0) { return } try { const data = await fetchAgentMessages({ conversationId, limit: 50, }) if (!isCurrentSession() || selectionGeneration !== messagesRequestSeq || get().selectedConversationId !== conversationId) { return } const batch = ensureArray(data.results) if (batch.length === 0) { return } set((state) => { const merged = mergeImMessagesByIdAsc(state.messages, batch) return { messages: merged, messagesCursor: cursorFromLoadedImMessages(merged) || (data.cursor ?? state.messagesCursor), messagesHasMore: hasMoreAfterLatestImMessageMerge({ previousMessages: state.messages, previousHasMore: state.messagesHasMore, merged, apiHasMore: Boolean(data.hasMore), }), } }) } catch { // Keep realtime callback errors contained in the store. } }, markSelectedConversationRead: async () => { const isCurrentSession = captureSessionScope() if (!isCurrentSession()) return const store = get() const conversationId = store.selectedConversationId const conversation = store.conversations.find((item) => item.id === conversationId) const lastMessage = store.messages.at(-1) if (!conversationId || !conversation || !lastMessage) { return } if ( conversation.agentUnreadCount <= 0 && (conversation.agentLastReadMessageId ?? 0) >= lastMessage.id ) { return } if (store.readingMessageId === lastMessage.id) { return } set({ readingMessageId: lastMessage.id }) try { await markAgentMessageRead(conversationId, lastMessage.id) if (!isCurrentSession() || get().readingMessageId !== lastMessage.id) return set((current) => { if (current.selectedConversationId !== conversationId) { return { readingMessageId: 0 } } return { readingMessageId: 0, messages: current.messages.map((item) => { if (item.id > lastMessage.id) { return item } return item.agentRead ? item : { ...item, agentRead: true } }), conversations: current.conversations.map((item) => item.id === conversationId ? { ...item, agentUnreadCount: 0, agentLastReadMessageId: lastMessage.id, } : item ), } }) } catch (error) { if (isCurrentSession() && get().readingMessageId === lastMessage.id) { set({ readingMessageId: 0 }) } throw error } }, applyRealtimeMessageCreated: (message) => { set((state) => { const isSelected = state.selectedConversationId === message.conversationId const nextMessages = isSelected ? mergeImMessagesByIdAsc(state.messages, [message]) : state.messages return { messages: nextMessages, activeAgentActivity: isSelected ? null : state.activeAgentActivity, conversations: patchConversationListWithMessage( state.conversations, message ), } }) }, applyRealtimeAgentActivity: (payload) => { set((state) => ({ activeAgentActivity: resolveAgentActivityState( state.activeAgentActivity, state.selectedConversationId ?? undefined, payload ), })) }, applyRealtimeOperatorPresence: (payload, currentUserId) => { const userId = currentUserId || readSession()?.user?.id || 0 set((state) => ({ operatorPresences: updateOperatorPresenceList( state.operatorPresences, payload, userId ), })) }, purgeExpiredOperatorPresences: () => { set((state) => { const purged = purgePresences(state.operatorPresences) if (purged.length === state.operatorPresences.length) { return state } return { operatorPresences: purged } }) }, applyRealtimeConversationChanged: (patch) => { set((state) => { const conversationId = patch.id ?? patch.conversationId ?? 0 const isSelected = conversationId > 0 && state.selectedConversationId === conversationId let nextMessages = state.messages if (isSelected) { if ((patch.agentLastReadMessageId ?? 0) > 0) { nextMessages = markMessagesReadToMessageId( nextMessages, patch.agentLastReadMessageId ?? 0, "agent", patch.agentLastReadAt ) } if ((patch.customerLastReadMessageId ?? 0) > 0) { nextMessages = markMessagesReadToMessageId( nextMessages, patch.customerLastReadMessageId ?? 0, "customer", patch.customerLastReadAt ) } } return { messages: nextMessages, conversations: patchConversationList(state.conversations, patch), ...(isSelected && (patch.status === 4 || patch.closedAt) ? { activeAgentActivity: null } : {}), } }) }, applyRealtimeMessageRecalled: (messageId, patch) => { if (messageId <= 0) { return } set((state) => ({ messages: state.messages.map((item) => item.id === messageId ? { ...item, ...patch, id: item.id } : item ), })) }, resyncRealtimeData: async (conversationId) => { const isCurrentSession = captureSessionScope() if (!isCurrentSession()) return await get().loadConversations() if (!isCurrentSession()) return const selectedConversationId = get().selectedConversationId const targetConversationId = conversationId ?? selectedConversationId if (targetConversationId && selectedConversationId === targetConversationId) { await get().loadMessages(targetConversationId, { forceLoading: false, reset: false, }) } }, sendMessage: async (html, recipientId) => { const isCurrentSession = captureSessionScope() const trimmedContent = html.trim() const { selectedConversationId, sending } = get() if ( !isCurrentSession() || !selectedConversationId || (recipientId !== undefined && recipientId !== selectedConversationId) || !trimmedContent || sending || get().uploadingAsset ) { return null } set({ sending: true }) try { const message = await sendAgentMessage({ conversationId: selectedConversationId, messageType: "html", content: trimmedContent, clientMsgId: `agent_${generateUUID()}`, }) if (isCurrentSession() && get().selectedConversationId === selectedConversationId) { set((current) => ({ messages: current.messages.some((m) => m.id === message.id) ? current.messages.map((m) => (m.id === message.id ? message : m)) : [...current.messages, message], conversations: patchConversationList( patchConversationListWithMessage(current.conversations, message), { conversationId: selectedConversationId, agentUnreadCount: 0, customerUnreadCount: (current.conversations.find((item) => item.id === selectedConversationId) ?.customerUnreadCount ?? 0) + 1, agentLastReadMessageId: message.id, } ), })) } return message } finally { if (isCurrentSession()) set({ sending: false }) } }, uploadImage: async (file, recipientId) => { const isCurrentSession = captureSessionScope() const { selectedConversationId, sending, uploadingAsset } = get() if ( !isCurrentSession() || !selectedConversationId || (recipientId !== undefined && recipientId !== selectedConversationId) || sending || uploadingAsset ) { return null } set({ uploadingAsset: true }) try { const asset = await uploadAgentConversationImage(selectedConversationId, file) return isCurrentSession() ? asset : null } finally { if (isCurrentSession()) set({ uploadingAsset: false }) } }, sendAttachment: async (file, recipientId) => { const isCurrentSession = captureSessionScope() const { selectedConversationId, sending, uploadingAsset } = get() if ( !isCurrentSession() || !selectedConversationId || (recipientId !== undefined && recipientId !== selectedConversationId) || sending || uploadingAsset ) { return null } set({ uploadingAsset: true }) try { const asset = await uploadAgentConversationAttachment(selectedConversationId, file) if (!isCurrentSession()) return null const message = await sendAgentMessage({ conversationId: selectedConversationId, messageType: "attachment", content: asset.filename, payload: JSON.stringify({ assetId: asset.assetId }), clientMsgId: `agent_attachment_${generateUUID()}`, }) if (isCurrentSession() && get().selectedConversationId === selectedConversationId) { set((current) => ({ messages: current.messages.some((m) => m.id === message.id) ? current.messages.map((m) => (m.id === message.id ? message : m)) : [...current.messages, message], conversations: patchConversationList( patchConversationListWithMessage(current.conversations, message), { conversationId: selectedConversationId, agentUnreadCount: 0, customerUnreadCount: (current.conversations.find((item) => item.id === selectedConversationId) ?.customerUnreadCount ?? 0) + 1, agentLastReadMessageId: message.id, } ), })) } return message } finally { if (isCurrentSession()) set({ uploadingAsset: false }) } }, recallMessage: async (messageId) => { const isCurrentSession = captureSessionScope() const { selectedConversationId, recallingMessageId } = get() if (!isCurrentSession() || !selectedConversationId || messageId <= 0 || recallingMessageId === messageId) { return null } set({ recallingMessageId: messageId }) try { const message = await recallAgentMessage(messageId) if (!isCurrentSession()) return null if (isCurrentSession() && get().selectedConversationId === selectedConversationId) { set((current) => { const nextMessages = current.messages.map((item) => item.id === message.id ? message : item ) const lastActiveMessage = [...nextMessages] .reverse() .find((item) => !item.recalledAt && item.sendStatus !== 6) return { recallingMessageId: 0, messages: nextMessages, conversations: current.conversations.map((item) => item.id === selectedConversationId ? { ...item, lastMessageId: lastActiveMessage?.id ?? 0, lastMessageAt: lastActiveMessage?.sentAt ?? "", lastMessageSummary: lastActiveMessage ? summarizeIMMessage(lastActiveMessage) : "", } : item ), } }) } else { if (isCurrentSession() && get().recallingMessageId === messageId) set({ recallingMessageId: 0 }) } return message } catch (error) { if (isCurrentSession() && get().recallingMessageId === messageId) set({ recallingMessageId: 0 }) throw error } }, })) export const agentConversationSelectors = { selectedConversation: (state: AgentConversationsStore) => state.conversations.find((item) => item.id === state.selectedConversationId) ?? null, }