# 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.