From 2c947517afd24a561e94b5075773485b9eeb1533 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Mon, 20 Apr 2026 14:45:10 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20WhatsApp=20AI=20assistant=20=E2=80=94?= =?UTF-8?q?=20provider-agnostic=20messaging=20with=20Gupshup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../plans/2026-04-20-whatsapp-ai-assistant.md | 901 ++++++++++++++++++ src/app.module.ts | 2 + src/config/configuration.ts | 8 + .../messaging-conversation.service.ts | 41 + src/messaging/messaging.controller.ts | 36 + src/messaging/messaging.module.ts | 28 + src/messaging/messaging.service.ts | 344 +++++++ src/messaging/providers/gupshup.provider.ts | 142 +++ .../providers/messaging-provider.interface.ts | 18 + src/messaging/types.ts | 27 + 10 files changed, 1547 insertions(+) create mode 100644 docs/plans/2026-04-20-whatsapp-ai-assistant.md create mode 100644 src/messaging/messaging-conversation.service.ts create mode 100644 src/messaging/messaging.controller.ts create mode 100644 src/messaging/messaging.module.ts create mode 100644 src/messaging/messaging.service.ts create mode 100644 src/messaging/providers/gupshup.provider.ts create mode 100644 src/messaging/providers/messaging-provider.interface.ts create mode 100644 src/messaging/types.ts diff --git a/docs/plans/2026-04-20-whatsapp-ai-assistant.md b/docs/plans/2026-04-20-whatsapp-ai-assistant.md new file mode 100644 index 0000000..d258f4d --- /dev/null +++ b/docs/plans/2026-04-20-whatsapp-ai-assistant.md @@ -0,0 +1,901 @@ +# WhatsApp AI Assistant — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Provider-agnostic WhatsApp AI assistant that handles inbound patient messages — answers questions from KB, books appointments via interactive buttons, and creates/updates leads automatically. + +**Architecture:** A `MessagingModule` with a provider interface (Gupshup first, swappable to Ozonetel/Meta later). Inbound webhook → caller resolution → AI conversation with tools (reuses existing `book_appointment`, `lookup_doctor`, etc.) → outbound replies via provider. Conversation history stored in Redis with 24h TTL. Interactive WhatsApp buttons/lists for structured selection steps. + +**Tech Stack:** NestJS, Vercel AI SDK (`generateText` with tools), Redis, Gupshup WhatsApp API (`POST https://api.gupshup.io/wa/api/v1/msg`) + +--- + +## File Structure + +``` +src/messaging/ +├── messaging.module.ts — NestJS module, wires everything +├── messaging.controller.ts — POST /api/messaging/webhook (inbound) +├── messaging.service.ts — Conversation orchestration (resolve caller, build prompt, call AI, send reply) +├── messaging-conversation.service.ts — Redis conversation history (store/load/clear, 24h TTL) +├── providers/ +│ ├── messaging-provider.interface.ts — Provider contract (sendText, sendList, sendButtons, parseInbound) +│ └── gupshup.provider.ts — Gupshup implementation +└── types.ts — NormalizedMessage, ConversationEntry, InteractiveButton, ListSection +``` + +**Modified files:** +- `src/config/configuration.ts` — add `messaging` config block +- `src/app.module.ts` — import MessagingModule + +--- + +### Task 1: Types and Provider Interface + +**Files:** +- Create: `src/messaging/types.ts` +- Create: `src/messaging/providers/messaging-provider.interface.ts` + +- [ ] **Step 1: Create types** + +```typescript +// src/messaging/types.ts + +export type NormalizedMessage = { + phone: string; // E.164 without +, e.g. "919949879837" + name: string; // sender name from WhatsApp profile + text: string; // message text (or button reply title) + type: 'text' | 'interactive_reply' | 'location' | 'image' | 'unknown'; + interactiveReply?: { // populated when user taps a button or list item + id: string; // button/row ID set by us + title: string; // display text + }; + rawPayload: any; // original provider payload for debugging +}; + +export type ConversationEntry = { + role: 'user' | 'assistant'; + content: string; + timestamp: number; +}; + +export type InteractiveButton = { + id: string; + title: string; // max 20 chars for WhatsApp +}; + +export type ListSection = { + title: string; + rows: { id: string; title: string; description?: string }[]; +}; +``` + +- [ ] **Step 2: Create provider interface** + +```typescript +// src/messaging/providers/messaging-provider.interface.ts + +import { NormalizedMessage, InteractiveButton, ListSection } from '../types'; + +export interface MessagingProvider { + /** Parse raw webhook payload into normalized message */ + parseInbound(body: any): NormalizedMessage | null; + + /** Send a plain text message */ + sendText(to: string, text: string): Promise; + + /** Send interactive buttons (max 3 for WhatsApp) */ + sendButtons(to: string, body: string, buttons: InteractiveButton[]): Promise; + + /** Send interactive list (max 10 rows total across sections) */ + sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise; + + /** Validate that inbound webhook is authentic */ + validateWebhook(body: any): boolean; +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/messaging/types.ts src/messaging/providers/messaging-provider.interface.ts +git commit -m "feat(messaging): types and provider interface" +``` + +--- + +### Task 2: Gupshup Provider + +**Files:** +- Create: `src/messaging/providers/gupshup.provider.ts` + +- [ ] **Step 1: Implement Gupshup provider** + +```typescript +// src/messaging/providers/gupshup.provider.ts + +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { MessagingProvider } from './messaging-provider.interface'; +import { NormalizedMessage, InteractiveButton, ListSection } from '../types'; + +@Injectable() +export class GupshupProvider implements MessagingProvider { + private readonly logger = new Logger(GupshupProvider.name); + private readonly apiKey: string; + private readonly appId: string; + private readonly sourceNumber: string; + private readonly apiUrl = 'https://api.gupshup.io/wa/api/v1/msg'; + + constructor(private config: ConfigService) { + this.apiKey = config.get('messaging.gupshup.apiKey') ?? ''; + this.appId = config.get('messaging.gupshup.appId') ?? ''; + this.sourceNumber = config.get('messaging.gupshup.sourceNumber') ?? ''; + if (this.apiKey) { + this.logger.log(`Gupshup provider configured: appId=${this.appId} source=${this.sourceNumber}`); + } else { + this.logger.warn('Gupshup provider not configured — missing API key'); + } + } + + validateWebhook(body: any): boolean { + // Gupshup doesn't sign webhooks — validate by app name match + return body?.app === this.appId || !this.appId; + } + + parseInbound(body: any): NormalizedMessage | null { + // Gupshup sends: { app, timestamp, version, type, payload } + if (body?.type !== 'message') return null; + + const payload = body.payload; + if (!payload?.sender?.phone) return null; + + const phone = payload.sender.phone.replace(/\D/g, ''); + const name = payload.sender.name ?? ''; + const msgType = payload.type; + + // Text message + if (msgType === 'text') { + return { + phone, name, + text: payload.payload?.text ?? payload.text ?? '', + type: 'text', + rawPayload: body, + }; + } + + // Interactive reply (button tap or list selection) + if (msgType === 'button_reply' || msgType === 'list_reply') { + return { + phone, name, + text: payload.payload?.title ?? '', + type: 'interactive_reply', + interactiveReply: { + id: payload.payload?.id ?? '', + title: payload.payload?.title ?? '', + }, + rawPayload: body, + }; + } + + // Location + if (msgType === 'location') { + return { + phone, name, + text: `Location: ${payload.payload?.latitude}, ${payload.payload?.longitude}`, + type: 'location', + rawPayload: body, + }; + } + + // Image/document/audio — acknowledge but treat as text + if (['image', 'audio', 'video', 'document', 'sticker'].includes(msgType)) { + return { + phone, name, + text: `[Sent ${msgType}]`, + type: 'image', + rawPayload: body, + }; + } + + this.logger.warn(`[GUPSHUP] Unknown message type: ${msgType}`); + return { phone, name, text: '', type: 'unknown', rawPayload: body }; + } + + async sendText(to: string, text: string): Promise { + await this.send(to, JSON.stringify({ type: 'text', text })); + } + + async sendButtons(to: string, body: string, buttons: InteractiveButton[]): Promise { + const message = { + type: 'quick_reply', + content: { type: 'text', text: body }, + options: buttons.map(b => ({ type: 'text', title: b.title, postbackText: b.id })), + }; + await this.send(to, JSON.stringify(message)); + } + + async sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise { + const message = { + type: 'list', + title: buttonText, + body: body, + globalButtons: [{ type: 'text', title: buttonText }], + items: sections.map(s => ({ + title: s.title, + options: s.rows.map(r => ({ + type: 'text', + title: r.title, + description: r.description ?? '', + postbackText: r.id, + })), + })), + }; + await this.send(to, JSON.stringify(message)); + } + + private async send(to: string, message: string): Promise { + const params = new URLSearchParams(); + params.append('channel', 'whatsapp'); + params.append('source', this.sourceNumber); + params.append('destination', to); + params.append('message', message); + params.append('src.name', this.appId); + + this.logger.log(`[GUPSHUP] Sending to ${to}: ${message.substring(0, 100)}...`); + + const resp = await fetch(this.apiUrl, { + method: 'POST', + headers: { + 'apikey': this.apiKey, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + + const result = await resp.json().catch(() => resp.text()); + if (!resp.ok) { + this.logger.error(`[GUPSHUP] Send failed (${resp.status}): ${JSON.stringify(result)}`); + throw new Error(`Gupshup send failed: ${resp.status}`); + } + this.logger.log(`[GUPSHUP] Sent: ${JSON.stringify(result)}`); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/messaging/providers/gupshup.provider.ts +git commit -m "feat(messaging): gupshup provider implementation" +``` + +--- + +### Task 3: Conversation History Service + +**Files:** +- Create: `src/messaging/messaging-conversation.service.ts` + +- [ ] **Step 1: Implement Redis-backed conversation store** + +```typescript +// src/messaging/messaging-conversation.service.ts + +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; +import { ConversationEntry } from './types'; + +@Injectable() +export class MessagingConversationService { + private readonly logger = new Logger(MessagingConversationService.name); + private readonly redis: Redis; + private readonly ttlSec = 24 * 60 * 60; // 24 hours — matches WhatsApp session window + private readonly maxHistory = 20; // keep last 20 message pairs + + constructor(config: ConfigService) { + const redisUrl = config.get('redis.url') ?? 'redis://localhost:6379'; + this.redis = new Redis(redisUrl); + } + + private key(phone: string): string { + return `wa:conv:${phone}`; + } + + async getHistory(phone: string): Promise { + const raw = await this.redis.get(this.key(phone)); + if (!raw) return []; + try { + return JSON.parse(raw); + } catch { + return []; + } + } + + async addMessages(phone: string, entries: ConversationEntry[]): Promise { + const existing = await this.getHistory(phone); + const updated = [...existing, ...entries].slice(-this.maxHistory); + await this.redis.setex(this.key(phone), this.ttlSec, JSON.stringify(updated)); + } + + async clear(phone: string): Promise { + await this.redis.del(this.key(phone)); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/messaging/messaging-conversation.service.ts +git commit -m "feat(messaging): redis conversation history service" +``` + +--- + +### Task 4: Messaging Service (Conversation Orchestration) + +**Files:** +- Create: `src/messaging/messaging.service.ts` + +This is the core — resolves the caller, builds AI context, runs the AI with tools, sends the reply back. + +- [ ] **Step 1: Create messaging service** + +```typescript +// src/messaging/messaging.service.ts + +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { generateText, tool } 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, InteractiveButton, ListSection } 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; // server-to-server API key auth + + 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('ai.anthropicApiKey'), + openaiApiKey: config.get('ai.openaiApiKey'), + }); + + // WhatsApp AI uses server-to-server auth (no user JWT) + const apiKey = config.get('platform.apiKey') ?? ''; + this.auth = apiKey ? `Bearer ${apiKey}` : ''; + } + + async handleInbound(message: NormalizedMessage): Promise { + 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 — provider is injected so tools can send interactive messages + const tools = this.buildTools(phone); + + // 6. Run AI + try { + const result = await generateText({ + model: this.aiModel, + system: systemPrompt, + messages, + tools, + maxSteps: 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; + + return { + lookup_appointments: tool({ + description: 'Look up existing appointments for the current patient.', + parameters: z.object({ + patientId: z.string().optional().describe('Patient ID — omit to use current caller context'), + }), + execute: async ({ patientId }) => { + // Resolve patient from phone if not provided + let pid = patientId; + if (!pid) { + const resolved = await this.caller.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 + } } } }`, + ); + return { appointments: data.appointments.edges.map((e: any) => e.node) }; + }, + }), + + send_department_list: tool({ + description: 'Send an interactive WhatsApp list of available departments for the patient to choose from. Call this when the patient wants to book but hasn\'t specified a department.', + parameters: 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); + return { sent: true, departments }; + }, + }), + + send_doctor_list: tool({ + description: 'Send an interactive WhatsApp list of doctors in a specific department. Call this after the patient selects a department.', + parameters: 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, + rows: deptDocs.slice(0, 10).map((d: any) => { + const name = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim(); + const fee = d.consultationFeeNew?.amountMicros + ? `₹${(d.consultationFeeNew.amountMicros / 1000000).toFixed(0)}` + : ''; + return { + id: `doc:${d.id}:${name}`, + title: name.substring(0, 24), + description: fee ? `${d.specialty ?? department} — ${fee}` : (d.specialty ?? department), + }; + }), + }]; + await provider.sendList(phone, `Doctors in ${department}:`, 'View Doctors', sections); + return { sent: true, count: deptDocs.length }; + }, + }), + + send_slot_list: tool({ + description: 'Send available time slots for a doctor as a WhatsApp list. Call this after the patient selects a doctor.', + parameters: z.object({ + doctorId: z.string().describe('Doctor ID from the doctor list selection'), + doctorName: z.string().describe('Doctor name for display'), + date: z.string().optional().describe('Date in YYYY-MM-DD format. 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( + `{ 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 Dr. ${doctorName} on ${targetDate}.` }; + } + + const sections: ListSection[] = [{ + title: `${doctorName} — ${targetDate}`, + rows: slots.slice(0, 10).map((s: any, i: number) => ({ + 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); + return { sent: true, slots: slots.length }; + }, + }), + + send_confirm_buttons: tool({ + description: 'Send confirmation buttons before booking the appointment. Call this after all details are collected.', + parameters: z.object({ + summary: z.string().describe('Appointment summary text 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); + return { sent: true }; + }, + }), + + book_appointment: tool({ + description: 'Book the appointment after patient confirms. Only call this AFTER the patient taps the Confirm button.', + parameters: 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 for the appointment'), + reason: z.string().describe('Reason for visit'), + }), + execute: async ({ patientName, phoneNumber, department, doctorName, scheduledAt, reason }) => { + logger.log(`[WA-BOOK] Booking: ${patientName} → ${doctorName} @ ${scheduledAt}`); + try { + // Ensure lead exists + const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10); + const resolved = await this.caller.resolve(cleanPhone, auth).catch(() => null); + + if (resolved?.isNew) { + // Create patient + lead + 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, appointmentStatus: 'SCHEDULED', doctorName, department, reasonForVisit: reason } }, + ); + const id = result?.createAppointment?.id; + if (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.' }; + } + }, + }), + }; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/messaging/messaging.service.ts +git commit -m "feat(messaging): conversation orchestration service with AI tools" +``` + +--- + +### Task 5: Webhook Controller + +**Files:** +- Create: `src/messaging/messaging.controller.ts` + +- [ ] **Step 1: Create the webhook controller** + +```typescript +// src/messaging/messaging.controller.ts + +import { Controller, Post, Body, Logger } from '@nestjs/common'; +import { MessagingProvider } from './providers/messaging-provider.interface'; +import { MessagingService } from './messaging.service'; + +@Controller('api/messaging') +export class MessagingController { + private readonly logger = new Logger(MessagingController.name); + + constructor( + private readonly provider: MessagingProvider, + private readonly messaging: MessagingService, + ) {} + + @Post('webhook') + async webhook(@Body() body: any) { + this.logger.log(`[WA-WEBHOOK] Received: ${JSON.stringify(body).substring(0, 300)}`); + + // Validate webhook source + if (!this.provider.validateWebhook(body)) { + this.logger.warn('[WA-WEBHOOK] Validation failed — ignoring'); + return { status: 'ignored', reason: 'validation failed' }; + } + + // Parse inbound message + const message = this.provider.parseInbound(body); + if (!message) { + this.logger.log('[WA-WEBHOOK] Non-message event — skipped'); + return { status: 'ok', type: body?.type ?? 'unknown' }; + } + + // Handle asynchronously — don't block the webhook response + this.messaging.handleInbound(message).catch(err => { + this.logger.error(`[WA-WEBHOOK] handleInbound failed: ${err.message}`); + }); + + return { status: 'ok' }; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/messaging/messaging.controller.ts +git commit -m "feat(messaging): webhook controller" +``` + +--- + +### Task 6: Module Wiring and Configuration + +**Files:** +- Create: `src/messaging/messaging.module.ts` +- Modify: `src/config/configuration.ts` +- Modify: `src/app.module.ts` + +- [ ] **Step 1: Add messaging config** + +Add to `src/config/configuration.ts`, after the `ai` block: + +```typescript + messaging: { + provider: process.env.MESSAGING_PROVIDER ?? 'gupshup', + gupshup: { + apiKey: process.env.GUPSHUP_API_KEY ?? '', + appId: process.env.GUPSHUP_APP_ID ?? '', + sourceNumber: process.env.GUPSHUP_SOURCE_NUMBER ?? '', + }, + }, +``` + +- [ ] **Step 2: Create module** + +```typescript +// src/messaging/messaging.module.ts + +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { PlatformModule } from '../platform/platform.module'; +import { CallerResolutionModule } from '../caller/caller-resolution.module'; +import { MessagingController } from './messaging.controller'; +import { MessagingService } from './messaging.service'; +import { MessagingConversationService } from './messaging-conversation.service'; +import { GupshupProvider } from './providers/gupshup.provider'; +import { MessagingProvider } from './providers/messaging-provider.interface'; + +@Module({ + imports: [ConfigModule, PlatformModule, CallerResolutionModule], + controllers: [MessagingController], + providers: [ + MessagingService, + MessagingConversationService, + { + provide: MessagingProvider, + useFactory: (config: ConfigService) => { + const provider = config.get('messaging.provider'); + // Future: switch on provider to return OzonetelProvider, MetaProvider, etc. + return new GupshupProvider(config); + }, + inject: [ConfigService], + }, + ], +}) +export class MessagingModule {} +``` + +- [ ] **Step 3: Register in app.module.ts** + +Add import at the top: +```typescript +import { MessagingModule } from './messaging/messaging.module'; +``` + +Add `MessagingModule` to the `imports` array. + +- [ ] **Step 4: Commit** + +```bash +git add src/messaging/messaging.module.ts src/config/configuration.ts src/app.module.ts +git commit -m "feat(messaging): module wiring and configuration" +``` + +--- + +### Task 7: Environment Variables and Deployment + +**Files:** +- Modify: Ramaiah sidecar env on EC2 + +- [ ] **Step 1: Add env vars to Ramaiah sidecar** + +SSH into EC2 and add to the sidecar-ramaiah environment in docker-compose: + +```bash +SSHPASS='SasiSuman@2007' sshpass -P "Enter passphrase" -e \ + ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194 + +cd /opt/fortytwo +# Edit docker-compose.yml — add to sidecar-ramaiah environment: +# MESSAGING_PROVIDER=gupshup +# GUPSHUP_API_KEY=sk_c6dd2ff65d4f4e2d967cf7bbc2f620ed +# GUPSHUP_APP_ID=f6196887-ed08-4c4e-9049-e4e4ec59b254 +# GUPSHUP_SOURCE_NUMBER= +``` + +- [ ] **Step 2: Configure Gupshup webhook** + +In the Gupshup dashboard, set the callback URL to: +``` +https://ramaiah.engage.healix360.net/api/messaging/webhook +``` + +- [ ] **Step 3: Build, push, and deploy sidecar** + +```bash +cd helix-engage-server +aws ecr get-login-password --region ap-south-1 | docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com +docker buildx build --platform linux/amd64 -t 043728036361.dkr.ecr.ap-south-1.amazonaws.com/fortytwo-eap/helix-engage-sidecar:alpha --push . +``` + +On EC2: +```bash +cd /opt/fortytwo && sudo docker compose pull sidecar-ramaiah && sudo docker compose up -d sidecar-ramaiah +``` + +- [ ] **Step 4: Test end-to-end** + +Send a WhatsApp message to the Gupshup-registered number. Verify: +1. Webhook received (check sidecar logs) +2. AI response sent back +3. Department list renders as interactive WhatsApp list +4. Doctor selection works +5. Slot selection works +6. Confirm/cancel buttons render +7. Appointment appears in platform + +- [ ] **Step 5: Commit env docs** + +```bash +git add docs/plans/2026-04-20-whatsapp-ai-assistant.md +git commit -m "docs: whatsapp AI assistant implementation plan" +``` + +--- + +## Missing: Source Number + +The `GUPSHUP_SOURCE_NUMBER` env var needs the WhatsApp Business number registered with Gupshup. This is the number patients will message. Check the Gupshup dashboard under App Settings → WhatsApp Number. + +## Provider Swap (Future) + +To add Ozonetel or Meta Cloud API: +1. Create `src/messaging/providers/ozonetel.provider.ts` implementing `MessagingProvider` +2. Add config block in `configuration.ts` +3. Update the `useFactory` in `messaging.module.ts` to switch on `config.get('messaging.provider')` +4. Set `MESSAGING_PROVIDER=ozonetel` in env + +No other files change — the controller, service, and conversation store are provider-agnostic. diff --git a/src/app.module.ts b/src/app.module.ts index 6b18437..bd1f633 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -23,6 +23,7 @@ import { WidgetModule } from './widget/widget.module'; import { TeamModule } from './team/team.module'; import { MasterdataModule } from './masterdata/masterdata.module'; import { LeadsModule } from './leads/leads.module'; +import { MessagingModule } from './messaging/messaging.module'; import { TelephonyRegistrationService } from './telephony-registration.service'; @Module({ @@ -53,6 +54,7 @@ import { TelephonyRegistrationService } from './telephony-registration.service'; TeamModule, MasterdataModule, LeadsModule, + MessagingModule, ], providers: [TelephonyRegistrationService], }) diff --git a/src/config/configuration.ts b/src/config/configuration.ts index c98efc2..cbbd79a 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -38,4 +38,12 @@ export default () => ({ openaiApiKey: process.env.OPENAI_API_KEY ?? '', model: process.env.AI_MODEL ?? 'gpt-4o-mini', }, + messaging: { + provider: process.env.MESSAGING_PROVIDER ?? 'gupshup', + gupshup: { + apiKey: process.env.GUPSHUP_API_KEY ?? '', + appId: process.env.GUPSHUP_APP_ID ?? '', + sourceNumber: process.env.GUPSHUP_SOURCE_NUMBER ?? '', + }, + }, }); diff --git a/src/messaging/messaging-conversation.service.ts b/src/messaging/messaging-conversation.service.ts new file mode 100644 index 0000000..8e076f4 --- /dev/null +++ b/src/messaging/messaging-conversation.service.ts @@ -0,0 +1,41 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; +import { ConversationEntry } from './types'; + +@Injectable() +export class MessagingConversationService { + private readonly logger = new Logger(MessagingConversationService.name); + private readonly redis: Redis; + private readonly ttlSec = 24 * 60 * 60; // 24h — matches WhatsApp session window + private readonly maxHistory = 20; + + constructor(config: ConfigService) { + const redisUrl = config.get('redis.url') ?? 'redis://localhost:6379'; + this.redis = new Redis(redisUrl); + } + + private key(phone: string): string { + return `wa:conv:${phone}`; + } + + async getHistory(phone: string): Promise { + const raw = await this.redis.get(this.key(phone)); + if (!raw) return []; + try { + return JSON.parse(raw); + } catch { + return []; + } + } + + async addMessages(phone: string, entries: ConversationEntry[]): Promise { + const existing = await this.getHistory(phone); + const updated = [...existing, ...entries].slice(-this.maxHistory); + await this.redis.setex(this.key(phone), this.ttlSec, JSON.stringify(updated)); + } + + async clear(phone: string): Promise { + await this.redis.del(this.key(phone)); + } +} diff --git a/src/messaging/messaging.controller.ts b/src/messaging/messaging.controller.ts new file mode 100644 index 0000000..26c6dc3 --- /dev/null +++ b/src/messaging/messaging.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Post, Body, Logger } from '@nestjs/common'; +import { MessagingProvider } from './providers/messaging-provider.interface'; +import { MessagingService } from './messaging.service'; + +@Controller('api/messaging') +export class MessagingController { + private readonly logger = new Logger(MessagingController.name); + + constructor( + private readonly provider: MessagingProvider, + private readonly messaging: MessagingService, + ) {} + + @Post('webhook') + async webhook(@Body() body: any) { + this.logger.log(`[WA-WEBHOOK] Received: ${JSON.stringify(body).substring(0, 300)}`); + + if (!this.provider.validateWebhook(body)) { + this.logger.warn('[WA-WEBHOOK] Validation failed — ignoring'); + return { status: 'ignored', reason: 'validation failed' }; + } + + const message = this.provider.parseInbound(body); + if (!message) { + this.logger.log('[WA-WEBHOOK] Non-message event — skipped'); + return { status: 'ok', type: body?.type ?? 'unknown' }; + } + + // Handle async — don't block webhook response + this.messaging.handleInbound(message).catch(err => { + this.logger.error(`[WA-WEBHOOK] handleInbound failed: ${err.message}`); + }); + + return { status: 'ok' }; + } +} diff --git a/src/messaging/messaging.module.ts b/src/messaging/messaging.module.ts new file mode 100644 index 0000000..da886e6 --- /dev/null +++ b/src/messaging/messaging.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PlatformModule } from '../platform/platform.module'; +import { CallerResolutionModule } from '../caller/caller-resolution.module'; +import { MessagingController } from './messaging.controller'; +import { MessagingService } from './messaging.service'; +import { MessagingConversationService } from './messaging-conversation.service'; +import { GupshupProvider } from './providers/gupshup.provider'; +import { MessagingProvider } from './providers/messaging-provider.interface'; + +@Module({ + imports: [PlatformModule, CallerResolutionModule], + controllers: [MessagingController], + providers: [ + MessagingService, + MessagingConversationService, + { + provide: MessagingProvider, + useFactory: (config: ConfigService) => { + // Future: switch on config.get('messaging.provider') to return + // OzonetelProvider, MetaCloudProvider, etc. + return new GupshupProvider(config); + }, + inject: [ConfigService], + }, + ], +}) +export class MessagingModule {} diff --git a/src/messaging/messaging.service.ts b/src/messaging/messaging.service.ts new file mode 100644 index 0000000..b187850 --- /dev/null +++ b/src/messaging/messaging.service.ts @@ -0,0 +1,344 @@ +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('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; + 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( + `{ 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, + 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( + `{ 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( + `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, 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.' }; + } + }, + }), + }; + } +} diff --git a/src/messaging/providers/gupshup.provider.ts b/src/messaging/providers/gupshup.provider.ts new file mode 100644 index 0000000..dbab47e --- /dev/null +++ b/src/messaging/providers/gupshup.provider.ts @@ -0,0 +1,142 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { MessagingProvider } from './messaging-provider.interface'; +import { NormalizedMessage, InteractiveButton, ListSection } from '../types'; + +@Injectable() +export class GupshupProvider extends MessagingProvider { + private readonly logger = new Logger(GupshupProvider.name); + private readonly apiKey: string; + private readonly appId: string; + private readonly sourceNumber: string; + private readonly apiUrl = 'https://api.gupshup.io/wa/api/v1/msg'; + + constructor(private config: ConfigService) { + super(); + this.apiKey = config.get('messaging.gupshup.apiKey') ?? ''; + this.appId = config.get('messaging.gupshup.appId') ?? ''; + this.sourceNumber = config.get('messaging.gupshup.sourceNumber') ?? ''; + if (this.apiKey) { + this.logger.log(`Gupshup configured: appId=${this.appId} source=${this.sourceNumber}`); + } else { + this.logger.warn('Gupshup not configured — missing API key'); + } + } + + validateWebhook(body: any): boolean { + return body?.app === this.appId || !this.appId; + } + + parseInbound(body: any): NormalizedMessage | null { + if (body?.type !== 'message') return null; + + const payload = body.payload; + if (!payload?.sender?.phone) return null; + + const phone = payload.sender.phone.replace(/\D/g, ''); + const name = payload.sender.name ?? ''; + const msgType = payload.type; + + if (msgType === 'text') { + return { + phone, name, + text: payload.payload?.text ?? payload.text ?? '', + type: 'text', + rawPayload: body, + }; + } + + if (msgType === 'button_reply' || msgType === 'list_reply') { + return { + phone, name, + text: payload.payload?.title ?? '', + type: 'interactive_reply', + interactiveReply: { + id: payload.payload?.id ?? payload.payload?.postbackText ?? '', + title: payload.payload?.title ?? '', + }, + rawPayload: body, + }; + } + + if (msgType === 'location') { + return { + phone, name, + text: `Location: ${payload.payload?.latitude}, ${payload.payload?.longitude}`, + type: 'location', + rawPayload: body, + }; + } + + if (['image', 'audio', 'video', 'document', 'sticker'].includes(msgType)) { + return { + phone, name, + text: `[Sent ${msgType}]`, + type: 'image', + rawPayload: body, + }; + } + + this.logger.warn(`[GUPSHUP] Unknown message type: ${msgType}`); + return { phone, name, text: '', type: 'unknown', rawPayload: body }; + } + + async sendText(to: string, text: string): Promise { + await this.send(to, JSON.stringify({ type: 'text', text })); + } + + async sendButtons(to: string, body: string, buttons: InteractiveButton[]): Promise { + const message = { + type: 'quick_reply', + content: { type: 'text', text: body }, + options: buttons.map(b => ({ type: 'text', title: b.title, postbackText: b.id })), + }; + await this.send(to, JSON.stringify(message)); + } + + async sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise { + const message = { + type: 'list', + title: buttonText, + body: body, + globalButtons: [{ type: 'text', title: buttonText }], + items: sections.map(s => ({ + title: s.title, + options: s.rows.map(r => ({ + type: 'text', + title: r.title, + description: r.description ?? '', + postbackText: r.id, + })), + })), + }; + await this.send(to, JSON.stringify(message)); + } + + private async send(to: string, message: string): Promise { + const params = new URLSearchParams(); + params.append('channel', 'whatsapp'); + params.append('source', this.sourceNumber); + params.append('destination', to); + params.append('message', message); + params.append('src.name', this.appId); + + this.logger.log(`[GUPSHUP] Sending to ${to}: ${message.substring(0, 100)}...`); + + const resp = await fetch(this.apiUrl, { + method: 'POST', + headers: { + 'apikey': this.apiKey, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + + const result = await resp.json().catch(() => resp.text()); + if (!resp.ok) { + this.logger.error(`[GUPSHUP] Send failed (${resp.status}): ${JSON.stringify(result)}`); + throw new Error(`Gupshup send failed: ${resp.status}`); + } + this.logger.log(`[GUPSHUP] Sent: ${JSON.stringify(result)}`); + } +} diff --git a/src/messaging/providers/messaging-provider.interface.ts b/src/messaging/providers/messaging-provider.interface.ts new file mode 100644 index 0000000..dd78ba3 --- /dev/null +++ b/src/messaging/providers/messaging-provider.interface.ts @@ -0,0 +1,18 @@ +import { NormalizedMessage, InteractiveButton, ListSection } from '../types'; + +export abstract class MessagingProvider { + /** Parse raw webhook payload into normalized message */ + abstract parseInbound(body: any): NormalizedMessage | null; + + /** Send a plain text message */ + abstract sendText(to: string, text: string): Promise; + + /** Send interactive buttons (max 3 for WhatsApp) */ + abstract sendButtons(to: string, body: string, buttons: InteractiveButton[]): Promise; + + /** Send interactive list (max 10 rows total across sections) */ + abstract sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise; + + /** Validate that inbound webhook is authentic */ + abstract validateWebhook(body: any): boolean; +} diff --git a/src/messaging/types.ts b/src/messaging/types.ts new file mode 100644 index 0000000..abc856a --- /dev/null +++ b/src/messaging/types.ts @@ -0,0 +1,27 @@ +export type NormalizedMessage = { + phone: string; // E.164 without +, e.g. "919949879837" + name: string; // sender name from WhatsApp profile + text: string; // message text (or button reply title) + type: 'text' | 'interactive_reply' | 'location' | 'image' | 'unknown'; + interactiveReply?: { // populated when user taps a button or list item + id: string; // button/row ID set by us + title: string; // display text + }; + rawPayload: any; // original provider payload for debugging +}; + +export type ConversationEntry = { + role: 'user' | 'assistant'; + content: string; + timestamp: number; +}; + +export type InteractiveButton = { + id: string; + title: string; // max 20 chars for WhatsApp +}; + +export type ListSection = { + title: string; + rows: { id: string; title: string; description?: string }[]; +};