274 lines
8.3 KiB
JavaScript
274 lines
8.3 KiB
JavaScript
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,
|
||
};
|