import express, { Request, Response } from "express"; import { storage } from "../storage"; import { DEFAULT_GREETING, DEFAULT_RECALL_GREETING } from "../storage/twilio-storage"; import { prisma as db } from "@repo/db/client"; import { sendTelnyxSms } from "../comms/telnyx-client"; import { runNewPatientStep } from "../ai/new-patient-graph"; import { parseDateOnlyFromMessage, parseTime, isOfficeDayOpen, isWithinOfficeHours, getOfficeHoursDisplay, timeLabel, } from "../ai/reschedule-graph"; import { getLlm, resolveAiProvider, type AiProvider } from "../ai/llm-factory"; import { soundsLikeGoodbye } from "../ai/voice-assistant"; import { getPublicBaseUrl } from "../utils/publicUrl"; import { routePatientTurn, getAppointmentDatetime, applyOfficeName, } from "../ai/patient-conversation-router"; import { getHandoff, getAfterHoursHandoff, getStage, setStage, resetConversation, startRecallConversation, setPendingReschedule, getPendingReschedule, clearPendingReschedule, type ConversationStage, } from "../ai/aiHandoffStore"; const router = express.Router(); // ── Helpers ─────────────────────────────────────────────────────────────────── function escapeXml(text: string): string { return text .replace(/&/g, "&").replace(//g, ">") .replace(/"/g, """).replace(/'/g, "'"); } function ok(res: Response) { return res.status(200).json({ ok: true }); } type TelnyxSmsSettings = { apiKey: string; phoneNumber: string; messagingProfileId?: string | null }; /** Telnyx has no synchronous TeXML-style SMS reply — every reply is a * separate outbound API call. Sequential await preserves ordering. */ async function sendReplies(settings: TelnyxSmsSettings, to: string, texts: string[]): Promise { for (const text of texts) { await sendTelnyxSms({ apiKey: settings.apiKey, from: settings.phoneNumber, to, text, messagingProfileId: settings.messagingProfileId, }); } } /** Check if right now is outside office hours for the given user. */ async function isAfterHours(userId: number): Promise { const record = await storage.getOfficeHours(userId); if (!record?.data) return false; // no hours configured → treat as in-hours const data = record.data as any; const now = new Date(); const days = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"]; const day = days[now.getDay()]; const slot = data.doctors?.[day]; if (!slot?.enabled) return true; // office closed today const hhmm = `${String(now.getHours()).padStart(2,"0")}:${String(now.getMinutes()).padStart(2,"0")}`; if (hhmm >= slot.amStart && hhmm <= slot.amEnd) return false; if (hhmm >= slot.pmStart && hhmm <= slot.pmEnd) return false; return true; } /** Save an outbound message and return the text. */ async function saveOutbound(patientId: number, body: string): Promise { await storage.createCommunication({ patientId, channel: "sms", direction: "outbound", status: "sent", body, provider: "telnyx", }); } // ── Empathetic one-liner (instant keyword-based, no API latency) ───────────── function getEmpatheticAck(message: string, language: string): string { const hasPain = /pain|hurt|ache|toothache|emergency|urgent|asap|bleeding|broke|crack|fell out|swollen|infection|abscess/i.test(message); const painMsgs: Record = { English: "Sorry to hear that! We are here to help you.", Spanish: "¡Lo sentimos! Estamos aquí para ayudarle.", Portuguese: "Lamentamos isso! Estamos aqui para ajudá-lo.", Mandarin: "很遗憾听到这个消息!我们在这里帮助您。", Cantonese: "很遺憾聽到這個消息!我們在這裡幫助您。", Arabic: "آسف لسماع ذلك! نحن هنا لمساعدتك.", "Haitian Creole": "Nou regrèt tande sa! Nou la pou ede ou.", }; const normalMsgs: Record = { English: "No problem!", Spanish: "¡Sin problema!", Portuguese: "Sem problema!", Mandarin: "没问题!", Cantonese: "沒問題!", Arabic: "لا مشكلة!", "Haitian Creole": "Pa gen pwoblèm!", }; return hasPain ? (painMsgs[language] ?? painMsgs["English"]!) : (normalMsgs[language] ?? normalMsgs["English"]!); } // ── Stale-conversation handling (SMS) ───────────────────────────────────────── // Unlike a phone call, a long gap between texts doesn't necessarily mean the // patient walked away — replies are often just slow. So instead of hard // resetting on any idle gap, we only second-guess the stored stage once the // gap crosses STALE_SMS_THRESHOLD_MS, and even then we ask the LLM to look at // the recent history before deciding whether the new text is still answering // the last question or reads like a fresh, unrelated conversation. const STALE_SMS_THRESHOLD_MS = 3 * 60 * 60 * 1000; // 3 hours const STILL_HERE_MSGS: Record = { English: "It's been a while since our last message. How can I help you today?", Spanish: "Ha pasado un tiempo desde nuestro último mensaje. ¿Cómo puedo ayudarle hoy?", Portuguese: "Já faz um tempo desde nossa última mensagem. Como posso ajudá-lo hoje?", Mandarin: "距离我们上次的消息已经有一段时间了。今天我能帮您什么?", Cantonese: "距離我們上次嘅消息已經有一段時間喇。今日我可以點幫您?", Arabic: "لقد مر بعض الوقت منذ آخر رسالة لنا. كيف يمكنني مساعدتك اليوم؟", "Haitian Creole": "Sa gen yon ti tan depi dènye mesaj nou. Kijan mwen ka ede ou jodi a?", }; /** * After a long idle gap, ask the LLM whether the patient's new text still * answers what was last asked (recent history + stage in context) or reads * like an unrelated, fresh message. Defaults to "continue" on any failure — * an LLM call error shouldn't be the reason a legitimate reply gets bounced. */ async function isLikelyContinuation( recentHistory: { role: "user" | "assistant"; text: string }[], newMessage: string, stage: ConversationStage, apiKey: string, provider: AiProvider, model?: string, ): Promise { if (recentHistory.length === 0) return true; try { const llm = getLlm(provider, apiKey, model); const transcript = recentHistory .map(m => `${m.role === "assistant" ? "Office" : "Patient"}: ${m.text}`) .join("\n"); const res = await llm.invoke([ { role: "system", content: `You are helping a dental office text-messaging assistant resume a conversation with a patient after a long gap of silence. ` + `Here is the recent conversation (oldest first):\n${transcript}\n\n` + `The assistant is currently waiting on stage "${stage}" of a booking flow. ` + `The patient just sent a new message after the long gap: "${newMessage}"\n\n` + `Does this new message still look like it's answering/continuing that same conversation, or does it read like an unrelated, fresh message (e.g. a new question, a greeting, a different topic)? ` + `Reply with ONLY one word: "continue" or "restart".`, }, ]); return !/restart/i.test(String(res.content)); } catch { return true; } } // ── POST /api/telnyx/webhook/sms ────────────────────────────────────────────── const CONVO_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes // In-memory conversation state for unknown phone numbers (no patient record yet). // Keyed by normalized "From" number. const unknownPhoneConvos = new Map(); router.post("/webhook/sms", async (req: Request, res: Response): Promise => { try { // Telnyx webhooks are JSON with a nested event envelope, not Twilio's flat // form-encoded From/To/Body/MessageSid. Ignore any event type other than // an inbound message — Telnyx may fan message.sent/message.finalized into // the same webhook URL depending on portal config. const event = (req.body as any)?.data; if (!event || event.event_type !== "message.received") return ok(res); const payload = event.payload || {}; const From = payload?.from?.phone_number || ""; const To = payload?.to?.[0]?.phone_number || ""; const Body = payload?.text || ""; const MessageSid = payload?.id; const normalizedFrom = (From || "").replace(/\D/g, ""); // Ordered by id so that when duplicate patients share a phone number // (one record per insurance), the earliest-created record is the default. const allPatients = await db.patient.findMany({ select: { id: true, phone: true, userId: true, preferredLanguage: true }, orderBy: { id: "asc" }, }); const patient = allPatients.find( (p) => p.phone && p.phone.replace(/\D/g, "") === normalizedFrom ); if (!patient) { // Unknown number — look up office by the Telnyx "To" number const normalizedTo = (To || "").replace(/\D/g, ""); const telnyxRow = await db.telnyxSettings.findFirst({ where: { phoneNumber: { contains: normalizedTo.slice(-10) } }, }); if (!telnyxRow) return ok(res); const userId = telnyxRow.userId; const openPhoneReply = await storage.getOpenPhoneReply(userId); if (!openPhoneReply) return ok(res); // Fetch required context for this office const aiSettings = await storage.getAiSettings(userId); const activeAiU = resolveAiProvider(aiSettings ?? {}); if (!activeAiU) return ok(res); const chatTemplates = await storage.getAiChatTemplates(userId); const officeContact = await storage.getOfficeContact(userId); const officeName = (officeContact as any)?.officeName?.trim() || ""; const convo = unknownPhoneConvos.get(normalizedFrom); let stage = convo?.stage ?? "initial"; const language = convo?.language ?? "English"; // Reset conversation if idle for more than 5 minutes if ( stage !== "initial" && stage !== "done" && convo?.lastActivityAt && Date.now() - convo.lastActivityAt.getTime() > CONVO_TIMEOUT_MS ) { stage = "initial"; unknownPhoneConvos.set(normalizedFrom, { userId, stage: "initial", language, lastActivityAt: new Date() }); } const replyUnknown = async ( text: string, nextStage: ConversationStage, pendingApptDate?: { date: Date; dateLabel: string }, ) => { unknownPhoneConvos.set(normalizedFrom, { userId, stage: nextStage, language, lastActivityAt: new Date(), ...(pendingApptDate ? { pendingApptDate } : { pendingApptDate: convo?.pendingApptDate }), }); await sendReplies(telnyxRow, From, [text]); return ok(res); }; if (stage === "initial" || stage === "done") { // MSG 1: AI intro const rawGreeting = chatTemplates.newPatientGreeting || `Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you schedule an appointment, check your insurance, and answer general questions 24/7.`; const introText = applyOfficeName(rawGreeting, officeName); // MSG 2: Empathetic acknowledgment const empatheticText = getEmpatheticAck(Body, language); // MSG 3: Detect intent from first message const isReschedule = /reschedule|rescheduler|change.*appoint|modify.*appoint|move.*appoint|reprogramar|reagendar|cambiar|mudar/i.test(Body); let msg3Unk: string; let nextStage3Unk: ConversationStage; if (isReschedule) { const notFoundMsgs: Record = { English: "I couldn't find an appointment associated with your number. Please leave your name and a good callback number and our receptionist will assist you as soon as possible.", Spanish: "No encontré una cita asociada a su número. Por favor deje su nombre y un número de contacto y nuestra recepcionista le atenderá lo antes posible.", Portuguese: "Não encontrei uma consulta associada ao seu número. Por favor deixe seu nome e um número de contato e nossa recepcionista lhe atenderá o mais breve possível.", Mandarin: "我找不到与您号码关联的预约。请留下您的姓名和联系电话,我们的前台将尽快与您联系。", Cantonese: "我找不到與您號碼關聯的預約。請留下您的姓名和聯絡電話,我們的接待員將盡快與您聯絡。", Arabic: "لم أجد موعداً مرتبطاً برقمك. يرجى ترك اسمك ورقم هاتف للتواصل وسيساعدك موظف الاستقبال في أقرب وقت.", "Haitian Creole": "Mwen pa jwenn yon randevou ki asosye ak nimewo ou. Tanpri kite non ou ak yon nimewo pou rele epi resepsyonis nou an pral ede ou pi vit posib.", }; msg3Unk = notFoundMsgs[language] ?? notFoundMsgs["English"]!; nextStage3Unk = "collecting_contact_info"; } else { const newOrExistingMsgs: Record = { English: "Can you please tell me if you are a new or existing patient?", Spanish: "¿Puede decirme si es usted un paciente nuevo o existente?", Portuguese: "Pode me dizer se você é um paciente novo ou existente?", Mandarin: "请问您是新患者还是现有患者?", Cantonese: "請問您是新病人還是現有病人?", Arabic: "هل يمكنك إخباري إذا كنت مريضاً جديداً أم حالياً؟", "Haitian Creole": "Èske ou ka di mwen si ou se yon nouvo pasyan oswa yon pasyan egzistan?", }; msg3Unk = newOrExistingMsgs[language] ?? newOrExistingMsgs["English"]!; nextStage3Unk = "asked_new_or_existing"; } // Update store then send all 3 in guaranteed order unknownPhoneConvos.set(normalizedFrom, { userId, stage: nextStage3Unk, language, lastActivityAt: new Date(), }); await sendReplies(telnyxRow, From, [introText, empatheticText, msg3Unk]); return ok(res); } // ── Unknown: asked_new_or_reschedule (fallback for in-progress conversations) ── if (stage === "asked_new_or_reschedule") { const isReschedule = /reschedule|rescheduler|change|modify|move|reprogramar|reagendar|cambiar|mudar/i.test(Body); if (isReschedule) { const notFoundMsgs: Record = { English: "I couldn't find an appointment associated with your number. Please leave your name and a good callback number and our receptionist will assist you as soon as possible.", Spanish: "No encontré una cita asociada a su número. Por favor deje su nombre y un número de contacto y nuestra recepcionista le atenderá lo antes posible.", Portuguese: "Não encontrei uma consulta associada ao seu número. Por favor deixe seu nome e um número de contato e nossa recepcionista lhe atenderá o mais breve possível.", Mandarin: "我找不到与您号码关联的预约。请留下您的姓名和联系电话,我们的前台将尽快与您联系。", Cantonese: "我找不到與您號碼關聯的預約。請留下您的姓名和聯絡電話,我們的接待員將盡快與您聯絡。", Arabic: "لم أجد موعداً مرتبطاً برقمك. يرجى ترك اسمك ورقم هاتف للتواصل وسيساعدك موظف الاستقبال في أقرب وقت.", "Haitian Creole": "Mwen pa jwenn yon randevou ki asosye ak nimewo ou. Tanpri kite non ou ak yon nimewo pou rele epi resepsyonis nou an pral ede ou pi vit posib.", }; return await replyUnknown(notFoundMsgs[language] ?? notFoundMsgs["English"]!, "collecting_contact_info"); } const newOrExistingMsgs: Record = { English: "Can you please tell me if you are a new or existing patient?", Spanish: "¿Puede decirme si es usted un paciente nuevo o existente?", Portuguese: "Pode me dizer se você é um paciente novo ou existente?", Mandarin: "请问您是新患者还是现有患者?", Cantonese: "請問您是新病人還是現有病人?", Arabic: "هل يمكنك إخباري إذا كنت مريضاً جديداً أم حالياً؟", "Haitian Creole": "Èske ou ka di mwen si ou se yon nouvo pasyan oswa yon pasyan egzistan?", }; return await replyUnknown(newOrExistingMsgs[language] ?? newOrExistingMsgs["English"]!, "asked_new_or_existing"); } // ── Unknown: asked_appointment_time → parse date, check office hours ── if (stage === "asked_appointment_time") { const parsedDate = await parseDateOnlyFromMessage(Body, activeAiU.key, activeAiU.provider, activeAiU.model); if (!parsedDate) { const msgs: Record = { English: "I didn't catch that. What day would you prefer? For example: 'May 28', 'next Monday', or '5/28'.", Spanish: "No entendí. ¿Qué día prefiere? Por ejemplo: '28 de mayo', 'próximo lunes' o '28/5'.", Portuguese: "Não entendi. Que dia você prefere? Por exemplo: '28 de maio', 'próxima segunda' ou '28/5'.", Mandarin: "我没听清。您希望哪天?例如:'5月28日'、'下周一'或'5/28'。", Cantonese: "我沒聽清。您希望哪天?例如:'5月28日'、'下週一'或'5/28'。", Arabic: "لم أفهم. ما اليوم الذي تفضله؟ مثلاً: '28 مايو' أو '5/28'.", "Haitian Creole": "Mwen pa konprann. Ki jou ou prefere? Pa egzanp: '28 me', 'Lendi pwochèn', oswa '5/28'.", }; return await replyUnknown(msgs[language] ?? msgs["English"]!, "asked_appointment_time"); } const { date, dateLabel } = parsedDate; const dayCheck = await isOfficeDayOpen(date, userId); if (!dayCheck.open) { const msgs: Record = { English: `Our office is closed on ${dateLabel} (${dayCheck.displayDay}). Can you please choose another day?`, Spanish: `Nuestra oficina está cerrada el ${dateLabel} (${dayCheck.displayDay}). ¿Puede elegir otro día?`, Portuguese: `Nosso consultório está fechado em ${dateLabel} (${dayCheck.displayDay}). Pode escolher outro dia?`, Mandarin: `我们诊所在 ${dateLabel}(${dayCheck.displayDay})不开放。请您选择另一天好吗?`, Cantonese: `我們診所在 ${dateLabel}(${dayCheck.displayDay})不開放。請您選擇另一天好嗎?`, Arabic: `مكتبنا مغلق في ${dateLabel} (${dayCheck.displayDay}). هل يمكنك اختيار يوم آخر؟`, "Haitian Creole": `Biwo nou fèmen nan ${dateLabel} (${dayCheck.displayDay}). Tanpri chwazi yon lòt jou?`, }; return await replyUnknown(msgs[language] ?? msgs["English"]!, "asked_appointment_time"); } const askTimeMsgs: Record = { English: `What time do you prefer on ${dateLabel}?`, Spanish: `¿A qué hora prefiere el ${dateLabel}?`, Portuguese: `Que horário você prefere em ${dateLabel}?`, Mandarin: `您希望在 ${dateLabel} 几点?`, Cantonese: `您希望在 ${dateLabel} 幾點?`, Arabic: `ما الوقت الذي تفضله في ${dateLabel}؟`, "Haitian Creole": `Ki lè ou prefere nan ${dateLabel}?`, }; return await replyUnknown( askTimeMsgs[language] ?? askTimeMsgs["English"]!, "asked_new_appt_time_for_date", { date, dateLabel }, ); } // ── Unknown: asked_new_appt_time_for_date → parse time, check hours ─── if (stage === "asked_new_appt_time_for_date") { const pendingApptDate = convo?.pendingApptDate; if (!pendingApptDate) { return await replyUnknown( "I lost track of the date. What day would you prefer?", "asked_appointment_time", ); } const startTime = await parseTime(Body, activeAiU.key, activeAiU.provider, activeAiU.model); if (!startTime) { const msgs: Record = { English: `I didn't catch the time. What time do you prefer on ${pendingApptDate.dateLabel}? For example: '10am' or '2pm'.`, Spanish: `No entendí la hora. ¿Qué hora prefiere el ${pendingApptDate.dateLabel}? Por ejemplo: '10am' o '2pm'.`, Portuguese: `Não entendi o horário. Que hora você prefere em ${pendingApptDate.dateLabel}? Por exemplo: '10h' ou '14h'.`, Mandarin: `我没听清时间。您在 ${pendingApptDate.dateLabel} 几点?例如:上午10点或下午2点。`, Cantonese: `我沒聽清時間。您在 ${pendingApptDate.dateLabel} 幾點?例如:上午10點或下午2點。`, Arabic: `لم أفهم الوقت. ما الوقت الذي تفضله في ${pendingApptDate.dateLabel}؟ مثلاً: 10 صباحاً أو 2 مساءً.`, "Haitian Creole": `Mwen pa konprann lè a. Ki lè ou prefere nan ${pendingApptDate.dateLabel}? Pa egzanp: 10am oswa 2pm.`, }; return await replyUnknown(msgs[language] ?? msgs["English"]!, "asked_new_appt_time_for_date"); } const withinHours = await isWithinOfficeHours(pendingApptDate.date, startTime, userId); if (!withinHours) { const hoursDisplay = await getOfficeHoursDisplay(pendingApptDate.date, userId); const timeLbl = timeLabel(startTime); const msgs: Record = { English: hoursDisplay ? `Our office is not available at ${timeLbl} on ${pendingApptDate.dateLabel}. Our hours are ${hoursDisplay}. What other time do you prefer?` : `Our office is not available at ${timeLbl} on ${pendingApptDate.dateLabel}. What other time do you prefer?`, Spanish: hoursDisplay ? `Nuestra oficina no está disponible a las ${timeLbl} el ${pendingApptDate.dateLabel}. Nuestro horario es ${hoursDisplay}. ¿Qué otro horario prefiere?` : `Nuestra oficina no está disponible a las ${timeLbl} el ${pendingApptDate.dateLabel}. ¿Qué otro horario prefiere?`, Portuguese: hoursDisplay ? `Nosso consultório não está disponível às ${timeLbl} em ${pendingApptDate.dateLabel}. Nosso horário é ${hoursDisplay}. Que outro horário você prefere?` : `Nosso consultório não está disponível às ${timeLbl} em ${pendingApptDate.dateLabel}. Que outro horário você prefere?`, Mandarin: hoursDisplay ? `我们诊所在 ${pendingApptDate.dateLabel} ${timeLbl} 不开放。工作时间是 ${hoursDisplay}。您希望改什么时间?` : `我们诊所在 ${pendingApptDate.dateLabel} ${timeLbl} 不开放。您希望改什么时间?`, Cantonese: hoursDisplay ? `我們診所在 ${pendingApptDate.dateLabel} ${timeLbl} 不開放。工作時間是 ${hoursDisplay}。您希望改什麼時間?` : `我們診所在 ${pendingApptDate.dateLabel} ${timeLbl} 不開放。您希望改什麼時間?`, Arabic: hoursDisplay ? `مكتبنا غير متاح في ${timeLbl} يوم ${pendingApptDate.dateLabel}. ساعات العمل: ${hoursDisplay}. ما وقت آخر تفضله؟` : `مكتبنا غير متاح في ${timeLbl} يوم ${pendingApptDate.dateLabel}. ما وقت آخر تفضله؟`, "Haitian Creole": hoursDisplay ? `Biwo nou pa disponib a ${timeLbl} nan ${pendingApptDate.dateLabel}. Orè nou se ${hoursDisplay}. Ki lòt lè ou prefere?` : `Biwo nou pa disponib a ${timeLbl} nan ${pendingApptDate.dateLabel}. Ki lòt lè ou prefere?`, }; return await replyUnknown(msgs[language] ?? msgs["English"]!, "asked_new_appt_time_for_date"); } const apptLabel = `${pendingApptDate.dateLabel} at ${timeLabel(startTime)}`; const confirmMsgs: Record = { English: `Thank you! Your preferred appointment is ${apptLabel}. Our receptionist will confirm the details with you shortly.`, Spanish: `¡Gracias! Su cita preferida es el ${apptLabel}. Nuestra recepcionista confirmará los detalles con usted en breve.`, Portuguese: `Obrigado! Sua consulta preferida é em ${apptLabel}. Nossa recepcionista confirmará os detalhes em breve.`, Mandarin: `谢谢!您的预约时间为 ${apptLabel}。我们的前台将很快与您确认详情。`, Cantonese: `多謝!您的預約時間為 ${apptLabel}。我們的接待員將很快與您確認詳情。`, Arabic: `شكراً! موعدك المفضل هو ${apptLabel}. ستتواصل معك موظفة الاستقبال قريباً لتأكيد التفاصيل.`, "Haitian Creole": `Mèsi! Randevou ou a se ${apptLabel}. Resepsyonis nou an pral konfime detay yo ak ou byento.`, }; return await replyUnknown(confirmMsgs[language] ?? confirmMsgs["English"]!, "done"); } // Multi-step new patient stages for unknown numbers const unknownNewPatientStages: ConversationStage[] = [ "new_patient_greeted", "asked_new_or_existing", "asked_new_patient_insurance", "asked_insurance_type", "asked_masshealth_check_consent", "asked_existing_insurance", "asked_appointment_preference", "asked_self_pay", "asked_other_insurance_after_inactive", "collecting_contact_info", ]; if (unknownNewPatientStages.includes(stage)) { const { reply: aiReply, nextStage } = await runNewPatientStep( Body, stage, language, activeAiU.key, chatTemplates.generalFallback, activeAiU.provider, activeAiU.model ); return await replyUnknown(aiReply, nextStage); } return ok(res); } // Save inbound message await storage.createCommunication({ patientId: patient.id, channel: "sms", direction: "inbound", status: "delivered", body: Body, twilioSid: MessageSid, provider: "telnyx", }); // Per-patient handoff toggle must be ON if (!await getHandoff(patient.userId, patient.id)) return ok(res); const aiSettings = await storage.getAiSettings(patient.userId); const activeAi = resolveAiProvider(aiSettings ?? {}); if (!activeAi) return ok(res); const telnyxSettings = await storage.getTelnyxSettings(patient.userId); if (!telnyxSettings) return ok(res); const language = patient.preferredLanguage || "English"; let stage = await getStage(patient.userId, patient.id); const chatTemplates = await storage.getAiChatTemplates(patient.userId); // Long idle gap: don't blindly resume (SMS replies are often just slow), // but don't blindly reset either — check whether the new text still reads // like a continuation before deciding. if (stage !== "initial" && stage !== "done") { const convRow = await db.patientConversation.findUnique({ where: { patientId: patient.id }, select: { updatedAt: true }, }); if (convRow?.updatedAt && Date.now() - convRow.updatedAt.getTime() > STALE_SMS_THRESHOLD_MS) { const recentComms = await storage.getCommunicationsByPatient(patient.id); const recentHistory = recentComms.slice(-5).map((c: any) => ({ role: (c.direction === "outbound" ? "assistant" : "user") as "assistant" | "user", text: c.body || "", })); const continuing = await isLikelyContinuation( recentHistory, Body, stage, activeAi.key, activeAi.provider, activeAi.model ); if (!continuing) { clearPendingReschedule(patient.userId, patient.id); await setStage(patient.userId, patient.id, "initial"); stage = "initial"; const stillHereText = STILL_HERE_MSGS[language] ?? STILL_HERE_MSGS["English"]!; await saveOutbound(patient.id, stillHereText); await sendReplies(telnyxSettings, From, [stillHereText]); return ok(res); } // Looks like a continuation — fall through and resume the stored stage. } } const officeContact = await storage.getOfficeContact(patient.userId); const officeName = (officeContact as any)?.officeName?.trim() || ""; // ── Shared business rules (reminder/reschedule/new-patient graphs, plus the // MassHealth and appointment-booking stages) — the exact same code path the // AI voice call webhook uses, so a patient gets identical guardrails and // outcomes regardless of channel. const routed = await routePatientTurn({ patient, stage, message: Body, activeAi, chatTemplates, officeName, }); if (routed.segments.length > 0) { for (const seg of routed.segments) await saveOutbound(patient.id, seg); await sendReplies(telnyxSettings, From, routed.segments); return ok(res); } // ── Stage: initial / done (patient texts in fresh) ─────────────────── if (stage === "initial" || stage === "done") { const openPhoneReply = await storage.getOpenPhoneReply(patient.userId); const afterHoursEnabled = await getAfterHoursHandoff(patient.userId); const outsideHours = await isAfterHours(patient.userId); if (openPhoneReply || (afterHoursEnabled && outsideHours)) { // MSG 1: AI self-introduction const rawGreeting = chatTemplates.newPatientGreeting || `Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you schedule an appointment, check your insurance, and answer general questions 24/7.`; const introText = applyOfficeName(rawGreeting, officeName); // MSG 2: Empathetic acknowledgment of the patient's message const empatheticText = getEmpatheticAck(Body, language); // MSG 3: Detect reschedule vs new appointment from initial text const isRescheduleIntent = /reschedule|rescheduler|change.*appoint|modify.*appoint|move.*appoint|reprogramar|reagendar|cambiar|mudar/i.test(Body); let msg3: string; let nextStage3: ConversationStage; if (isRescheduleIntent) { const apptDatetime = await getAppointmentDatetime(patient.id); if (apptDatetime) { const foundMsgs: Record = { English: `Ok. Just to confirm, your current appointment is on ${apptDatetime}. When would you like to reschedule?`, Spanish: `De acuerdo. Solo para confirmar, su cita actual es el ${apptDatetime}. ¿Cuándo le gustaría reprogramarla?`, Portuguese: `Ok. Só para confirmar, sua consulta atual é em ${apptDatetime}. Quando você gostaria de reagendar?`, Mandarin: `好的。请确认一下,您当前的预约是 ${apptDatetime}。您想重新安排到什么时候?`, Cantonese: `好的。請確認一下,您目前的預約是 ${apptDatetime}。您想重新安排到什麼時候?`, Arabic: `حسناً. فقط للتأكيد، موعدك الحالي في ${apptDatetime}. متى تريد إعادة الجدولة؟`, "Haitian Creole": `Oke. Jis pou konfime, randevou aktyèl ou a se ${apptDatetime}. Ki lè ou ta renmen reprogramè?`, }; msg3 = foundMsgs[language] ?? foundMsgs["English"]!; nextStage3 = "asked_reschedule_datetime"; } else { const notFoundMsgs: Record = { English: "Ok. I could not find your current appointment. Please tell me when you'd like to reschedule and our receptionist will contact you as soon as possible.", Spanish: "De acuerdo. No pude encontrar su cita actual. Por favor díganos cuándo le gustaría reprogramar y nuestra recepcionista le contactará lo antes posible.", Portuguese: "Ok. Não consegui encontrar sua consulta atual. Por favor, diga-nos quando você gostaria de reagendar e nossa recepcionista entrará em contato o mais breve possível.", Mandarin: "好的。我找不到您当前的预约。请告诉我您希望重新安排的时间,我们的前台将尽快与您联系。", Cantonese: "好的。我找不到您目前的預約。請告訴我您希望重新安排的時間,我們的接待員將盡快與您聯絡。", Arabic: "حسناً. لم أجد موعدك الحالي. يرجى إخبارنا بالوقت الذي تريد إعادة الجدولة إليه وسيتصل بك موظف الاستقبال في أقرب وقت ممكن.", "Haitian Creole": "Oke. Mwen pa t jwenn randevou aktyèl ou. Tanpri di nou ki lè ou ta renmen reprogramè epi resepsyonis nou an pral kontakte ou pi vit posib.", }; msg3 = notFoundMsgs[language] ?? notFoundMsgs["English"]!; nextStage3 = "done"; } } else { // New appointment (or any other intent) → ask new or existing patient const newOrExistingMsgs: Record = { English: "Can you please tell me if you are a new or existing patient?", Spanish: "¿Puede decirme si es usted un paciente nuevo o existente?", Portuguese: "Pode me dizer se você é um paciente novo ou existente?", Mandarin: "请问您是新患者还是现有患者?", Cantonese: "請問您是新病人還是現有病人?", Arabic: "هل يمكنك إخباري إذا كنت مريضاً جديداً أم حالياً؟", "Haitian Creole": "Èske ou ka di mwen si ou se yon nouvo pasyan oswa yon pasyan egzistan?", }; msg3 = newOrExistingMsgs[language] ?? newOrExistingMsgs["English"]!; nextStage3 = "asked_new_or_existing"; } // Save all 3 outbound messages and set stage await saveOutbound(patient.id, introText); await saveOutbound(patient.id, empatheticText); await saveOutbound(patient.id, msg3); await setStage(patient.userId, patient.id, nextStage3); await sendReplies(telnyxSettings, From, [introText, empatheticText, msg3]); return ok(res); } } return ok(res); } catch (err) { return ok(res); } }); // ── POST /api/telnyx/webhook/voice ──────────────────────────────────────────── router.post("/webhook/voice", async (req: Request, res: Response): Promise => { try { const { From, CallSid } = req.body; const normalizedFrom = (From || "").replace(/\D/g, ""); // Ordered by id — earliest-created record wins when duplicates share a phone. const allPatients = await db.patient.findMany({ select: { id: true, phone: true, userId: true }, orderBy: { id: "asc" } }); const patient = allPatients.find( (p: { id: number; phone: string | null; userId: number }) => p.phone && p.phone.replace(/\D/g, "") === normalizedFrom ); let greeting = "Thank you for calling. Please leave a message after the beep and we will get back to you shortly."; if (patient) { const settings = await storage.getTelnyxSettings(patient.userId); if (settings?.greetingMessage?.trim()) greeting = settings.greetingMessage.trim(); } if (patient) { await storage.createCommunication({ patientId: patient.id, channel: "voice", direction: "inbound", status: "completed", body: "(Inbound call — voicemail below)", twilioSid: CallSid, provider: "telnyx", }); } const recordingCallbackUrl = `${getPublicBaseUrl(req)}/api/telnyx/webhook/voice-recording`; res.set("Content-Type", "text/xml"); return res.send(` ${greeting} We did not receive a recording. Goodbye. `); } catch (err) { res.set("Content-Type", "text/xml"); return res.send(`Thank you for calling. Please try again later.`); } }); // ── POST /api/telnyx/webhook/voice-recording ────────────────────────────────── router.post("/webhook/voice-recording", async (req: Request, res: Response): Promise => { try { const { CallSid, RecordingUrl } = req.body; if (RecordingUrl && CallSid) { const comm = await db.communication.findFirst({ where: { twilioSid: CallSid, provider: "telnyx" } }); if (comm) { await db.communication.update({ where: { id: comm.id }, data: { body: `Voicemail: ${RecordingUrl}.mp3` }, }); } } res.set("Content-Type", "text/xml"); return res.send(""); } catch (err) { res.set("Content-Type", "text/xml"); return res.send(""); } }); // ── POST /api/telnyx/webhook/say ────────────────────────────────────────────── // Minimal helper endpoint for /make-call's one-shot outbound calls — avoids // relying on an unverified "inline TeXML on call creation" SDK feature. router.post("/webhook/say", async (req: Request, res: Response): Promise => { const text = typeof req.query.text === "string" ? req.query.text : "Hello, this is a call from your dental office."; res.set("Content-Type", "text/xml"); return res.send(`${escapeXml(text)}`); }); // ── AI phone call (outbound "AI Call" from Patient Connection) ─────────────── const MAX_AI_CALL_TURNS = 12; interface AiCallSession { patientId: number; userId: number; phone: string | null; language: string; officeName: string; chatTemplates: Record; history: { role: "assistant" | "user"; text: string }[]; turns: number; } const aiCallSessions = new Map(); interface VoiceLocale { gatherLang: string; sayVoice: string; } /** English/Spanish only for now — Telnyx TeXML's native voice/recognition * coverage for the app's other supported languages (Mandarin, Cantonese, * Arabic, Haitian Creole) is inconsistent, so those fall back to English on * calls. NOTE: voice names below (Polly.*) are carried over from the Twilio * implementation — verify Telnyx TeXML's naming convention * against a real test call before relying on this. */ function voiceLocaleFor(language: string): VoiceLocale { if (language === "Spanish") return { gatherLang: "es-US", sayVoice: "Polly.Lupe" }; return { gatherLang: "en-US", sayVoice: "Polly.Joanna" }; } // Spoken greeting is plain text, not LLM-generated, so it can't follow the // patient's language on its own — translate it ourselves when the office // hasn't overridden the default wording. Matches voiceLocaleFor's English/ // Spanish-only voice coverage above. const GREETING_TRANSLATIONS: Record = { Spanish: "Hola {firstName}, soy Lisa, la asistente de inteligencia artificial de {officeName}. ¿En qué puedo ayudarle hoy?", }; const RECALL_GREETING_TRANSLATIONS: Record = { Spanish: "Hola {firstName}, soy Lisa de {officeName}. Queríamos avisarle que ya le corresponde un examen y limpieza de rutina, que la mayoría de los seguros cubren sin costo. ¿Le gustaría programar una cita?", }; /** Picks the greeting template to speak: the office's custom wording as-is if * they've edited it (we can't auto-translate free text they wrote), otherwise * the default translated into the patient's language when available. */ function resolveGreetingTemplate(rawTemplate: string, defaultTemplate: string, translations: Record, language: string): string { if (rawTemplate.trim() !== defaultTemplate) return rawTemplate; return translations[language] ?? defaultTemplate; } const ENGLISH_LOCALE: VoiceLocale = { gatherLang: "en-US", sayVoice: "Polly.Joanna" }; function aiVoiceTwiml(sayText: string, gatherActionUrl: string, locale: VoiceLocale): string { // action is always set, so the platform posts back to it on both speech and // silence timeout — nothing after would ever run. return ` ${escapeXml(sayText)} `; } function aiVoiceHangup(sayText: string, locale: VoiceLocale = ENGLISH_LOCALE): string { return ` ${escapeXml(sayText)} `; } // POST /api/telnyx/webhook/ai-voice // Entry point + conversation loop for outbound AI-driven calls placed via // POST /api/telnyx/make-ai-call. TeXML hits this once when the call connects // (no CallSid session yet) and again after every with SpeechResult. // Each spoken turn is routed through the same routePatientTurn() logic the // SMS webhook uses, so calls follow identical rules/guardrails, in the // patient's preferred language (English/Spanish supported for voice). router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise => { res.set("Content-Type", "text/xml"); try { const { CallSid, SpeechResult } = req.body as { CallSid?: string; SpeechResult?: string }; const patientIdParam = req.query.patientId as string | undefined; const purposeParam = req.query.purpose as string | undefined; if (!CallSid) return res.send(aiVoiceHangup("Sorry, something went wrong. Goodbye.")); let session = aiCallSessions.get(CallSid); // ── First hit for this call: set up session + greet ────────────────── if (!session) { const patientId = parseInt(patientIdParam || "", 10); const patient = !isNaN(patientId) ? await db.patient.findUnique({ where: { id: patientId }, select: { id: true, userId: true, firstName: true, phone: true, preferredLanguage: true }, }) : null; if (!patient) return res.send(aiVoiceHangup("Sorry, we could not find your patient record. Goodbye.")); const language = patient.preferredLanguage || "English"; const locale = voiceLocaleFor(language); const isRecallCall = purposeParam === "recall"; // Resume an in-progress cross-channel conversation (e.g. mid-reschedule // from a prior text) if one exists; otherwise start a fresh check-in call. // A "recall" call (staff-triggered from Patient Connection, offering an // existing patient a routine exam & cleaning) seeds a different stage // than the default reminder-style check-in. // Either kind of call always speaks its own pitch fresh (see greeting // below), so it must always reset the stage — even if a prior call of // either kind left the patient stuck mid-flow (e.g. "recall_asked_datetime" // or "asked_reschedule_datetime"). Otherwise the reply below gets // interpreted against that stale leftover stage instead of the fresh // pitch that was just spoken. if (isRecallCall) { await startRecallConversation(patient.userId, patient.id); } else { await resetConversation(patient.userId, patient.id); } const officeContact = await storage.getOfficeContact(patient.userId); const officeName = (officeContact as any)?.officeName?.trim() || ""; const callTemplates = await storage.getAiCallTemplates(patient.userId); session = { patientId: patient.id, userId: patient.userId, phone: patient.phone, language, officeName, chatTemplates: callTemplates, history: [], turns: 0, }; aiCallSessions.set(CallSid, session); const rawCallTemplate = (isRecallCall ? callTemplates.recallGreeting?.trim() : callTemplates.greeting?.trim()) || (isRecallCall ? DEFAULT_RECALL_GREETING : DEFAULT_GREETING); const callTemplate = isRecallCall ? resolveGreetingTemplate(rawCallTemplate, DEFAULT_RECALL_GREETING, RECALL_GREETING_TRANSLATIONS, language) : resolveGreetingTemplate(rawCallTemplate, DEFAULT_GREETING, GREETING_TRANSLATIONS, language); const greeting = callTemplate .replace(/\{firstName\}/g, patient.firstName || "there") .replace(/\{officeName\}/g, officeName || "our office"); session.history.push({ role: "assistant", text: greeting }); await storage.createCommunication({ patientId: patient.id, userId: patient.userId, channel: "voice", direction: "outbound", status: "completed", body: greeting, twilioSid: CallSid, provider: "telnyx", }); const actionUrl = `${getPublicBaseUrl(req)}/api/telnyx/webhook/ai-voice?patientId=${patient.id}`; return res.send(aiVoiceTwiml(greeting, actionUrl, locale)); } const locale = voiceLocaleFor(session.language); // ── Caller stayed silent past the speech timeout ────────────────────── // A call is a single continuous conversation, unlike SMS — if it ends // here, mid-flow or not, there's nothing left to resume, so reset the // stage now instead of leaving the patient stuck for their next call/text. if (!SpeechResult?.trim()) { aiCallSessions.delete(CallSid); await setStage(session.userId, session.patientId, "done"); return res.send(aiVoiceHangup("I didn't catch a response. Our staff will follow up with you if needed. Goodbye.", locale)); } session.history.push({ role: "user", text: SpeechResult.trim() }); await storage.createCommunication({ patientId: session.patientId, userId: session.userId, channel: "voice", direction: "inbound", status: "completed", body: SpeechResult.trim(), twilioSid: CallSid, provider: "telnyx", }); session.turns++; // ── Caller said something that sounds like goodbye, or we've hit the cap ── if (soundsLikeGoodbye(SpeechResult) || session.turns >= MAX_AI_CALL_TURNS) { aiCallSessions.delete(CallSid); await setStage(session.userId, session.patientId, "done"); return res.send(aiVoiceHangup("Thank you for calling, have a great day. Goodbye.", locale)); } const aiSettings = await storage.getAiSettings(session.userId); const activeAi = resolveAiProvider(aiSettings ?? {}); if (!activeAi) { aiCallSessions.delete(CallSid); await setStage(session.userId, session.patientId, "done"); return res.send(aiVoiceHangup("Our AI assistant is not available right now. Our staff will follow up with you. Goodbye.", locale)); } const stage = await getStage(session.userId, session.patientId); const routed = await routePatientTurn({ patient: { id: session.patientId, userId: session.userId, phone: session.phone, preferredLanguage: session.language, }, stage, message: SpeechResult.trim(), activeAi, chatTemplates: session.chatTemplates, officeName: session.officeName, }); const reply = routed.segments.join(" ").trim() || "Sorry, I didn't quite catch that — could you say that again?"; session.history.push({ role: "assistant", text: reply }); await storage.createCommunication({ patientId: session.patientId, userId: session.userId, channel: "voice", direction: "outbound", status: "completed", body: reply, twilioSid: CallSid, provider: "telnyx", }); if (routed.nextStage === "done" || soundsLikeGoodbye(reply)) { aiCallSessions.delete(CallSid); // routePatientTurn() already persisted nextStage — but if we're hanging // up because the reply sounded like a goodbye while nextStage was still // mid-flow, force it to "done" so the next call/text doesn't resume there. if (routed.nextStage !== "done") { await setStage(session.userId, session.patientId, "done"); } return res.send(aiVoiceHangup(reply, locale)); } const actionUrl = `${getPublicBaseUrl(req)}/api/telnyx/webhook/ai-voice?patientId=${session.patientId}`; return res.send(aiVoiceTwiml(reply, actionUrl, locale)); } catch (err) { return res.send(aiVoiceHangup("Sorry, something went wrong on our end. Goodbye.")); } }); export default router;