import { randomUUID } from "node:crypto" import { NextResponse, type NextRequest } from "next/server" import { SITE_CHAT_MAX_HISTORY_MESSAGES, SITE_CHAT_MAX_INPUT_CHARS, SITE_CHAT_MAX_MESSAGE_CHARS, SITE_CHAT_MAX_OUTPUT_CHARS, SITE_CHAT_MAX_OUTPUT_CHARS_PER_SESSION_WINDOW, SITE_CHAT_MAX_OUTPUT_TOKENS, SITE_CHAT_MAX_REQUESTS_PER_IP_WINDOW, SITE_CHAT_MAX_REQUESTS_PER_SESSION_WINDOW, SITE_CHAT_MODEL, SITE_CHAT_OUTPUT_WINDOW_MS, SITE_CHAT_REQUEST_WINDOW_MS, SITE_CHAT_SESSION_COOKIE, SITE_CHAT_SOURCE, SITE_CHAT_TEMPERATURE, isSiteChatSuppressedRoute, } from "@/lib/site-chat/config" import { SITE_CHAT_SYSTEM_PROMPT } from "@/lib/site-chat/prompt" import { consumeChatOutput, consumeChatRequest, getChatRateLimitStatus } from "@/lib/site-chat/rate-limit" import { createSmsConsentPayload } from "@/lib/sms-compliance" type ChatRole = "user" | "assistant" type ChatMessage = { role: ChatRole content: string } type ChatRequestBody = { messages?: ChatMessage[] pathname?: string sessionId?: string visitor?: { consentCapturedAt?: string consentSourcePage?: string consentVersion?: string email?: string intent?: string marketingTextConsent?: boolean name?: string phone?: string serviceTextConsent?: boolean } } type ChatVisitorProfile = { consentCapturedAt: string consentSourcePage: string consentVersion: string email: string intent: string marketingTextConsent: boolean name: string phone: string serviceTextConsent: boolean } function getOptionalEnv(name: string) { const value = process.env[name] return typeof value === "string" && value.trim() ? value.trim() : "" } function getClientIp(request: NextRequest) { const forwardedFor = request.headers.get("x-forwarded-for") if (forwardedFor) { return forwardedFor.split(",")[0]?.trim() || "unknown" } return request.headers.get("x-real-ip") || "unknown" } function normalizeSessionId(rawSessionId: string | undefined | null) { const value = (rawSessionId || "").trim() if (!value) { return randomUUID() } return value.slice(0, 120) } function normalizePathname(rawPathname: string | undefined) { const pathname = typeof rawPathname === "string" && rawPathname.trim() ? rawPathname.trim() : "/" return pathname.startsWith("/") ? pathname : `/${pathname}` } function normalizeMessages(messages: ChatMessage[] | undefined) { const safeMessages = Array.isArray(messages) ? messages : [] return safeMessages .filter((message) => message && (message.role === "user" || message.role === "assistant")) .map((message) => ({ role: message.role, content: String(message.content || "").replace(/\s+/g, " ").trim().slice(0, SITE_CHAT_MAX_MESSAGE_CHARS), })) .filter((message) => message.content.length > 0) .slice(-SITE_CHAT_MAX_HISTORY_MESSAGES) } function normalizeVisitorProfile(rawVisitor: ChatRequestBody["visitor"], pathname: string): ChatVisitorProfile | null { if (!rawVisitor) { return null } const name = String(rawVisitor.name || "").replace(/\s+/g, " ").trim().slice(0, 80) const phone = String(rawVisitor.phone || "").replace(/\s+/g, " ").trim().slice(0, 40) const email = String(rawVisitor.email || "").replace(/\s+/g, " ").trim().slice(0, 120).toLowerCase() const intent = String(rawVisitor.intent || "").replace(/\s+/g, " ").trim().slice(0, 80) if (!name || !phone || !email || !intent) { return null } const digits = phone.replace(/\D/g, "") const emailIsValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) if (digits.length < 10 || !emailIsValid) { return null } const consentPayload = createSmsConsentPayload({ consentCapturedAt: rawVisitor.consentCapturedAt, consentSourcePage: rawVisitor.consentSourcePage || pathname, consentVersion: rawVisitor.consentVersion, marketingTextConsent: rawVisitor.marketingTextConsent, serviceTextConsent: rawVisitor.serviceTextConsent, }) if (!consentPayload.serviceTextConsent) { return null } return { email, intent, name, phone, ...consentPayload, } } function truncateOutput(text: string) { const clean = text.replace(/\s+/g, " ").trim() if (clean.length <= SITE_CHAT_MAX_OUTPUT_CHARS) { return clean } return `${clean.slice(0, SITE_CHAT_MAX_OUTPUT_CHARS - 1).trimEnd()}…` } function extractAssistantText(data: any) { const messageContent = data?.choices?.[0]?.message?.content if (typeof messageContent === "string") { return messageContent } if (Array.isArray(messageContent)) { return messageContent .map((item) => { if (typeof item === "string") { return item } if (item && typeof item.text === "string") { return item.text } if (item && typeof item.content === "string") { return item.content } return "" }) .join(" ") .trim() } return "" } export async function POST(request: NextRequest) { const responseHeaders: Record = { "Cache-Control": "no-store", } try { const body = (await request.json().catch(() => ({}))) as ChatRequestBody const pathname = normalizePathname(body.pathname) const visitor = normalizeVisitorProfile(body.visitor, pathname) if (isSiteChatSuppressedRoute(pathname)) { return NextResponse.json({ error: "Chat is not available on this route." }, { status: 403, headers: responseHeaders }) } if (!visitor) { return NextResponse.json( { error: "Name, phone, email, intent, and required service SMS consent are needed to start chat.", }, { status: 400, headers: responseHeaders }, ) } const sessionId = normalizeSessionId(body.sessionId || request.cookies.get(SITE_CHAT_SESSION_COOKIE)?.value) const ip = getClientIp(request) const messages = normalizeMessages(body.messages) const latestUserMessage = [...messages].reverse().find((message) => message.role === "user") if (!latestUserMessage) { return NextResponse.json({ error: "A user message is required.", sessionId }, { status: 400, headers: responseHeaders }) } if (latestUserMessage.content.length > SITE_CHAT_MAX_INPUT_CHARS) { return NextResponse.json( { error: `Please keep each message under ${SITE_CHAT_MAX_INPUT_CHARS} characters.`, sessionId, }, { status: 400, headers: responseHeaders }, ) } const limitStatus = getChatRateLimitStatus({ ip, maxIpRequests: SITE_CHAT_MAX_REQUESTS_PER_IP_WINDOW, maxSessionRequests: SITE_CHAT_MAX_REQUESTS_PER_SESSION_WINDOW, maxSessionOutputChars: SITE_CHAT_MAX_OUTPUT_CHARS_PER_SESSION_WINDOW, outputWindowMs: SITE_CHAT_OUTPUT_WINDOW_MS, requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS, sessionId, }) if (limitStatus.blocked) { const blockedResponse = NextResponse.json( { error: "Chat is temporarily limited right now. Please wait a bit or call Rocky Mountain Vending directly.", sessionId, limits: limitStatus, }, { status: 429, headers: responseHeaders }, ) blockedResponse.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 60 * 60 * 24 * 30, }) return blockedResponse } consumeChatRequest({ ip, requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS, sessionId }) const xaiApiKey = getOptionalEnv("XAI_API_KEY") if (!xaiApiKey) { console.warn("[site-chat] missing XAI_API_KEY", { pathname, sessionId, }) return NextResponse.json( { error: "Jessica is temporarily unavailable right now. Please call us or use the contact form.", sessionId, }, { status: 503, headers: responseHeaders }, ) } const completionResponse = await fetch("https://api.x.ai/v1/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${xaiApiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: SITE_CHAT_MODEL, temperature: SITE_CHAT_TEMPERATURE, max_tokens: SITE_CHAT_MAX_OUTPUT_TOKENS, messages: [ { role: "system", content: `${SITE_CHAT_SYSTEM_PROMPT}\n\nConversation context:\n- Current pathname: ${pathname}\n- Source: ${SITE_CHAT_SOURCE}\n- Visitor name: ${visitor.name}\n- Visitor email: ${visitor.email}\n- Visitor phone: ${visitor.phone}\n- Visitor intent: ${visitor.intent}\n- Service SMS consent: ${visitor.serviceTextConsent ? "yes" : "no"}\n- Marketing SMS consent: ${visitor.marketingTextConsent ? "yes" : "no"}`, }, ...messages, ], }), }) const completionData = await completionResponse.json().catch(() => ({})) if (!completionResponse.ok) { console.error("[site-chat] xAI completion failed", { status: completionResponse.status, pathname, sessionId, completionData, }) return NextResponse.json( { error: "Jessica is having trouble replying right now. Please try again or call us directly.", sessionId, }, { status: 502, headers: responseHeaders }, ) } const assistantReply = truncateOutput(extractAssistantText(completionData)) if (!assistantReply) { return NextResponse.json( { error: "Jessica did not return a usable reply. Please try again.", sessionId, }, { status: 502, headers: responseHeaders }, ) } consumeChatOutput({ chars: assistantReply.length, outputWindowMs: SITE_CHAT_OUTPUT_WINDOW_MS, sessionId }) const nextLimitStatus = getChatRateLimitStatus({ ip, maxIpRequests: SITE_CHAT_MAX_REQUESTS_PER_IP_WINDOW, maxSessionRequests: SITE_CHAT_MAX_REQUESTS_PER_SESSION_WINDOW, maxSessionOutputChars: SITE_CHAT_MAX_OUTPUT_CHARS_PER_SESSION_WINDOW, outputWindowMs: SITE_CHAT_OUTPUT_WINDOW_MS, requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS, sessionId, }) const response = NextResponse.json( { reply: assistantReply, sessionId, limits: nextLimitStatus, }, { headers: responseHeaders }, ) response.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 60 * 60 * 24 * 30, }) return response } catch (error) { console.error("[site-chat] request failed", error) const safeError = error instanceof Error && error.message.startsWith("Missing required site chat environment variable:") ? "Jessica is temporarily unavailable right now. Please call us or use the contact form." : error instanceof Error ? error.message : "Chat failed unexpectedly." return NextResponse.json( { error: safeError, }, { status: 500, headers: responseHeaders }, ) } }