mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
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>
This commit is contained in:
41
src/messaging/messaging-conversation.service.ts
Normal file
41
src/messaging/messaging-conversation.service.ts
Normal file
@@ -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<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));
|
||||
}
|
||||
}
|
||||
36
src/messaging/messaging.controller.ts
Normal file
36
src/messaging/messaging.controller.ts
Normal file
@@ -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' };
|
||||
}
|
||||
}
|
||||
28
src/messaging/messaging.module.ts
Normal file
28
src/messaging/messaging.module.ts
Normal file
@@ -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 {}
|
||||
344
src/messaging/messaging.service.ts
Normal file
344
src/messaging/messaging.service.ts
Normal file
@@ -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<string>('ai.anthropicApiKey'),
|
||||
openaiApiKey: config.get<string>('ai.openaiApiKey'),
|
||||
});
|
||||
|
||||
const apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
this.auth = apiKey ? `Bearer ${apiKey}` : '';
|
||||
|
||||
if (this.aiModel) {
|
||||
this.logger.log(`WhatsApp AI configured: ${cfg.provider}/${cfg.model}`);
|
||||
} else {
|
||||
this.logger.warn('WhatsApp AI not configured — will send fallback replies');
|
||||
}
|
||||
}
|
||||
|
||||
async handleInbound(message: NormalizedMessage): Promise<void> {
|
||||
const { phone, name, text } = message;
|
||||
this.logger.log(`[WA] Inbound from ${phone} (${name}): ${text.substring(0, 100)}`);
|
||||
|
||||
if (!this.aiModel) {
|
||||
await this.provider.sendText(phone, 'Our assistant is temporarily unavailable. Please call us directly.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Resolve caller
|
||||
const resolved = await this.caller.resolve(phone, this.auth).catch(err => {
|
||||
this.logger.error(`[WA] Caller resolution failed: ${err.message}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
// 2. Build context
|
||||
let callerContextPrompt = '';
|
||||
if (resolved && !resolved.isNew && resolved.leadId) {
|
||||
const ctx = await this.callerContext.getOrBuild(resolved.leadId, resolved.patientId ?? '', this.auth).catch(() => null);
|
||||
if (ctx) {
|
||||
callerContextPrompt = this.callerContext.renderForPrompt(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Load conversation history
|
||||
const history = await this.conversation.getHistory(phone);
|
||||
const messages = [
|
||||
...history.map(h => ({ role: h.role as 'user' | 'assistant', content: h.content })),
|
||||
{ role: 'user' as const, content: text },
|
||||
];
|
||||
|
||||
// 4. Build system prompt
|
||||
const systemPrompt = this.buildSystemPrompt(callerContextPrompt, name, phone, resolved?.isNew ?? true);
|
||||
|
||||
// 5. Build tools
|
||||
const tools = this.buildTools(phone);
|
||||
|
||||
// 6. Run AI
|
||||
try {
|
||||
const result = await generateText({
|
||||
model: this.aiModel,
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
tools,
|
||||
stopWhen: stepCountIs(5),
|
||||
});
|
||||
|
||||
const reply = result.text?.trim();
|
||||
if (reply) {
|
||||
await this.provider.sendText(phone, reply);
|
||||
}
|
||||
|
||||
// 7. Persist conversation
|
||||
await this.conversation.addMessages(phone, [
|
||||
{ role: 'user', content: text, timestamp: Date.now() },
|
||||
...(reply ? [{ role: 'assistant' as const, content: reply, timestamp: Date.now() }] : []),
|
||||
]);
|
||||
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[WA] AI error: ${err.message}`);
|
||||
await this.provider.sendText(phone, 'Sorry, I encountered an error. Please try again or call us directly.');
|
||||
}
|
||||
}
|
||||
|
||||
private buildSystemPrompt(callerContext: string, name: string, phone: string, isNew: boolean): string {
|
||||
return `You are a friendly WhatsApp assistant for a hospital. You help patients with:
|
||||
- Answering questions about departments, doctors, timings, fees
|
||||
- Booking appointments
|
||||
- Checking existing appointments
|
||||
|
||||
RULES:
|
||||
- Be concise — WhatsApp messages should be short (2-3 sentences max per message).
|
||||
- No markdown formatting (no **, ##, bullets). Plain text only.
|
||||
- When booking an appointment, collect: department, doctor preference, preferred date/time, reason for visit.
|
||||
- Use the send_department_list tool to show available departments as a WhatsApp list.
|
||||
- Use the send_doctor_list tool to show available doctors as a WhatsApp list.
|
||||
- Use the send_slot_list tool to show available time slots as a WhatsApp list.
|
||||
- Use the send_confirm_buttons tool to let the patient confirm or cancel before booking.
|
||||
- After booking, send a confirmation with doctor name, date, time, and reference number.
|
||||
- If the patient asks something you can't help with, suggest they call the hospital directly.
|
||||
- Always be warm and professional. Use the patient's name when known.
|
||||
- Reply in the same language the patient uses. Button/list labels stay in English.
|
||||
|
||||
CURRENT PATIENT:
|
||||
Name: ${name || 'Unknown'}
|
||||
Phone: ${phone}
|
||||
${isNew ? 'New patient — no prior records.' : ''}
|
||||
${callerContext ? `\n${callerContext}` : ''}`;
|
||||
}
|
||||
|
||||
private buildTools(phone: string) {
|
||||
const provider = this.provider;
|
||||
const platform = this.platform;
|
||||
const auth = this.auth;
|
||||
const logger = this.logger;
|
||||
const callerService = this.caller;
|
||||
|
||||
return {
|
||||
lookup_appointments: tool({
|
||||
description: 'Look up existing appointments for the current patient.',
|
||||
inputSchema: z.object({
|
||||
patientId: z.string().optional().describe('Patient ID — omit to use current caller'),
|
||||
}),
|
||||
execute: async ({ patientId }) => {
|
||||
let pid = patientId;
|
||||
if (!pid) {
|
||||
const resolved = await callerService.resolve(phone, auth).catch(() => null);
|
||||
pid = resolved?.patientId;
|
||||
}
|
||||
if (!pid) return { appointments: [], message: 'No patient record found.' };
|
||||
|
||||
const data = await platform.query<any>(
|
||||
`{ appointments(first: 10, filter: { patientId: { eq: "${pid}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id scheduledAt appointmentStatus doctorName department reasonForVisit
|
||||
} } } }`,
|
||||
);
|
||||
const appts = data.appointments.edges.map((e: any) => e.node);
|
||||
logger.log(`[WA-TOOL] lookup_appointments: ${appts.length} found`);
|
||||
return { appointments: appts };
|
||||
},
|
||||
}),
|
||||
|
||||
send_department_list: tool({
|
||||
description: 'Send an interactive WhatsApp list of available departments. Call when patient wants to book but hasn\'t specified a department.',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
const data = await platform.query<any>(
|
||||
`{ doctors(first: 50) { edges { node { department } } } }`,
|
||||
);
|
||||
const departments = [...new Set(
|
||||
data.doctors.edges.map((e: any) => e.node.department).filter(Boolean),
|
||||
)] as string[];
|
||||
|
||||
if (!departments.length) return { sent: false, message: 'No departments available.' };
|
||||
|
||||
const sections: ListSection[] = [{
|
||||
title: 'Departments',
|
||||
rows: departments.slice(0, 10).map(d => ({
|
||||
id: `dept:${d}`,
|
||||
title: d.substring(0, 24),
|
||||
})),
|
||||
}];
|
||||
await provider.sendList(phone, 'Which department would you like to visit?', 'View Departments', sections);
|
||||
logger.log(`[WA-TOOL] send_department_list: ${departments.length} departments`);
|
||||
return { sent: true, departments };
|
||||
},
|
||||
}),
|
||||
|
||||
send_doctor_list: tool({
|
||||
description: 'Send an interactive WhatsApp list of doctors in a department. Call after patient selects a department.',
|
||||
inputSchema: z.object({
|
||||
department: z.string().describe('Department name'),
|
||||
}),
|
||||
execute: async ({ department }) => {
|
||||
const data = await platform.query<any>(
|
||||
`{ doctors(first: 50) { edges { node {
|
||||
id fullName { firstName lastName }
|
||||
department specialty
|
||||
consultationFeeNew { amountMicros currencyCode }
|
||||
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||
} } } }`,
|
||||
);
|
||||
const allDocs = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
|
||||
const deptDocs = allDocs.filter((d: any) =>
|
||||
d.department?.toLowerCase() === department.toLowerCase(),
|
||||
);
|
||||
|
||||
if (!deptDocs.length) return { sent: false, message: `No doctors found in ${department}.` };
|
||||
|
||||
const sections: ListSection[] = [{
|
||||
title: department,
|
||||
rows: deptDocs.slice(0, 10).map((d: any) => {
|
||||
const docName = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim();
|
||||
const fee = d.consultationFeeNew?.amountMicros
|
||||
? `₹${(d.consultationFeeNew.amountMicros / 1000000).toFixed(0)}`
|
||||
: '';
|
||||
return {
|
||||
id: `doc:${d.id}:${docName}`,
|
||||
title: docName.substring(0, 24),
|
||||
description: fee ? `${d.specialty ?? department} — ${fee}` : (d.specialty ?? department),
|
||||
};
|
||||
}),
|
||||
}];
|
||||
await provider.sendList(phone, `Doctors in ${department}:`, 'View Doctors', sections);
|
||||
logger.log(`[WA-TOOL] send_doctor_list: ${deptDocs.length} doctors in ${department}`);
|
||||
return { sent: true, count: deptDocs.length };
|
||||
},
|
||||
}),
|
||||
|
||||
send_slot_list: tool({
|
||||
description: 'Send available time slots for a doctor as a WhatsApp list. Call after patient selects a doctor.',
|
||||
inputSchema: z.object({
|
||||
doctorId: z.string().describe('Doctor ID from the list selection'),
|
||||
doctorName: z.string().describe('Doctor name for display'),
|
||||
date: z.string().optional().describe('Date in YYYY-MM-DD. Defaults to tomorrow.'),
|
||||
}),
|
||||
execute: async ({ doctorId, doctorName, date }) => {
|
||||
const targetDate = date ?? new Date(Date.now() + 86400000).toISOString().split('T')[0];
|
||||
|
||||
const data = await platform.query<any>(
|
||||
`{ doctors(first: 50) { edges { node {
|
||||
id fullName { firstName lastName }
|
||||
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||
} } } }`,
|
||||
);
|
||||
const allDocs = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
|
||||
const doctor = allDocs.find((d: any) => d.id === doctorId);
|
||||
const slots = doctor?.availableSlots ?? [];
|
||||
|
||||
if (!slots.length) {
|
||||
return { sent: false, message: `No slots available for ${doctorName} on ${targetDate}.` };
|
||||
}
|
||||
|
||||
const sections: ListSection[] = [{
|
||||
title: `${doctorName} — ${targetDate}`,
|
||||
rows: slots.slice(0, 10).map((s: any) => ({
|
||||
id: `slot:${doctorId}:${targetDate}T${s.time}:00`,
|
||||
title: s.time,
|
||||
description: s.clinic ?? '',
|
||||
})),
|
||||
}];
|
||||
await provider.sendList(phone, `Available slots for ${doctorName}:`, 'View Slots', sections);
|
||||
logger.log(`[WA-TOOL] send_slot_list: ${slots.length} slots for ${doctorName}`);
|
||||
return { sent: true, slots: slots.length };
|
||||
},
|
||||
}),
|
||||
|
||||
send_confirm_buttons: tool({
|
||||
description: 'Send confirmation buttons before booking. Call after all details are collected.',
|
||||
inputSchema: z.object({
|
||||
summary: z.string().describe('Appointment summary to show the patient'),
|
||||
}),
|
||||
execute: async ({ summary }) => {
|
||||
const buttons: InteractiveButton[] = [
|
||||
{ id: 'confirm_booking', title: 'Confirm' },
|
||||
{ id: 'cancel_booking', title: 'Cancel' },
|
||||
];
|
||||
await provider.sendButtons(phone, summary, buttons);
|
||||
logger.log(`[WA-TOOL] send_confirm_buttons`);
|
||||
return { sent: true };
|
||||
},
|
||||
}),
|
||||
|
||||
book_appointment: tool({
|
||||
description: 'Book the appointment after patient confirms. Only call AFTER the patient taps Confirm.',
|
||||
inputSchema: z.object({
|
||||
patientName: z.string().describe('Patient name'),
|
||||
phoneNumber: z.string().describe('Patient phone number'),
|
||||
department: z.string().describe('Department'),
|
||||
doctorName: z.string().describe('Doctor name'),
|
||||
scheduledAt: z.string().describe('ISO datetime'),
|
||||
reason: z.string().describe('Reason for visit'),
|
||||
}),
|
||||
execute: async ({ patientName, phoneNumber, department, doctorName, scheduledAt, reason }) => {
|
||||
logger.log(`[WA-BOOK] Booking: ${patientName} → ${doctorName} @ ${scheduledAt}`);
|
||||
try {
|
||||
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
||||
const resolved = await callerService.resolve(cleanPhone, auth).catch(() => null);
|
||||
|
||||
if (resolved?.isNew) {
|
||||
const firstName = patientName.split(' ')[0];
|
||||
const lastName = patientName.split(' ').slice(1).join(' ') || '';
|
||||
try {
|
||||
const p = await platform.query<any>(
|
||||
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||
{ data: { fullName: { firstName, lastName }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } },
|
||||
);
|
||||
const patientId = p?.createPatient?.id;
|
||||
await platform.query<any>(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{ data: { name: `WhatsApp — ${patientName}`, contactName: { firstName, lastName }, contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` }, source: 'WHATSAPP', status: 'NEW', interestedService: department, ...(patientId ? { patientId } : {}) } },
|
||||
);
|
||||
} catch (err: any) {
|
||||
logger.warn(`[WA-BOOK] Lead/patient creation failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await platform.query<any>(
|
||||
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||
{ data: { name: `WhatsApp Booking — ${patientName} (${department})`, scheduledAt, appointmentStatus: 'SCHEDULED', doctorName, department, reasonForVisit: reason } },
|
||||
);
|
||||
const id = result?.createAppointment?.id;
|
||||
if (id) {
|
||||
logger.log(`[WA-BOOK] Success: appointmentId=${id}`);
|
||||
return { booked: true, appointmentId: id, message: `Appointment booked! Reference: ${id.substring(0, 8)}` };
|
||||
}
|
||||
return { booked: false, message: 'Booking failed. Please try again.' };
|
||||
} catch (err: any) {
|
||||
logger.error(`[WA-BOOK] Failed: ${err.message}`);
|
||||
return { booked: false, message: 'Booking failed. Please call us directly.' };
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
142
src/messaging/providers/gupshup.provider.ts
Normal file
142
src/messaging/providers/gupshup.provider.ts
Normal file
@@ -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<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 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<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)}`);
|
||||
}
|
||||
}
|
||||
18
src/messaging/providers/messaging-provider.interface.ts
Normal file
18
src/messaging/providers/messaging-provider.interface.ts
Normal file
@@ -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<void>;
|
||||
|
||||
/** Send interactive buttons (max 3 for WhatsApp) */
|
||||
abstract sendButtons(to: string, body: string, buttons: InteractiveButton[]): Promise<void>;
|
||||
|
||||
/** Send interactive list (max 10 rows total across sections) */
|
||||
abstract sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise<void>;
|
||||
|
||||
/** Validate that inbound webhook is authentic */
|
||||
abstract validateWebhook(body: any): boolean;
|
||||
}
|
||||
27
src/messaging/types.ts
Normal file
27
src/messaging/types.ts
Normal file
@@ -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 }[];
|
||||
};
|
||||
Reference in New Issue
Block a user