const crypto = require('crypto'); const Participant = require('../models/Participant'); const { sendConciergeOtpEmail } = require('../utils/mail'); const OTP_TTL_MS = 10 * 60 * 1000; const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i; function extractEmail(value) { const match = String(value || '').match(EMAIL_RE); return match ? match[0].trim().toLowerCase() : ''; } function normalizeEmail(value) { return String(value || '').trim().toLowerCase(); } function isFixedOtpMode() { return String(process.env.CONCIERGE_OTP_MODE || 'dynamic').toLowerCase() === 'fixed'; } function generateOtp() { if (isFixedOtpMode()) { const fixed = String(process.env.CONCIERGE_FIXED_OTP || '123123').replace(/\D/g, ''); return fixed.padStart(6, '0').slice(-6); } return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0'); } function hashOtp(otp, eventId, email) { const pepper = process.env.OTP_PEPPER || 'eventbuzz-concierge-otp'; return crypto .createHash('sha256') .update(`${pepper}|${otp}|${eventId}|${email}`) .digest('hex'); } function isVerified(session) { return Boolean(session?.participant && session?.identityVerifiedAt); } function clearAuthChallenge(session) { if (typeof session.$unset === 'function') { session.$unset('authState'); } else { session.authState = undefined; } session.authEmail = null; session.otpHash = null; session.otpExpiresAt = null; } function clearPending(session) { session.pendingIntent = null; session.pendingMessage = null; session.pendingEntities = null; } function clearIdentity(session) { session.participant = null; session.identityVerifiedAt = null; session.identityMethod = null; clearAuthChallenge(session); clearPending(session); } function membershipForEvent(participant, eventId) { const eid = String(eventId); const rows = Array.isArray(participant?.eventMemberships) ? participant.eventMemberships : []; return rows.find((m) => String(m.event) === eid) || null; } const AUTH_CANCEL_ONLY_RE = /^(please\s+)?(ignore|cancel|skip|stop)(\s+(that|this|it))?([.!]*)?$|^(never mind|nevermind|forget it|not now|no thanks|no thank you|later)([.!]*)?$/i; function isAuthCancelMessage(text) { const t = String(text || '').trim(); if (!t || extractEmail(t)) return false; if (AUTH_CANCEL_ONLY_RE.test(t)) return true; return /\b(ignore|cancel|skip|never mind|nevermind|forget it|not now|stop asking)\b/i.test(t); } function isAuthCancelOnly(text) { return AUTH_CANCEL_ONLY_RE.test(String(text || '').trim()); } function looksLikeNewQuestionDuringAuth(text) { const t = String(text || '').trim(); if (!t || extractEmail(t)) return false; if (/^\d{4,8}$/.test(t.replace(/\s/g, ''))) return false; if (t.includes('?')) return true; return t.split(/\s+/).filter(Boolean).length >= 4; } async function beginEmailAuth(session, { intent, message, eventName, entities = null }) { session.pendingIntent = intent || null; session.pendingMessage = message || null; session.pendingEntities = entities && typeof entities === 'object' ? entities : null; clearAuthChallengeFieldsKeepPending(session); session.authState = 'awaiting_email'; return { reply: 'Send the email you registered with, or say cancel.', classification: null, usage: { inputTokens: 0, outputTokens: 0 }, modelId: null, resumed: false, }; } function clearAuthChallengeFieldsKeepPending(session) { session.authEmail = null; session.otpHash = null; session.otpExpiresAt = null; } async function sendOtpForEmail(session, { email, eventId, eventName }) { const em = normalizeEmail(email); const participant = await Participant.findOne({ tenantOrganization: session.organization, email: em, deletedAt: null, }).lean(); if (!participant || !membershipForEvent(participant, eventId)) { session.authState = 'awaiting_email'; clearAuthChallengeFieldsKeepPending(session); return { reply: `I couldn’t find a registration for ${em} on this event. ` + `Please use the email you registered with, or contact the organizers if you need help.`, verified: false, }; } const otp = generateOtp(); session.authState = 'awaiting_otp'; session.authEmail = em; session.otpHash = hashOtp(otp, String(eventId), em); session.otpExpiresAt = new Date(Date.now() + OTP_TTL_MS); if (isFixedOtpMode()) { console.log(`[concierge OTP — fixed mode] to=${em} code=${otp}`); await sendConciergeOtpEmail({ to: em, otp, eventName }); return { reply: `I found your registration for ${em}. Reply with ${otp} to continue.`, verified: false, }; } const sent = await sendConciergeOtpEmail({ to: em, otp, eventName }); if (!sent) { return { reply: `I found your registration, but couldn’t send the verification email right now. Please try again in a moment.`, verified: false, }; } return { reply: `I sent a 6-digit code to ${em}. Please reply with that code to continue.`, verified: false, }; } async function verifyOtpAndLink(session, { code, eventId }) { const em = normalizeEmail(session.authEmail); const digits = String(code || '').replace(/\D/g, '').slice(0, 6); if (!em || digits.length !== 6) { return { reply: 'Please reply with the 6-digit verification code from your email.', verified: false, }; } if (!session.otpHash || !session.otpExpiresAt || new Date(session.otpExpiresAt).getTime() < Date.now()) { session.authState = 'awaiting_email'; clearAuthChallengeFieldsKeepPending(session); return { reply: 'That code has expired. Please send your registration email again so I can send a new code.', verified: false, }; } const expected = hashOtp(digits, String(eventId), em); if (expected !== session.otpHash) { return { reply: 'That code is incorrect. Please try again, or send your email again to get a new code.', verified: false, }; } const participant = await Participant.findOne({ tenantOrganization: session.organization, email: em, deletedAt: null, }); if (!participant || !membershipForEvent(participant, eventId)) { session.authState = 'awaiting_email'; clearAuthChallengeFieldsKeepPending(session); return { reply: 'I couldn’t verify that registration anymore. Please send your registration email again.', verified: false, }; } session.participant = participant._id; session.identityVerifiedAt = new Date(); session.identityMethod = 'email'; clearAuthChallenge(session); return { reply: null, verified: true, participant }; } /** * Handle an in-progress auth challenge turn. * @returns {{ reply: string|null, verified: boolean, shouldResume: boolean }} */ async function handleAuthTurn(session, { message, eventId, eventName }) { const text = String(message || '').trim(); const state = session.authState; const cancelAuth = () => ({ reply: null, verified: false, shouldResume: false, cancelled: true, cancelOnly: isAuthCancelOnly(text), }); if (state === 'awaiting_email') { if (isAuthCancelMessage(text) || looksLikeNewQuestionDuringAuth(text)) { return cancelAuth(); } const email = extractEmail(text); if (!email) { return { reply: 'Send the email you registered with, or say cancel.', verified: false, shouldResume: false, }; } const result = await sendOtpForEmail(session, { email, eventId, eventName }); return { ...result, shouldResume: false }; } if (state === 'awaiting_otp') { if (isAuthCancelMessage(text) || looksLikeNewQuestionDuringAuth(text)) { return cancelAuth(); } // Allow restarting with a new email const maybeEmail = extractEmail(text); if (maybeEmail && !/^\d{4,8}$/.test(text.replace(/\s/g, ''))) { const result = await sendOtpForEmail(session, { email: maybeEmail, eventId, eventName }); return { ...result, shouldResume: false }; } const result = await verifyOtpAndLink(session, { code: text, eventId }); if (!result.verified) { return { ...result, shouldResume: false }; } return { reply: null, verified: true, shouldResume: true }; } return { reply: null, verified: isVerified(session), shouldResume: false }; } module.exports = { extractEmail, isVerified, isAuthCancelMessage, beginEmailAuth, handleAuthTurn, clearIdentity, clearAuthChallenge, clearPending, };