Files
DentalManagement09/apps/Backend/src/routes/telnyx-webhooks.ts
T
GiteadandClaude Sonnet 5 79ac6aaa31 feat: migrate SMS/voice to Telnyx (TeXML), keep browser dial pad on Twilio
Twilio now handles only the in-browser dial pad (Access Token + browser-Device
outbound TwiML); Telnyx takes over SMS, inbound voicemail, the AI voice
conversation loop, and staff-triggered outbound calls via TeXML. Adds
TelnyxSettings model, Communication.provider discriminator, new comms/routes
modules, and a real Telnyx Settings page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 15:39:43 -04:00

898 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&apos;");
}
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<void> {
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<boolean> {
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<void> {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<boolean> {
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<string, {
userId: number;
stage: ConversationStage;
language: string;
lastActivityAt: Date;
pendingApptDate?: { date: Date; dateLabel: string };
}>();
router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> => {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<any> => {
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(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="alice">${greeting}</Say>
<Record maxLength="120" action="${recordingCallbackUrl}" transcribeCallback="${recordingCallbackUrl}" playBeep="true"/>
<Say voice="alice">We did not receive a recording. Goodbye.</Say>
</Response>`);
} catch (err) {
res.set("Content-Type", "text/xml");
return res.send(`<?xml version="1.0" encoding="UTF-8"?><Response><Say>Thank you for calling. Please try again later.</Say></Response>`);
}
});
// ── POST /api/telnyx/webhook/voice-recording ──────────────────────────────────
router.post("/webhook/voice-recording", async (req: Request, res: Response): Promise<any> => {
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("<Response></Response>");
} catch (err) {
res.set("Content-Type", "text/xml");
return res.send("<Response></Response>");
}
});
// ── 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<any> => {
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(`<?xml version="1.0" encoding="UTF-8"?><Response><Say>${escapeXml(text)}</Say></Response>`);
});
// ── 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<string, string>;
history: { role: "assistant" | "user"; text: string }[];
turns: number;
}
const aiCallSessions = new Map<string, AiCallSession>();
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 <Say voice="..."> 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<string, string> = {
Spanish: "Hola {firstName}, soy Lisa, la asistente de inteligencia artificial de {officeName}. ¿En qué puedo ayudarle hoy?",
};
const RECALL_GREETING_TRANSLATIONS: Record<string, string> = {
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<string, string>, 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 </Gather> would ever run.
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Gather input="speech" language="${locale.gatherLang}" speechTimeout="auto" timeout="15" action="${gatherActionUrl}" method="POST">
<Say voice="${locale.sayVoice}">${escapeXml(sayText)}</Say>
</Gather>
</Response>`;
}
function aiVoiceHangup(sayText: string, locale: VoiceLocale = ENGLISH_LOCALE): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="${locale.sayVoice}">${escapeXml(sayText)}</Say>
<Hangup/>
</Response>`;
}
// 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 <Gather> 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<any> => {
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;