feat: add AI-driven phone calls to patient connection
Adds an "AI Call" option alongside Call/SMS/Chat that places an outbound call where Lisa converses live with the patient via Twilio speech gather + TTS, using the same conversational AI as chat. Includes a separate, user-editable Call Template (Settings > AI Chat/Call Settings) so the call greeting can be customized independently of SMS templates. Also fixes the outbound-call webhook URL to always use the fixed public Twilio hostname (CLOUDFLARE_HOST) instead of deriving it from the triggering request, since staff-browser requests arrive over the LAN-only hostname, which Twilio can never reach. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -214,8 +214,8 @@ router.put("/chat-templates", async (req: Request, res: Response): Promise<any>
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
const { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting, reminderSms } = req.body;
|
||||
await storage.saveAiChatTemplates(userId, { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting, reminderSms });
|
||||
const { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting, reminderSms, callTemplate } = req.body;
|
||||
await storage.saveAiChatTemplates(userId, { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting, reminderSms, callTemplate });
|
||||
const updated = await storage.getAiChatTemplates(userId);
|
||||
return res.status(200).json(updated);
|
||||
} catch (err) {
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
timeLabel,
|
||||
} from "../ai/reschedule-graph";
|
||||
import { getLlm, resolveAiProvider } from "../ai/llm-factory";
|
||||
import { runVoiceAssistantTurn, soundsLikeGoodbye } from "../ai/voice-assistant";
|
||||
import { getPublicBaseUrl } from "../utils/publicUrl";
|
||||
import { runEligibilityProcessor } from "../queue/processors/eligibilityProcessor";
|
||||
import {
|
||||
getHandoff, getAfterHoursHandoff,
|
||||
@@ -1169,7 +1171,7 @@ router.post("/webhook/voice", async (req: Request, res: Response): Promise<any>
|
||||
});
|
||||
}
|
||||
|
||||
const recordingCallbackUrl = `${process.env.BASE_URL || "https://communitydentistsoflowell.mydentalofficemanagement.com"}/api/twilio/webhook/voice-recording`;
|
||||
const recordingCallbackUrl = `${getPublicBaseUrl(req)}/api/twilio/webhook/voice-recording`;
|
||||
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -1228,4 +1230,144 @@ router.post("/webhook/voice-browser", async (req: Request, res: Response): Promi
|
||||
}
|
||||
});
|
||||
|
||||
// ── AI phone call (outbound "AI Call" from Patient Connection) ───────────────
|
||||
|
||||
const MAX_AI_CALL_TURNS = 12;
|
||||
|
||||
interface AiCallSession {
|
||||
patientId: number;
|
||||
userId: number;
|
||||
history: { role: "assistant" | "user"; text: string }[];
|
||||
turns: number;
|
||||
}
|
||||
|
||||
const aiCallSessions = new Map<string, AiCallSession>();
|
||||
|
||||
function aiVoiceTwiml(sayText: string, gatherActionUrl: string): string {
|
||||
// action is always set, so Twilio 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="en-US" speechTimeout="auto" action="${gatherActionUrl}" method="POST">
|
||||
<Say voice="alice">${escapeXml(sayText)}</Say>
|
||||
</Gather>
|
||||
</Response>`;
|
||||
}
|
||||
|
||||
function aiVoiceHangup(sayText: string): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say voice="alice">${escapeXml(sayText)}</Say>
|
||||
<Hangup/>
|
||||
</Response>`;
|
||||
}
|
||||
|
||||
// POST /api/twilio/webhook/ai-voice
|
||||
// Entry point + conversation loop for outbound AI-driven calls placed via
|
||||
// POST /api/twilio/make-ai-call. Twilio hits this once when the call connects
|
||||
// (no CallSid session yet) and again after every <Gather> with SpeechResult.
|
||||
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;
|
||||
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 } })
|
||||
: null;
|
||||
if (!patient) return res.send(aiVoiceHangup("Sorry, we could not find your patient record. Goodbye."));
|
||||
|
||||
session = { patientId: patient.id, userId: patient.userId, history: [], turns: 0 };
|
||||
aiCallSessions.set(CallSid, session);
|
||||
|
||||
const officeContact = await storage.getOfficeContact(patient.userId);
|
||||
const officeName = (officeContact as any)?.officeName?.trim() || "";
|
||||
const officeAddress = [
|
||||
(officeContact as any)?.streetAddress?.trim(),
|
||||
(officeContact as any)?.city?.trim(),
|
||||
(officeContact as any)?.state?.trim(),
|
||||
(officeContact as any)?.zipCode?.trim(),
|
||||
].filter(Boolean).join(", ");
|
||||
const officePhone = (officeContact as any)?.phoneNumber?.trim() || "";
|
||||
const appointmentDatetime = await getAppointmentDatetime(patient.id);
|
||||
|
||||
const chatTemplates = await storage.getAiChatTemplates(patient.userId);
|
||||
const rawCallTemplate = chatTemplates.callTemplate?.trim() ||
|
||||
"Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?";
|
||||
const greeting = rawCallTemplate
|
||||
.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,
|
||||
});
|
||||
|
||||
// Stash office context on the session for later turns
|
||||
(session as any).ctx = { officeName, officeAddress, officePhone, appointmentDatetime };
|
||||
|
||||
const actionUrl = `${getPublicBaseUrl(req)}/api/twilio/webhook/ai-voice?patientId=${patient.id}`;
|
||||
return res.send(aiVoiceTwiml(greeting, actionUrl));
|
||||
}
|
||||
|
||||
// ── Caller stayed silent past the speech timeout ──────────────────────
|
||||
if (!SpeechResult?.trim()) {
|
||||
aiCallSessions.delete(CallSid);
|
||||
return res.send(aiVoiceHangup("I didn't catch a response. Our staff will follow up with you if needed. Goodbye."));
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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);
|
||||
return res.send(aiVoiceHangup("Thank you for calling, have a great day. Goodbye."));
|
||||
}
|
||||
|
||||
const aiSettings = await storage.getAiSettings(session.userId);
|
||||
const activeAi = resolveAiProvider(aiSettings ?? {});
|
||||
if (!activeAi) {
|
||||
aiCallSessions.delete(CallSid);
|
||||
return res.send(aiVoiceHangup("Our AI assistant is not available right now. Our staff will follow up with you. Goodbye."));
|
||||
}
|
||||
|
||||
const patientRow = await db.patient.findUnique({ where: { id: session.patientId }, select: { firstName: true } });
|
||||
const ctx = (session as any).ctx || {};
|
||||
const reply = await runVoiceAssistantTurn(
|
||||
session.history,
|
||||
{ firstName: patientRow?.firstName || "", ...ctx },
|
||||
activeAi.key, activeAi.provider, activeAi.model
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
if (soundsLikeGoodbye(reply)) {
|
||||
aiCallSessions.delete(CallSid);
|
||||
return res.send(aiVoiceHangup(reply));
|
||||
}
|
||||
|
||||
const actionUrl = `${getPublicBaseUrl(req)}/api/twilio/webhook/ai-voice?patientId=${session.patientId}`;
|
||||
return res.send(aiVoiceTwiml(reply, actionUrl));
|
||||
} catch (err) {
|
||||
return res.send(aiVoiceHangup("Sorry, something went wrong on our end. Goodbye."));
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,6 +3,8 @@ import twilio from "twilio";
|
||||
import { storage } from "../storage";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
import { getHandoff, setHandoff, resetConversation, startNewPatientConversation, startRescheduleConversation, getAfterHoursHandoff, setAfterHoursHandoff } from "../ai/aiHandoffStore";
|
||||
import { resolveAiProvider } from "../ai/llm-factory";
|
||||
import { getTwilioPublicBaseUrl } from "../utils/publicUrl";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -117,6 +119,52 @@ router.post("/send-sms", async (req: Request, res: Response): Promise<any> => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/twilio/make-ai-call
|
||||
// Places an outbound call where Lisa (the AI assistant) conducts the whole
|
||||
// conversation live over the phone — see /webhook/ai-voice for the loop.
|
||||
router.post("/make-ai-call", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const { to, patientId } = req.body;
|
||||
if (!to || !patientId) return res.status(400).json({ message: "to and patientId are required" });
|
||||
|
||||
const settings = await storage.getTwilioSettings(userId);
|
||||
if (!settings) {
|
||||
return res.status(400).json({ message: "Twilio is not configured. Please add your Twilio credentials in Settings." });
|
||||
}
|
||||
|
||||
const aiSettings = await storage.getAiSettings(userId);
|
||||
if (!resolveAiProvider(aiSettings ?? {})) {
|
||||
return res.status(400).json({ message: "AI is not configured. Please add an AI provider key in Settings before placing an AI call." });
|
||||
}
|
||||
|
||||
const patient = await db.patient.findFirst({ where: { id: Number(patientId), userId } });
|
||||
if (!patient) return res.status(404).json({ message: "Patient not found" });
|
||||
|
||||
let publicBaseUrl: string;
|
||||
try {
|
||||
publicBaseUrl = getTwilioPublicBaseUrl();
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ message: err.message });
|
||||
}
|
||||
const webhookUrl = `${publicBaseUrl}/api/twilio/webhook/ai-voice?patientId=${patient.id}`;
|
||||
|
||||
const client = getTwilioClient(settings.accountSid, settings.authToken);
|
||||
const call = await client.calls.create({
|
||||
url: webhookUrl,
|
||||
method: "POST",
|
||||
from: settings.phoneNumber,
|
||||
to,
|
||||
});
|
||||
|
||||
return res.status(200).json({ sid: call.sid, status: call.status });
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ error: err.message || "Failed to place AI call" });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/twilio/send-reminders-batch
|
||||
router.post("/send-reminders-batch", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user