211 lines
7.1 KiB
JavaScript
211 lines
7.1 KiB
JavaScript
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,
|
|
};
|
|
|