Files
helix-engage-server/src/messaging/messaging.service.ts
saridsa2 2c947517af feat: WhatsApp AI assistant — provider-agnostic messaging with Gupshup
Provider-agnostic WhatsApp integration for AI-driven appointment booking:

- MessagingProvider interface (sendText, sendButtons, sendList, parseInbound)
- GupshupProvider implementation (Gupshup WhatsApp API)
- MessagingService — AI orchestration with tools (department/doctor/slot
  lists via interactive WhatsApp messages, appointment booking, caller
  resolution + context injection)
- Redis conversation history (24h TTL, matches WhatsApp session window)
- Webhook controller at POST /api/messaging/webhook

Swappable to Ozonetel or Meta Cloud API by implementing MessagingProvider
and switching MESSAGING_PROVIDER env var.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 14:45:26 +05:30

345 lines
17 KiB
TypeScript

import { Injectable, Logger } 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 { 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,
) {
const cfg = aiConfig.getConfig();
this.aiModel = createAiModel({
provider: cfg.provider,
model: cfg.model,
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
openaiApiKey: config.get<string>('ai.openaiApiKey'),
});
const apiKey = config.get<string>('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<void> {
const { phone, name, text } = message;
this.logger.log(`[WA] Inbound from ${phone} (${name}): ${text.substring(0, 100)}`);
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);
const messages = [
...history.map(h => ({ role: h.role as 'user' | 'assistant', content: h.content })),
{ role: 'user' as const, content: text },
];
// 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 {
return `You are a friendly WhatsApp assistant for a hospital. You help patients with:
- Answering questions about departments, doctors, timings, fees
- Booking appointments
- Checking existing appointments
RULES:
- Be concise — WhatsApp messages should be short (2-3 sentences max per message).
- No markdown formatting (no **, ##, bullets). Plain text only.
- When booking an appointment, collect: department, doctor preference, preferred date/time, reason for visit.
- Use the send_department_list tool to show available departments as a WhatsApp list.
- Use the send_doctor_list tool to show available doctors as a WhatsApp list.
- Use the send_slot_list tool to show available time slots as a WhatsApp list.
- Use the send_confirm_buttons tool to let the patient confirm or cancel before booking.
- After booking, send a confirmation with doctor name, date, time, and reference number.
- If the patient asks something you can't help with, suggest they call the hospital 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<any>(
`{ 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<any>(
`{ 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<any>(
`{ 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,
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 }) => {
const targetDate = date ?? new Date(Date.now() + 86400000).toISOString().split('T')[0];
const data = await platform.query<any>(
`{ doctors(first: 50) { edges { node {
id fullName { firstName lastName }
${DOCTOR_VISIT_SLOTS_FRAGMENT}
} } } }`,
);
const allDocs = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
const doctor = allDocs.find((d: any) => d.id === doctorId);
const slots = doctor?.availableSlots ?? [];
if (!slots.length) {
return { sent: false, message: `No slots available for ${doctorName} on ${targetDate}.` };
}
const sections: ListSection[] = [{
title: `${doctorName}${targetDate}`,
rows: slots.slice(0, 10).map((s: any) => ({
id: `slot:${doctorId}:${targetDate}T${s.time}:00`,
title: s.time,
description: s.clinic ?? '',
})),
}];
await provider.sendList(phone, `Available slots for ${doctorName}:`, 'View Slots', sections);
logger.log(`[WA-TOOL] send_slot_list: ${slots.length} slots for ${doctorName}`);
return { sent: true, slots: slots.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);
if (resolved?.isNew) {
const firstName = patientName.split(' ')[0];
const lastName = patientName.split(' ').slice(1).join(' ') || '';
try {
const p = await platform.query<any>(
`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<any>(
`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<any>(
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
{ data: { name: `WhatsApp Booking — ${patientName} (${department})`, scheduledAt, appointmentStatus: 'SCHEDULED', doctorName, department, reasonForVisit: reason } },
);
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.' };
}
},
}),
};
}
}