import { Injectable, Inject, Logger, Optional } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { generateText, tool, stepCountIs } from 'ai'; import { z } from 'zod'; import { MessagingProvider } from './providers/messaging-provider.interface'; import { MessagingConversationService } from './messaging-conversation.service'; import { FlowExecutionService } from './flow/flow-execution.service'; import { CallerResolutionService } from '../caller/caller-resolution.service'; import { CallerContextService } from '../caller/caller-context.service'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { createAiModel } from '../ai/ai-provider'; import { AiConfigService } from '../config/ai-config.service'; import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils'; import type { NormalizedMessage, ListSection, InteractiveButton } from './types'; import type { LanguageModel } from 'ai'; @Injectable() export class MessagingService { private readonly logger = new Logger(MessagingService.name); private readonly aiModel: LanguageModel | null; private readonly auth: string; constructor( private config: ConfigService, private provider: MessagingProvider, private conversation: MessagingConversationService, private caller: CallerResolutionService, private callerContext: CallerContextService, private platform: PlatformGraphqlService, private aiConfig: AiConfigService, @Optional() private flowExecution: FlowExecutionService, ) { const cfg = aiConfig.getConfig(); this.aiModel = createAiModel({ provider: cfg.provider, model: cfg.model, anthropicApiKey: config.get('ai.anthropicApiKey'), openaiApiKey: config.get('ai.openaiApiKey'), }); const apiKey = config.get('platform.apiKey') ?? ''; this.auth = apiKey ? `Bearer ${apiKey}` : ''; if (this.aiModel) { this.logger.log(`WhatsApp AI configured: ${cfg.provider}/${cfg.model}`); } else { this.logger.warn('WhatsApp AI not configured — will send fallback replies'); } } async handleInbound(message: NormalizedMessage): Promise { const { phone, name, text } = message; const replyId = message.interactiveReply?.id; this.logger.log(`[WA] Inbound from ${phone} (${name}): ${text.substring(0, 100)}${replyId ? ` [reply_id=${replyId}]` : ''}`); // Delegate to flow engine if published flows exist if (this.flowExecution?.hasFlows()) { this.logger.log(`[WA] Delegating to flow engine`); await this.flowExecution.handleMessage(message); return; } // Fallback: hardcoded AI chat (legacy — will be removed once flows are validated) if (!this.aiModel) { await this.provider.sendText(phone, 'Our assistant is temporarily unavailable. Please call us directly.'); return; } // 1. Resolve caller const resolved = await this.caller.resolve(phone, this.auth).catch(err => { this.logger.error(`[WA] Caller resolution failed: ${err.message}`); return null; }); // 2. Build context let callerContextPrompt = ''; if (resolved && !resolved.isNew && resolved.leadId) { const ctx = await this.callerContext.getOrBuild(resolved.leadId, resolved.patientId ?? '', this.auth).catch(() => null); if (ctx) { callerContextPrompt = this.callerContext.renderForPrompt(ctx); } } // 3. Load conversation history const history = await this.conversation.getHistory(phone); // For interactive replies, include the selection ID so the AI can // extract structured data (e.g. "doc:{uuid}:{name}" → doctorId) let userContent = text; if (message.type === 'interactive_reply' && message.interactiveReply?.id) { userContent = `[Selected: ${message.interactiveReply.title}] (selection_id: ${message.interactiveReply.id})`; } const messages = [ ...history.map(h => ({ role: h.role as 'user' | 'assistant', content: h.content })), { role: 'user' as const, content: userContent }, ]; // 4. Build system prompt const systemPrompt = this.buildSystemPrompt(callerContextPrompt, name, phone, resolved?.isNew ?? true); // 5. Build tools const tools = this.buildTools(phone); // 6. Run AI try { const result = await generateText({ model: this.aiModel, system: systemPrompt, messages, tools, stopWhen: stepCountIs(5), }); const reply = result.text?.trim(); if (reply) { await this.provider.sendText(phone, reply); } // 7. Persist conversation await this.conversation.addMessages(phone, [ { role: 'user', content: text, timestamp: Date.now() }, ...(reply ? [{ role: 'assistant' as const, content: reply, timestamp: Date.now() }] : []), ]); } catch (err: any) { this.logger.error(`[WA] AI error: ${err.message}`); await this.provider.sendText(phone, 'Sorry, I encountered an error. Please try again or call us directly.'); } } private buildSystemPrompt(callerContext: string, name: string, phone: string, isNew: boolean): string { // Pull hospital name from theme config if available const hospitalName = this.config.get('theme.hospitalName') ?? 'our hospital'; return `You are a friendly WhatsApp assistant for ${hospitalName}. You help patients with: - Answering questions about departments, doctors, timings, fees - Booking appointments - Checking existing appointments APPOINTMENT BOOKING FLOW — follow this exact sequence: 1. When the patient wants to book, IMMEDIATELY call send_department_list. Do NOT ask "which department" in text. 2. When the patient picks a department (selection_id starts with "dept:"), IMMEDIATELY call send_doctor_list with the department name after "dept:". 3. When the patient picks a doctor (selection_id starts with "doc:"), IMMEDIATELY call send_slot_list. Extract the doctorId from the selection_id format "doc:{doctorId}:{doctorName}" — use the UUID between the first and second colon as doctorId, and the text after the second colon as doctorName. 4. When the patient picks a slot (selection_id starts with "slot:"), call send_confirm_buttons with a summary. Extract the datetime from "slot:{doctorId}:{datetime}". 5. When the patient taps Confirm (selection_id = "confirm_booking"), call book_appointment with all collected details. 6. After booking, send a confirmation with doctor name, date, time, and reference number. CRITICAL: Always use the interactive list/button tools. Never ask questions in text when a tool exists. When a user message contains "selection_id:", parse it and call the appropriate tool immediately. OTHER RULES: - Be concise — WhatsApp messages should be short (2-3 sentences max). - No markdown formatting (no **, ##, bullets). Plain text only. - If the patient mentions a specific department or doctor upfront, skip ahead in the flow. - If the patient asks something you can't help with, suggest they call ${hospitalName} directly. - Always be warm and professional. Use the patient's name when known. - Reply in the same language the patient uses. Button/list labels stay in English. CURRENT PATIENT: Name: ${name || 'Unknown'} Phone: ${phone} ${isNew ? 'New patient — no prior records.' : ''} ${callerContext ? `\n${callerContext}` : ''}`; } private buildTools(phone: string) { const provider = this.provider; const platform = this.platform; const auth = this.auth; const logger = this.logger; const callerService = this.caller; return { lookup_appointments: tool({ description: 'Look up existing appointments for the current patient.', inputSchema: z.object({ patientId: z.string().optional().describe('Patient ID — omit to use current caller'), }), execute: async ({ patientId }) => { let pid = patientId; if (!pid) { const resolved = await callerService.resolve(phone, auth).catch(() => null); pid = resolved?.patientId; } if (!pid) return { appointments: [], message: 'No patient record found.' }; const data = await platform.query( `{ appointments(first: 10, filter: { patientId: { eq: "${pid}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { id scheduledAt appointmentStatus doctorName department reasonForVisit } } } }`, ); const appts = data.appointments.edges.map((e: any) => e.node); logger.log(`[WA-TOOL] lookup_appointments: ${appts.length} found`); return { appointments: appts }; }, }), send_department_list: tool({ description: 'Send an interactive WhatsApp list of available departments. Call when patient wants to book but hasn\'t specified a department.', inputSchema: z.object({}), execute: async () => { const data = await platform.query( `{ doctors(first: 50) { edges { node { department } } } }`, ); const departments = [...new Set( data.doctors.edges.map((e: any) => e.node.department).filter(Boolean), )] as string[]; if (!departments.length) return { sent: false, message: 'No departments available.' }; const sections: ListSection[] = [{ title: 'Departments', rows: departments.slice(0, 10).map(d => ({ id: `dept:${d}`, title: d.substring(0, 24), })), }]; await provider.sendList(phone, 'Which department would you like to visit?', 'View Departments', sections); logger.log(`[WA-TOOL] send_department_list: ${departments.length} departments`); return { sent: true, departments }; }, }), send_doctor_list: tool({ description: 'Send an interactive WhatsApp list of doctors in a department. Call after patient selects a department.', inputSchema: z.object({ department: z.string().describe('Department name'), }), execute: async ({ department }) => { const data = await platform.query( `{ doctors(first: 50) { edges { node { id fullName { firstName lastName } department specialty consultationFeeNew { amountMicros currencyCode } ${DOCTOR_VISIT_SLOTS_FRAGMENT} } } } }`, ); const allDocs = normalizeDoctors(data.doctors.edges.map((e: any) => e.node)); const deptDocs = allDocs.filter((d: any) => d.department?.toLowerCase() === department.toLowerCase(), ); if (!deptDocs.length) return { sent: false, message: `No doctors found in ${department}.` }; const sections: ListSection[] = [{ title: department.substring(0, 24), rows: deptDocs.slice(0, 10).map((d: any) => { const docName = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim(); const fee = d.consultationFeeNew?.amountMicros ? `₹${(d.consultationFeeNew.amountMicros / 1000000).toFixed(0)}` : ''; return { id: `doc:${d.id}:${docName}`, title: docName.substring(0, 24), description: fee ? `${d.specialty ?? department} — ${fee}` : (d.specialty ?? department), }; }), }]; await provider.sendList(phone, `Doctors in ${department}:`, 'View Doctors', sections); logger.log(`[WA-TOOL] send_doctor_list: ${deptDocs.length} doctors in ${department}`); return { sent: true, count: deptDocs.length }; }, }), send_slot_list: tool({ description: 'Send available time slots for a doctor as a WhatsApp list. Call after patient selects a doctor.', inputSchema: z.object({ doctorId: z.string().describe('Doctor ID from the list selection'), doctorName: z.string().describe('Doctor name for display'), date: z.string().optional().describe('Date in YYYY-MM-DD. Defaults to tomorrow.'), }), execute: async ({ doctorId, doctorName, date }) => { // Default to tomorrow, use IST for day-of-week matching const targetDate = date ?? new Date(Date.now() + 86400000).toISOString().split('T')[0]; const dayNames = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY']; const targetDay = dayNames[new Date(targetDate + 'T00:00:00+05:30').getDay()]; const data = await platform.query( `{ doctors(first: 50) { edges { node { id fullName { firstName lastName } ${DOCTOR_VISIT_SLOTS_FRAGMENT} } } } }`, ); const rawDocs = data.doctors.edges.map((e: any) => e.node); const doctor = rawDocs.find((d: any) => d.id === doctorId); if (!doctor) { return { sent: false, message: `Doctor not found.` }; } // Find visit slots for the target day-of-week const rawSlots = doctor.visitSlots?.edges?.map((e: any) => e.node) ?? []; const daySlots = rawSlots.filter((s: any) => s.dayOfWeek === targetDay); if (!daySlots.length) { return { sent: false, message: `${doctorName} is not available on ${targetDay.charAt(0) + targetDay.slice(1).toLowerCase()} (${targetDate}). Please choose a different date.` }; } // Generate hourly time slots from startTime-endTime const timeSlots: { time: string; clinic: string }[] = []; for (const ds of daySlots) { const startHour = parseInt(ds.startTime?.split(':')[0] ?? '9', 10); const endHour = parseInt(ds.endTime?.split(':')[0] ?? '17', 10); const clinicName = ds.clinic?.clinicName ?? ''; for (let h = startHour; h < endHour && timeSlots.length < 10; h++) { timeSlots.push({ time: `${String(h).padStart(2, '0')}:00`, clinic: clinicName }); } } if (!timeSlots.length) { return { sent: false, message: `No slots available for ${doctorName} on ${targetDate}.` }; } const sections: ListSection[] = [{ title: targetDate, // section title max 24 chars rows: timeSlots.map((s) => ({ id: `slot:${doctorId}:${targetDate}T${s.time}:00`, title: s.time, // row title max 24 chars description: s.clinic || undefined, })), }]; await provider.sendList(phone, `Available slots for ${doctorName}:`, 'View Slots', sections); logger.log(`[WA-TOOL] send_slot_list: ${timeSlots.length} slots for ${doctorName} on ${targetDate} (${targetDay})`); return { sent: true, slots: timeSlots.length }; }, }), send_confirm_buttons: tool({ description: 'Send confirmation buttons before booking. Call after all details are collected.', inputSchema: z.object({ summary: z.string().describe('Appointment summary to show the patient'), }), execute: async ({ summary }) => { const buttons: InteractiveButton[] = [ { id: 'confirm_booking', title: 'Confirm' }, { id: 'cancel_booking', title: 'Cancel' }, ]; await provider.sendButtons(phone, summary, buttons); logger.log(`[WA-TOOL] send_confirm_buttons`); return { sent: true }; }, }), book_appointment: tool({ description: 'Book the appointment after patient confirms. Only call AFTER the patient taps Confirm.', inputSchema: z.object({ patientName: z.string().describe('Patient name'), phoneNumber: z.string().describe('Patient phone number'), department: z.string().describe('Department'), doctorName: z.string().describe('Doctor name'), scheduledAt: z.string().describe('ISO datetime'), reason: z.string().describe('Reason for visit'), }), execute: async ({ patientName, phoneNumber, department, doctorName, scheduledAt, reason }) => { logger.log(`[WA-BOOK] Booking: ${patientName} → ${doctorName} @ ${scheduledAt}`); try { const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10); const resolved = await callerService.resolve(cleanPhone, auth).catch(() => null); // Conflict check: same doctor + same date const bookingDate = scheduledAt.split('T')[0]; const existingAppts = await platform.query( `{ appointments(first: 50, filter: { doctorName: { eq: "${doctorName}" } }, orderBy: [{ scheduledAt: AscNullsLast }]) { edges { node { id scheduledAt status patientName } } } }`, ).catch(() => ({ appointments: { edges: [] } })); const conflicts = existingAppts.appointments.edges .map((e: any) => e.node) .filter((a: any) => a.status === 'SCHEDULED' && a.scheduledAt?.startsWith(bookingDate)); // Check if this patient already has a booking with this doctor on the same date const patientConflict = conflicts.find((a: any) => a.patientName?.toLowerCase().includes(patientName.split(' ')[0].toLowerCase()), ); if (patientConflict) { logger.log(`[WA-BOOK] Conflict: patient already booked with ${doctorName} on ${bookingDate}`); return { booked: false, message: `You already have an appointment with ${doctorName} on ${bookingDate}. Would you like to choose a different date?` }; } // Check if the doctor has too many appointments at this exact time const slotConflicts = conflicts.filter((a: any) => a.scheduledAt === scheduledAt); if (slotConflicts.length >= 3) { logger.log(`[WA-BOOK] Conflict: ${doctorName} fully booked at ${scheduledAt} (${slotConflicts.length} existing)`); return { booked: false, message: `${doctorName} is fully booked at this time. Please choose a different slot.` }; } let patientId = resolved?.patientId; if (resolved?.isNew) { const firstName = patientName.split(' ')[0]; const lastName = patientName.split(' ').slice(1).join(' ') || ''; try { const p = await platform.query( `mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`, { data: { fullName: { firstName, lastName }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } }, ); const patientId = p?.createPatient?.id; await platform.query( `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, { data: { name: `WhatsApp — ${patientName}`, contactName: { firstName, lastName }, contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` }, source: 'WHATSAPP', status: 'NEW', interestedService: department, ...(patientId ? { patientId } : {}) } }, ); } catch (err: any) { logger.warn(`[WA-BOOK] Lead/patient creation failed: ${err.message}`); } } const result = await platform.query( `mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`, { data: { name: `WhatsApp Booking — ${patientName} (${department})`, scheduledAt, status: 'SCHEDULED', doctorName, department, reasonForVisit: reason, ...(patientId ? { patientId } : {}) } }, ); const id = result?.createAppointment?.id; if (id) { logger.log(`[WA-BOOK] Success: appointmentId=${id}`); return { booked: true, appointmentId: id, message: `Appointment booked! Reference: ${id.substring(0, 8)}` }; } return { booked: false, message: 'Booking failed. Please try again.' }; } catch (err: any) { logger.error(`[WA-BOOK] Failed: ${err.message}`); return { booked: false, message: 'Booking failed. Please call us directly.' }; } }, }), }; } }