Files
helix-engage-server/docs/plans/2026-04-20-whatsapp-ai-assistant.md
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

36 KiB

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

// 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
// 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<void>;

    /** Send interactive buttons (max 3 for WhatsApp) */
    sendButtons(to: string, body: string, buttons: InteractiveButton[]): Promise<void>;

    /** Send interactive list (max 10 rows total across sections) */
    sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise<void>;

    /** Validate that inbound webhook is authentic */
    validateWebhook(body: any): boolean;
}
  • Step 3: Commit
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

// 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<string>('messaging.gupshup.apiKey') ?? '';
        this.appId = config.get<string>('messaging.gupshup.appId') ?? '';
        this.sourceNumber = config.get<string>('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<void> {
        await this.send(to, JSON.stringify({ type: 'text', text }));
    }

    async sendButtons(to: string, body: string, buttons: InteractiveButton[]): Promise<void> {
        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<void> {
        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<void> {
        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
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

// 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<string>('redis.url') ?? 'redis://localhost:6379';
        this.redis = new Redis(redisUrl);
    }

    private key(phone: string): string {
        return `wa:conv:${phone}`;
    }

    async getHistory(phone: string): Promise<ConversationEntry[]> {
        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<void> {
        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<void> {
        await this.redis.del(this.key(phone));
    }
}
  • Step 2: Commit
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
// 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<string>('ai.anthropicApiKey'),
            openaiApiKey: config.get<string>('ai.openaiApiKey'),
        });

        // WhatsApp AI uses server-to-server auth (no user JWT)
        const apiKey = config.get<string>('platform.apiKey') ?? '';
        this.auth = apiKey ? `Bearer ${apiKey}` : '';
    }

    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 — 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<any>(
                        `{ 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<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);
                    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<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 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<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 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<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) {
                            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
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

// 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
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:

    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
// 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<string>('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:

import { MessagingModule } from './messaging/messaging.module';

Add MessagingModule to the imports array.

  • Step 4: Commit
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:

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=<the WhatsApp Business number registered with Gupshup>
  • 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
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:

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