const crypto = require('crypto'); const mongoose = require('mongoose'); const ConciergeBot = require('../models/ConciergeBot'); const Event = require('../models/Event'); const WebChatSession = require('../models/WebChatSession'); const { success, error } = require('../utils/response'); const { runConciergePipeline } = require('../services/conciergePipeline.service'); const { interpolateConciergeGreeting } = require('../utils/conciergeGreeting'); const MAX_MESSAGE_LENGTH = 800; const MAX_HISTORY_MESSAGES = 40; const SESSION_IDLE_MS = 30 * 60 * 1000; // Basic in-memory rate limit (best-effort). const ipBuckets = new Map(); const WINDOW_MS = 60_000; const MAX_REQ_PER_WINDOW = 20; function takeIpToken(ip) { const now = Date.now(); const key = String(ip || 'unknown'); const row = ipBuckets.get(key) || { resetAt: now + WINDOW_MS, count: 0 }; if (now > row.resetAt) { row.resetAt = now + WINDOW_MS; row.count = 0; } row.count += 1; ipBuckets.set(key, row); return row.count <= MAX_REQ_PER_WINDOW; } async function loadBotOr404(botId) { if (!mongoose.Types.ObjectId.isValid(String(botId))) return { err: error('VALIDATION_ERROR', 'Invalid bot') }; const bot = await ConciergeBot.findById(botId).lean(); if (!bot) return { err: error('NOT_FOUND', 'Chatbot not found') }; if (!bot.enabled || bot.status !== 'ACTIVE') return { err: error('FORBIDDEN', 'Chatbot is not enabled') }; const event = await Event.findById(bot.event).select('name').lean(); return { bot, event }; } const config = async (request, h) => { const { botId } = request.params; const { bot, event, err } = await loadBotOr404(botId); if (err) return h.response(err).code(err.error?.code === 'NOT_FOUND' ? 404 : 400); const eventName = event?.name || undefined; const greetingTemplate = bot.greeting?.message || ''; return h.response( success({ botId: String(bot._id), eventId: String(bot.event), eventName: eventName || 'Event', greeting: interpolateConciergeGreeting(greetingTemplate, { profileName: undefined, eventName, }), maxMessageLength: MAX_MESSAGE_LENGTH, }), ); }; const chat = async (request, h) => { const ip = request.headers['x-forwarded-for'] || request.info.remoteAddress; if (!takeIpToken(ip)) return h.response(error('RATE_LIMIT', 'Too many requests. Try again in a minute.')).code(429); const { botId } = request.params; const { bot, event, err } = await loadBotOr404(botId); if (err) return h.response(err).code(err.error?.code === 'NOT_FOUND' ? 404 : 400); const msg = String(request.payload?.message || '').trim(); if (!msg) return h.response(error('VALIDATION_ERROR', 'Message is required')).code(400); if (msg.length > MAX_MESSAGE_LENGTH) { return h .response(error('VALIDATION_ERROR', `Message too long (max ${MAX_MESSAGE_LENGTH} chars)`)) .code(400); } let sessionId = String(request.payload?.sessionId || '').trim(); if (!sessionId) sessionId = crypto.randomBytes(16).toString('hex'); let session = await WebChatSession.findOne({ botId: bot._id, sessionId }); const sessionExpired = session && ( session.endedAt || (session.lastSeenAt && Date.now() - new Date(session.lastSeenAt).getTime() > SESSION_IDLE_MS) ); if (sessionExpired) { sessionId = crypto.randomBytes(16).toString('hex'); session = null; } if (!session) { session = await WebChatSession.create({ botId: bot._id, eventId: bot.event, organization: bot.organization, sessionId, history: [], lastSeenAt: new Date(), }); } session.history.push({ role: 'user', text: msg, ts: new Date(), modelId: null, inputTokens: 0, outputTokens: 0, }); if (session.history.length > MAX_HISTORY_MESSAGES) { session.history = session.history.slice(-MAX_HISTORY_MESSAGES); } session.lastSeenAt = new Date(); const historyForPrompt = session.history.slice(-12).map((m) => ({ role: m.role, text: m.text })); const includeEventDetails = bot.knowledge?.includeEventDetails !== false; const includeFoodRecommendations = bot.knowledge?.includeFoodRecommendations !== false; const { determineIntentCategory } = require('../utils/conciergeIntent'); const last6ForIntent = session.history.slice(-6).map((m) => ({ role: m.role, text: m.text, })); const intentResult = session.awaitingPersonalEmail ? { category: 'Personal Information', databaseSearchNeeded: true } : await determineIntentCategory(last6ForIntent); const intentCategory = intentResult?.category || 'unrecognized'; if (intentResult?.databaseSearchNeeded) { console.log('[concierge] database search needed for intent:', intentCategory); } // Web console / backend terminal debugging: show intent classification for each inbound chat message. console.log('[concierge][intent]', { botId: String(bot._id), sessionId, message: msg, intent: intentCategory, databaseSearchNeeded: Boolean(intentResult?.databaseSearchNeeded), }); const pipelineResult = await runConciergePipeline({ eventId: String(bot.event), eventName: event?.name || 'Event', bot, sessionHistory: session.history.map((m) => ({ role: m.role, text: m.text })), intentResult, chatSession: session, organizationId: String(bot.organization), }); const answerText = pipelineResult?.text || "I don't have that in my knowledge base yet. Please check with event staff or try another question."; const places = Array.isArray(pipelineResult?.places) ? pipelineResult.places.slice(0, 5) : []; const foods = Array.isArray(pipelineResult?.foods) ? pipelineResult.foods.slice(0, 5) : []; const reply = String(answerText || '').trim().slice(0, 1200); const inTok = pipelineResult?.usage?.inputTokens || 0; const outTok = pipelineResult?.usage?.outputTokens || 0; session.inputTokensTotal = Number(session.inputTokensTotal || 0) + Number(inTok || 0); session.outputTokensTotal = Number(session.outputTokensTotal || 0) + Number(outTok || 0); session.history.push({ role: 'assistant', text: reply, ts: new Date(), modelId: pipelineResult?.modelId || null, inputTokens: inTok, outputTokens: outTok, }); if (session.history.length > MAX_HISTORY_MESSAGES) { session.history = session.history.slice(-MAX_HISTORY_MESSAGES); } await session.save(); return h.response(success({ sessionId, reply, places, foods, usage: pipelineResult?.usage || null, intent: intentCategory })); }; const end = async (request, h) => { const { botId } = request.params; const { bot, err } = await loadBotOr404(botId); if (err) return h.response(err).code(err.error?.code === 'NOT_FOUND' ? 404 : 400); const sessionId = String(request.payload?.sessionId || '').trim(); if (sessionId) { await WebChatSession.updateOne( { botId: bot._id, sessionId }, { $set: { participant: null, awaitingPersonalEmail: false, identityVerifiedAt: null, identityMethod: null, endedAt: new Date(), lastSeenAt: new Date(), }, }, ); } return h.response(success({ ended: true })); }; module.exports = { config, chat, end, };