import { Dispatch, SetStateAction, MutableRefObject } from "react"; import { BotData, ChatState } from "~/types/bot"; import { Message, ConversationStep } from "~/types/chat"; import { ProductCardData } from "~/types/product"; import { sendMessageToStreamAPI, validateThreadId } from "~/utils/api"; import { withAsyncErrorHandling, addBreadcrumb } from "~/utils/sentry"; import { WidgetType, getWidgetPlatformType, clearDoriThreadId, } from "~/utils/session-storage"; import { getProductId } from "~/utils/product-context"; import { buildPageContextString } from "~/utils/page-context"; import { trackMessageSent } from "~/utils/posthog"; import { createUserMessage, createLoadingMessage, createErrorMessage, } from "../utils/message-builders"; import { createChunkHandler } from "../streaming/chunk-handler"; import { createStreamErrorHandler, createStreamCompletionHandler, } from "../streaming/stream-handlers"; /** * Creates the sendMessage function with all dependencies injected * This is the main chat message sending logic */ export function createSendMessage( botData: BotData | null, isInputDisabled: boolean, chatState: ChatState, widgetType: WidgetType, shouldTrackSession: boolean, currentAbortController: MutableRefObject, messages: Message[], setMessages: Dispatch>, setCurrentStep: Dispatch>, setIsInputDisabled: Dispatch>, setInputState: Dispatch< SetStateAction<"starting" | "generating" | "minimized"> >, setChatState: Dispatch>, setShouldTrackSession: Dispatch>, shouldInjectContext: boolean, setShouldInjectContext: Dispatch> ) { return withAsyncErrorHandling( "sendMessage", async ( { message, imageUrl, apiMessage, }: { message: string; imageUrl?: string; apiMessage?: string; }, repliedProduct?: ProductCardData ) => { if (!botData || isInputDisabled) { return; } // All context injection and apiMessage handling is ONLY for widget_test mode // When widget_test is false, we use the plain message as-is let fullMessageForApi: string | undefined = undefined; if (botData.widget_test) { // Determine if we should inject page context const shouldAddContext = shouldInjectContext; let pageContextString = ""; if (shouldAddContext) { pageContextString = buildPageContextString(); // Reset the flag after using it setShouldInjectContext(false); } // Build the full message that will be sent to API // Priority: page context (if any) + apiMessage (if dynamic suggestion) OR plain message const baseMessageForApi = apiMessage || message; fullMessageForApi = pageContextString ? pageContextString + baseMessageForApi : apiMessage ? apiMessage : undefined; // undefined means no special handling needed } // Create user message: // - content: what gets sent to API (with context/instructions if any) // - displayContent: what user sees (always just their message) const userMessage = createUserMessage( message, // Display content (what user sees) imageUrl, repliedProduct, fullMessageForApi // Full content for backend (undefined if same as message) ); setMessages((prev) => { const filteredMessages = prev.filter( (msg) => msg.type !== "suggestions" ); return [...filteredMessages, userMessage]; }); addBreadcrumb("User message sent", "user-interaction.message", "info", { message_length: message.length, has_image: !!imageUrl, thread_id: chatState.threadId, }); // Track message sent in PostHog trackMessageSent( chatState.threadId, message.length, !!imageUrl, false // isRetry flag can be added if needed ); if (currentAbortController.current) { currentAbortController.current.abort(); currentAbortController.current = null; } setCurrentStep("thinking"); setIsInputDisabled(true); setInputState("generating"); setChatState((prev) => ({ ...prev, isRequestInProgress: true })); currentAbortController.current = new AbortController(); const loadingMessage = createLoadingMessage("بذار یه لحظه بررسی کنم..."); setMessages((prev) => [...prev, loadingMessage]); // Final message for API is what we computed earlier (or plain message as fallback) let finalMessage = fullMessageForApi || message; if (repliedProduct) { // For replied products, prepend product info to the base message finalMessage = `this message is replied to product with id ${repliedProduct.id}: ${finalMessage}`; } const isFirstMessage = messages.length <= 2; if (isFirstMessage && chatState.threadId && botData.assistant_id) { addBreadcrumb( "Fallback thread validation before first message", "chat.fallback_thread_validation", "info", { thread_id: chatState.threadId, } ); try { const isValid = await validateThreadId( chatState.threadId, botData.assistant_id ); if (!isValid) { addBreadcrumb( "Fallback validation failed - clearing invalid thread", "chat.fallback_validation_failed", "warning", { thread_id: chatState.threadId, } ); clearDoriThreadId(widgetType); setChatState((prev) => ({ ...prev, threadId: null })); } } catch (error) { addBreadcrumb( "Fallback validation error - clearing thread", "chat.fallback_validation_error", "warning", { thread_id: chatState.threadId, error_message: (error as Error)?.message || String(error), } ); clearDoriThreadId(widgetType); setChatState((prev) => ({ ...prev, threadId: null })); } } // State refs for streaming const fullMessageRef = { current: "" }; const currentBotMessageRef = { current: null as Message | null }; const accumulatedProductsRef = { current: [] as ProductCardData[] }; const pendingSuggestionsRef = { current: [] as string[] }; const productId = getProductId(); // Create thread access error handler const handleThreadAccessError = () => { clearDoriThreadId(widgetType); setChatState((prev) => ({ ...prev, threadId: null })); addBreadcrumb( "Thread access error - cleared invalid threadId", "chat.thread_access_error", "warning", { widget_type: widgetType, previous_thread_id: chatState.threadId, } ); }; // Create handlers const chunkHandler = createChunkHandler( botData, chatState, widgetType, shouldTrackSession, setChatState, setMessages, setShouldTrackSession, fullMessageRef, currentBotMessageRef, accumulatedProductsRef, pendingSuggestionsRef ); const errorHandler = createStreamErrorHandler( currentAbortController, botData, chatState, widgetType, setChatState, setIsInputDisabled, setInputState, setCurrentStep, setMessages, handleThreadAccessError ); const completionHandler = createStreamCompletionHandler( currentAbortController, currentBotMessageRef, pendingSuggestionsRef, setChatState, setIsInputDisabled, setInputState, setCurrentStep, setMessages ); try { await sendMessageToStreamAPI( finalMessage, botData, chatState.threadId, chunkHandler, errorHandler, completionHandler, currentAbortController.current, imageUrl, getWidgetPlatformType(widgetType), widgetType === WidgetType.EMBEDDED ? productId : undefined, handleThreadAccessError ); } catch (error) { if ( currentAbortController.current?.signal.aborted || (error as Error).name === "AbortError" ) { currentAbortController.current = null; setChatState((prev) => ({ ...prev, isRequestInProgress: false, repliedProduct: null, })); setIsInputDisabled(false); setInputState("starting"); setCurrentStep("ongoing"); setMessages((prev) => prev.filter((msg) => msg.type !== "loading")); } else { // Check if error indicates thread access issues const errorMessage = (error as Error)?.message || String(error); const isThreadAccessError = errorMessage.toLowerCase().includes("access denied for thread") || errorMessage.toLowerCase().includes("access denied") || errorMessage.includes("403"); if (isThreadAccessError) { handleThreadAccessError(); } // Log detailed error information for debugging console.error("[Mori Widget] Message send error:", { error: error, errorName: (error as Error)?.name, errorMessage: (error as Error)?.message, errorStack: (error as Error)?.stack, threadId: chatState.threadId, assistantId: botData?.assistant_id, messageLength: message.length, hasImage: !!imageUrl, widgetType: widgetType, timestamp: new Date().toISOString(), }); addBreadcrumb( "Message send failed with error", "chat.send_error", "error", { error_name: (error as Error)?.name, error_message: (error as Error)?.message, thread_id: chatState.threadId, } ); setMessages((prev) => [ ...prev.filter((msg) => msg.type !== "loading"), createErrorMessage( "متأسفانه خطایی رخ داده است. لطفاً دوباره تلاش کنید." ), ]); setCurrentStep("ongoing"); setChatState((prev) => ({ ...prev, isRequestInProgress: false, repliedProduct: null, })); setIsInputDisabled(false); setInputState("starting"); currentAbortController.current = null; } } } ); }