428 lines
11 KiB
TypeScript
428 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 }
|
|
)
|
|
}
|
|
}
|