import { NextRequest, NextResponse } from "next/server"; import { decodeHtmlEntitiesServer } from "@/lib/html-decode"; export async function POST(request: NextRequest) { let userMessage = ""; try { // Rate limiting for n8n chat endpoint const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; const { checkRateLimit } = await import('@/lib/auth'); if (!checkRateLimit(ip, 20, 60000)) { // 20 requests per minute for chat return NextResponse.json( { error: 'Rate limit exceeded. Please try again later.' }, { status: 429 } ); } const json = await request.json(); userMessage = json.message; const history = json.history || []; if (!userMessage || typeof userMessage !== "string") { return NextResponse.json( { error: "Message is required" }, { status: 400 }, ); } // Call your n8n chat webhook const n8nWebhookUrl = process.env.N8N_WEBHOOK_URL; if (!n8nWebhookUrl || n8nWebhookUrl.trim() === '') { console.error("N8N_WEBHOOK_URL not configured. Environment check:", { hasUrl: !!process.env.N8N_WEBHOOK_URL, urlValue: process.env.N8N_WEBHOOK_URL || '(empty)', nodeEnv: process.env.NODE_ENV, }); return NextResponse.json({ reply: getFallbackResponse(userMessage), }); } // Ensure URL doesn't have trailing slash before adding /webhook/chat const baseUrl = n8nWebhookUrl.replace(/\/$/, ''); const webhookUrl = `${baseUrl}/webhook/chat`; console.log(`Sending to n8n: ${webhookUrl}`, { hasSecretToken: !!process.env.N8N_SECRET_TOKEN, hasApiKey: !!process.env.N8N_API_KEY, }); // Add timeout to prevent hanging requests const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout try { const response = await fetch(webhookUrl, { method: "POST", headers: { "Content-Type": "application/json", ...(process.env.N8N_SECRET_TOKEN && { Authorization: `Bearer ${process.env.N8N_SECRET_TOKEN}`, }), ...(process.env.N8N_API_KEY && { "X-API-Key": process.env.N8N_API_KEY, }), }, body: JSON.stringify({ message: userMessage, history: history, }), signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { const errorText = await response.text().catch(() => 'Unknown error'); console.error(`n8n webhook failed with status: ${response.status}`, { status: response.status, statusText: response.statusText, error: errorText, webhookUrl: webhookUrl.replace(/\/\/[^:]+:[^@]+@/, '//***:***@'), // Hide credentials in logs }); throw new Error(`n8n webhook failed: ${response.status} - ${errorText.substring(0, 200)}`); } const data = await response.json(); console.log("n8n response data (full):", JSON.stringify(data, null, 2)); console.log("n8n response data type:", typeof data); console.log("n8n response is array:", Array.isArray(data)); // Try multiple ways to extract the reply let reply: string | undefined = undefined; // Direct fields if (data.reply) reply = data.reply; else if (data.message) reply = data.message; else if (data.response) reply = data.response; else if (data.text) reply = data.text; else if (data.content) reply = data.content; else if (data.answer) reply = data.answer; else if (data.output) reply = data.output; else if (data.result) reply = data.result; // Array handling else if (Array.isArray(data) && data.length > 0) { const firstItem = data[0]; if (typeof firstItem === 'string') { reply = firstItem; } else if (typeof firstItem === 'object') { reply = firstItem.reply || firstItem.message || firstItem.response || firstItem.text || firstItem.content || firstItem.answer || firstItem.output || firstItem.result; } } // Nested structures (common in n8n) else if (data && typeof data === "object") { // Check nested data field if (data.data) { if (typeof data.data === 'string') { reply = data.data; } else if (typeof data.data === 'object') { reply = data.data.reply || data.data.message || data.data.response || data.data.text || data.data.content || data.data.answer; } } // Check nested json field if (!reply && data.json) { if (typeof data.json === 'string') { reply = data.json; } else if (typeof data.json === 'object') { reply = data.json.reply || data.json.message || data.json.response || data.json.text || data.json.content || data.json.answer; } } // Check items array (n8n often wraps in items) if (!reply && Array.isArray(data.items) && data.items.length > 0) { const firstItem = data.items[0]; if (typeof firstItem === 'string') { reply = firstItem; } else if (typeof firstItem === 'object') { reply = firstItem.reply || firstItem.message || firstItem.response || firstItem.text || firstItem.content || firstItem.answer || firstItem.json?.reply || firstItem.json?.message; } } // Last resort: if it's a single string value object, try to extract if (!reply && Object.keys(data).length === 1) { const value = Object.values(data)[0]; if (typeof value === 'string') { reply = value; } } // If still no reply but data exists, stringify it (for debugging) if (!reply && Object.keys(data).length > 0) { console.warn("n8n response structure not recognized, attempting to extract any string value"); // Try to find any string value in the object const findStringValue = (obj: unknown): string | undefined => { if (typeof obj === 'string' && obj.length > 0) return obj; if (Array.isArray(obj) && obj.length > 0) { return findStringValue(obj[0]); } if (obj && typeof obj === 'object' && obj !== null) { const objRecord = obj as Record; for (const key of ['reply', 'message', 'response', 'text', 'content', 'answer', 'output', 'result']) { if (objRecord[key] && typeof objRecord[key] === 'string') { return objRecord[key] as string; } } // Recursively search for (const value of Object.values(objRecord)) { const found = findStringValue(value); if (found) return found; } } return undefined; }; reply = findStringValue(data); } } if (!reply) { console.error("n8n response missing reply field. Full response:", JSON.stringify(data, null, 2)); throw new Error("Invalid response format from n8n - no reply field found"); } // Decode HTML entities in the reply const decodedReply = decodeHtmlEntitiesServer(String(reply)); return NextResponse.json({ reply: decodedReply, }); } catch (fetchError: unknown) { clearTimeout(timeoutId); if (fetchError instanceof Error && fetchError.name === 'AbortError') { console.error("n8n webhook request timed out"); } else { console.error("n8n webhook fetch error:", fetchError); } throw fetchError; } } catch (error: unknown) { console.error("Chat API error:", error); console.error("Error details:", { message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, n8nUrl: process.env.N8N_WEBHOOK_URL ? `configured (${process.env.N8N_WEBHOOK_URL})` : 'missing', hasSecretToken: !!process.env.N8N_SECRET_TOKEN, hasApiKey: !!process.env.N8N_API_KEY, nodeEnv: process.env.NODE_ENV, }); // Fallback to mock responses // Now using the variable captured at the start return NextResponse.json({ reply: getFallbackResponse(userMessage) }); } } function getFallbackResponse(message: string): string { if (!message || typeof message !== "string") { return "I'm having a bit of trouble understanding. Could you try asking again?"; } const lowerMessage = message.toLowerCase(); if ( lowerMessage.includes("skill") || lowerMessage.includes("tech") || lowerMessage.includes("stack") ) { return "I specialize in full-stack development with Next.js, React, and Flutter for mobile. On the DevOps side, I love working with Docker Swarm, Traefik, and CI/CD pipelines. Basically, if it involves code or servers, I'm interested!"; } if ( lowerMessage.includes("project") || lowerMessage.includes("built") || lowerMessage.includes("work") ) { return "One of my key projects is Clarity, a Flutter app designed to help people with dyslexia. I also maintain a comprehensive self-hosted infrastructure with Docker Swarm. You can check out more details in the Projects section!"; } if ( lowerMessage.includes("contact") || lowerMessage.includes("email") || lowerMessage.includes("reach") || lowerMessage.includes("hire") ) { return "The best way to reach me is through the contact form below or by emailing contact@dk0.dev. I'm always open to discussing new ideas, opportunities, or just chatting about tech!"; } if ( lowerMessage.includes("location") || lowerMessage.includes("where") || lowerMessage.includes("live") ) { return "I'm based in Osnabrück, Germany. It's a great place to be a student and work on tech projects!"; } if ( lowerMessage.includes("hobby") || lowerMessage.includes("free time") || lowerMessage.includes("fun") ) { return "When I'm not coding or tweaking my servers, I enjoy gaming, going for a jog, or experimenting with new tech. Fun fact: I still use pen and paper for my calendar, even though I automate everything else!"; } if ( lowerMessage.includes("devops") || lowerMessage.includes("docker") || lowerMessage.includes("server") || lowerMessage.includes("hosting") ) { return "I'm really into DevOps! I run my own infrastructure on IONOS and OVHcloud using Docker Swarm and Traefik. It allows me to host various services and game servers efficiently while learning a ton about system administration."; } if ( lowerMessage.includes("student") || lowerMessage.includes("study") || lowerMessage.includes("education") ) { return "Yes, I'm currently a student in Osnabrück. I balance my studies with working on personal projects and managing my self-hosted infrastructure. It keeps me busy but I learn something new every day!"; } if ( lowerMessage.includes("hello") || lowerMessage.includes("hi ") || lowerMessage.includes("hey") ) { return "Hi there! I'm Dennis's AI assistant (currently in offline mode). How can I help you learn more about Dennis today?"; } // Default response return "That's an interesting question! I'm currently operating in fallback mode, so my knowledge is a bit limited right now. But I can tell you that Dennis is a full-stack developer and DevOps enthusiast who loves building with Next.js and Docker. Feel free to ask about his skills, projects, or how to contact him!"; }