Rocky_Mountain_Vending/app/api/chat/route.ts

371 lines
11 KiB
TypeScript

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<string, string> = {
"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 },
)
}
}