mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Compare commits
15 Commits
3bb4315925
...
9ee087b898
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ee087b898 | |||
| 963cf28d23 | |||
| 903e82b536 | |||
| 2e0527e1d8 | |||
| 4549241b78 | |||
| 6a3834a7eb | |||
| 6847f5de95 | |||
| d857a0b270 | |||
| 214cc60917 | |||
| c4c437abd6 | |||
| b1922809d0 | |||
| 8aae95e8cc | |||
| 2c947517af | |||
|
|
473183869a | ||
| 350fcdd926 |
901
docs/plans/2026-04-20-whatsapp-ai-assistant.md
Normal file
901
docs/plans/2026-04-20-whatsapp-ai-assistant.md
Normal file
@@ -0,0 +1,901 @@
|
||||
# WhatsApp AI Assistant — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Provider-agnostic WhatsApp AI assistant that handles inbound patient messages — answers questions from KB, books appointments via interactive buttons, and creates/updates leads automatically.
|
||||
|
||||
**Architecture:** A `MessagingModule` with a provider interface (Gupshup first, swappable to Ozonetel/Meta later). Inbound webhook → caller resolution → AI conversation with tools (reuses existing `book_appointment`, `lookup_doctor`, etc.) → outbound replies via provider. Conversation history stored in Redis with 24h TTL. Interactive WhatsApp buttons/lists for structured selection steps.
|
||||
|
||||
**Tech Stack:** NestJS, Vercel AI SDK (`generateText` with tools), Redis, Gupshup WhatsApp API (`POST https://api.gupshup.io/wa/api/v1/msg`)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/messaging/
|
||||
├── messaging.module.ts — NestJS module, wires everything
|
||||
├── messaging.controller.ts — POST /api/messaging/webhook (inbound)
|
||||
├── messaging.service.ts — Conversation orchestration (resolve caller, build prompt, call AI, send reply)
|
||||
├── messaging-conversation.service.ts — Redis conversation history (store/load/clear, 24h TTL)
|
||||
├── providers/
|
||||
│ ├── messaging-provider.interface.ts — Provider contract (sendText, sendList, sendButtons, parseInbound)
|
||||
│ └── gupshup.provider.ts — Gupshup implementation
|
||||
└── types.ts — NormalizedMessage, ConversationEntry, InteractiveButton, ListSection
|
||||
```
|
||||
|
||||
**Modified files:**
|
||||
- `src/config/configuration.ts` — add `messaging` config block
|
||||
- `src/app.module.ts` — import MessagingModule
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Types and Provider Interface
|
||||
|
||||
**Files:**
|
||||
- Create: `src/messaging/types.ts`
|
||||
- Create: `src/messaging/providers/messaging-provider.interface.ts`
|
||||
|
||||
- [ ] **Step 1: Create types**
|
||||
|
||||
```typescript
|
||||
// src/messaging/types.ts
|
||||
|
||||
export type NormalizedMessage = {
|
||||
phone: string; // E.164 without +, e.g. "919949879837"
|
||||
name: string; // sender name from WhatsApp profile
|
||||
text: string; // message text (or button reply title)
|
||||
type: 'text' | 'interactive_reply' | 'location' | 'image' | 'unknown';
|
||||
interactiveReply?: { // populated when user taps a button or list item
|
||||
id: string; // button/row ID set by us
|
||||
title: string; // display text
|
||||
};
|
||||
rawPayload: any; // original provider payload for debugging
|
||||
};
|
||||
|
||||
export type ConversationEntry = {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type InteractiveButton = {
|
||||
id: string;
|
||||
title: string; // max 20 chars for WhatsApp
|
||||
};
|
||||
|
||||
export type ListSection = {
|
||||
title: string;
|
||||
rows: { id: string; title: string; description?: string }[];
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create provider interface**
|
||||
|
||||
```typescript
|
||||
// src/messaging/providers/messaging-provider.interface.ts
|
||||
|
||||
import { NormalizedMessage, InteractiveButton, ListSection } from '../types';
|
||||
|
||||
export interface MessagingProvider {
|
||||
/** Parse raw webhook payload into normalized message */
|
||||
parseInbound(body: any): NormalizedMessage | null;
|
||||
|
||||
/** Send a plain text message */
|
||||
sendText(to: string, text: string): Promise<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**
|
||||
|
||||
```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<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**
|
||||
|
||||
```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<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**
|
||||
|
||||
```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<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**
|
||||
|
||||
```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<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:
|
||||
```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=<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**
|
||||
|
||||
```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.
|
||||
270
docs/specs/2026-04-20-flow-runtime-design.md
Normal file
270
docs/specs/2026-04-20-flow-runtime-design.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# WhatsApp Flow Runtime — Design Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Config-driven conversation engine that reads flow definitions (JSON) and executes them at runtime. Replaces the hardcoded system prompt + tools in `messaging.service.ts`. Hospital admins define flows via API/file — no code changes needed.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Inbound WhatsApp message
|
||||
→ MessagingController (existing)
|
||||
→ FlowExecutionService (NEW — replaces MessagingService AI logic)
|
||||
→ Load/create FlowSession from Redis
|
||||
→ Match flow by trigger (or resume existing session)
|
||||
→ Walk forward through Groups → Blocks
|
||||
→ Pause at InputBlock, resume on next message
|
||||
→ Send messages via MessagingProvider (existing)
|
||||
→ Call tools via ToolRegistry (NEW)
|
||||
→ Reply sent to patient
|
||||
```
|
||||
|
||||
## Flow Definition Schema
|
||||
|
||||
```typescript
|
||||
type Flow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
trigger: FlowTrigger;
|
||||
groups: Group[];
|
||||
edges: Edge[];
|
||||
variables: VariableDefinition[];
|
||||
version: number;
|
||||
status: 'draft' | 'published';
|
||||
};
|
||||
|
||||
type FlowTrigger =
|
||||
| { type: 'message'; conditions?: { keywords?: string[]; regex?: string } }
|
||||
| { type: 'default' };
|
||||
|
||||
type VariableDefinition = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||
defaultValue?: any;
|
||||
};
|
||||
```
|
||||
|
||||
## Groups and Edges
|
||||
|
||||
```typescript
|
||||
type Group = {
|
||||
id: string;
|
||||
title: string;
|
||||
blocks: Block[];
|
||||
};
|
||||
|
||||
type Edge = {
|
||||
id: string;
|
||||
from: { blockId: string; conditionId?: string };
|
||||
to: { groupId: string; blockId?: string };
|
||||
};
|
||||
```
|
||||
|
||||
## Block Types
|
||||
|
||||
```typescript
|
||||
type Block =
|
||||
| MessageBlock
|
||||
| InputBlock
|
||||
| ConditionBlock
|
||||
| SetVariableBlock
|
||||
| ToolCallBlock
|
||||
| AIBlock
|
||||
| JumpBlock;
|
||||
|
||||
// Send text/list/buttons to patient
|
||||
type MessageBlock = {
|
||||
id: string;
|
||||
type: 'message';
|
||||
content:
|
||||
| { format: 'text'; text: string }
|
||||
| { format: 'buttons'; text: string; buttons: { id: string; title: string }[] }
|
||||
| { format: 'list'; text: string; buttonText: string; sections: { title: string; rows: { id: string; title: string; description?: string }[] }[] };
|
||||
};
|
||||
|
||||
// Wait for patient reply
|
||||
type InputBlock = {
|
||||
id: string;
|
||||
type: 'input';
|
||||
inputType: 'text' | 'interactive_reply' | 'any';
|
||||
variableId: string;
|
||||
validation?: { regex?: string; errorMessage?: string };
|
||||
};
|
||||
|
||||
// Branch based on variable value
|
||||
type ConditionBlock = {
|
||||
id: string;
|
||||
type: 'condition';
|
||||
conditions: {
|
||||
id: string;
|
||||
variableId: string;
|
||||
operator: 'equals' | 'contains' | 'exists' | 'not_exists' | 'gt' | 'lt' | 'starts_with';
|
||||
value?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
// Assign/transform a variable
|
||||
type SetVariableBlock = {
|
||||
id: string;
|
||||
type: 'set_variable';
|
||||
variableId: string;
|
||||
value: string;
|
||||
expression?: 'extract_id';
|
||||
};
|
||||
|
||||
// Execute a registered tool
|
||||
type ToolCallBlock = {
|
||||
id: string;
|
||||
type: 'tool_call';
|
||||
toolName: string;
|
||||
inputs: Record<string, string>; // values support {{variables}}
|
||||
outputVariableId?: string;
|
||||
};
|
||||
|
||||
// Generate dynamic LLM response
|
||||
type AIBlock = {
|
||||
id: string;
|
||||
type: 'ai';
|
||||
prompt: string; // supports {{variables}}
|
||||
outputVariableId?: string;
|
||||
sendToPatient: boolean;
|
||||
};
|
||||
|
||||
// Jump to another group
|
||||
type JumpBlock = {
|
||||
id: string;
|
||||
type: 'jump';
|
||||
targetGroupId: string;
|
||||
};
|
||||
```
|
||||
|
||||
## Session State (Redis)
|
||||
|
||||
```typescript
|
||||
type FlowSession = {
|
||||
flowId: string;
|
||||
currentGroupId: string;
|
||||
currentBlockIndex: number;
|
||||
variables: Record<string, any>;
|
||||
history: ConversationEntry[];
|
||||
startedAt: number;
|
||||
lastActiveAt: number;
|
||||
};
|
||||
```
|
||||
|
||||
Key: `wa:flow:{phone}`, TTL: 24 hours (WhatsApp session window).
|
||||
|
||||
## Execution Loop
|
||||
|
||||
```
|
||||
On inbound message:
|
||||
1. Load session from Redis (or create new → match flow by trigger)
|
||||
2. If paused at InputBlock → store reply in variable, advance
|
||||
3. Walk forward:
|
||||
- MessageBlock → send via provider, advance
|
||||
- InputBlock → save session, STOP (wait for next message)
|
||||
- ConditionBlock → evaluate, follow matching edge (or fall through)
|
||||
- SetVariableBlock → assign value, advance
|
||||
- ToolCallBlock → execute tool, store result, advance
|
||||
- AIBlock → call LLM, optionally send, advance
|
||||
- JumpBlock → jump to target group
|
||||
- End of group → follow outgoing edge to next group
|
||||
4. If no more blocks/edges → flow complete, clear session
|
||||
```
|
||||
|
||||
## Tool Registry
|
||||
|
||||
Existing tools from messaging.service.ts become registered tools:
|
||||
|
||||
| Tool Name | Description | Inputs | Output |
|
||||
|---|---|---|---|
|
||||
| resolve_caller | Phone → Lead + Patient | phone | { leadId, patientId, isNew, name } |
|
||||
| send_department_list | Send interactive department list | (none — reads from platform) | { departments[] } |
|
||||
| send_doctor_list | Send interactive doctor list | department | { doctors[] } |
|
||||
| send_slot_list | Send time slots for doctor+date | doctorId, doctorName, date | { slots[] } |
|
||||
| send_confirm_buttons | Send confirm/cancel buttons | summary | { sent: true } |
|
||||
| book_appointment | Book appointment (with conflict check) | patientName, phoneNumber, department, doctorName, scheduledAt, reason | { booked, appointmentId } |
|
||||
| lookup_appointments | Check existing appointments | patientId? | { appointments[] } |
|
||||
| create_lead | Create lead + patient | name, phoneNumber, interest | { leadId } |
|
||||
|
||||
## Example Flow: Appointment Booking
|
||||
|
||||
```
|
||||
Group: "Greeting" (g1)
|
||||
→ AIBlock: greet using patient name + context
|
||||
→ MessageBlock: buttons ["Book Appointment", "Check Appointment", "Ask a Question"]
|
||||
→ InputBlock: store in {{intent}}
|
||||
Edges: g1 → ConditionBlock routes to g2 (book) / g7 (check) / g8 (question)
|
||||
|
||||
Group: "Department Selection" (g2)
|
||||
→ ToolCallBlock: send_department_list
|
||||
→ InputBlock: store in {{selectedDepartment}}
|
||||
Edge: g2 → g3
|
||||
|
||||
Group: "Doctor Selection" (g3)
|
||||
→ ToolCallBlock: send_doctor_list, input: department={{selectedDepartment}}
|
||||
→ InputBlock: store in {{selectedDoctor}}
|
||||
→ SetVariableBlock: extract doctorId from {{selectedDoctor}}
|
||||
Edge: g3 → g4
|
||||
|
||||
Group: "Date Selection" (g4)
|
||||
→ MessageBlock: "When would you like to visit?"
|
||||
→ MessageBlock: buttons ["Tomorrow", "Day After", "Choose Date"]
|
||||
→ InputBlock: store in {{dateChoice}}
|
||||
→ ConditionBlock: tomorrow → SetVariable, day_after → SetVariable, else → AI parse
|
||||
Edge: g4 → g5
|
||||
|
||||
Group: "Slot Selection" (g5)
|
||||
→ ToolCallBlock: send_slot_list, inputs: doctorId={{doctorId}}, date={{selectedDate}}
|
||||
→ InputBlock: store in {{selectedSlot}}
|
||||
Edge: g5 → g6
|
||||
|
||||
Group: "Confirmation" (g6)
|
||||
→ MessageBlock: buttons ["Confirm", "Cancel"], summary text
|
||||
→ InputBlock: store in {{confirmation}}
|
||||
→ ConditionBlock: confirm → g7, cancel → g8
|
||||
Edges: confirm → "Booking" group, cancel → "Cancelled" group
|
||||
|
||||
Group: "Booking" (g7)
|
||||
→ ToolCallBlock: book_appointment with all collected variables
|
||||
→ MessageBlock: confirmation with reference number
|
||||
|
||||
Group: "Cancelled" (g8)
|
||||
→ MessageBlock: "No problem! Let me know if you need anything else."
|
||||
```
|
||||
|
||||
## File Structure (Implementation)
|
||||
|
||||
```
|
||||
src/messaging/
|
||||
├── flow/
|
||||
│ ├── flow-types.ts — All types above
|
||||
│ ├── flow-execution.service.ts — Main execution loop
|
||||
│ ├── flow-session.service.ts — Redis session CRUD
|
||||
│ ├── flow-store.service.ts — Load/save flow definitions (file/Redis)
|
||||
│ ├── flow-variable.service.ts — Variable interpolation + expressions
|
||||
│ ├── tool-registry.ts — Tool name → handler mapping
|
||||
│ └── default-flows/
|
||||
│ └── appointment-booking.json — Seeded default flow
|
||||
├── providers/ (existing, unchanged)
|
||||
├── messaging.module.ts — Wire new services
|
||||
├── messaging.controller.ts — Unchanged (webhook still here)
|
||||
├── messaging.service.ts — Delegates to FlowExecutionService
|
||||
└── types.ts — Existing types (unchanged)
|
||||
```
|
||||
|
||||
## Migration Path
|
||||
|
||||
1. Build FlowExecutionService alongside existing MessagingService
|
||||
2. Seed default appointment-booking.json (equivalent to current hardcoded flow)
|
||||
3. MessagingService checks: if flow config exists → delegate to FlowExecutionService, else → current AI behavior (backward compatible)
|
||||
4. Once validated, remove hardcoded AI flow from MessagingService
|
||||
|
||||
## Not in Scope
|
||||
|
||||
- Visual builder UI (future, maybe never)
|
||||
- Flow versioning/rollback (v2)
|
||||
- Flow analytics/metrics (v2)
|
||||
- Multi-flow routing (v2 — for now, one active flow per trigger type)
|
||||
@@ -3,6 +3,9 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": true,
|
||||
"assets": [
|
||||
{ "include": "messaging/flow/default-flows/*.json", "watchAssets": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { WidgetModule } from './widget/widget.module';
|
||||
import { TeamModule } from './team/team.module';
|
||||
import { MasterdataModule } from './masterdata/masterdata.module';
|
||||
import { LeadsModule } from './leads/leads.module';
|
||||
import { MessagingModule } from './messaging/messaging.module';
|
||||
import { TelephonyRegistrationService } from './telephony-registration.service';
|
||||
|
||||
@Module({
|
||||
@@ -53,6 +54,7 @@ import { TelephonyRegistrationService } from './telephony-registration.service';
|
||||
TeamModule,
|
||||
MasterdataModule,
|
||||
LeadsModule,
|
||||
MessagingModule,
|
||||
],
|
||||
providers: [TelephonyRegistrationService],
|
||||
})
|
||||
|
||||
@@ -38,4 +38,12 @@ export default () => ({
|
||||
openaiApiKey: process.env.OPENAI_API_KEY ?? '',
|
||||
model: process.env.AI_MODEL ?? 'gpt-4o-mini',
|
||||
},
|
||||
messaging: {
|
||||
provider: process.env.MESSAGING_PROVIDER ?? 'gupshup',
|
||||
gupshup: {
|
||||
apiKey: process.env.GUPSHUP_API_KEY ?? '',
|
||||
appId: process.env.GUPSHUP_APP_ID ?? '',
|
||||
sourceNumber: process.env.GUPSHUP_SOURCE_NUMBER ?? '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
328
src/messaging/flow/default-flows/appointment-booking.json
Normal file
328
src/messaging/flow/default-flows/appointment-booking.json
Normal file
@@ -0,0 +1,328 @@
|
||||
{
|
||||
"id": "flow-appointment-booking",
|
||||
"name": "Appointment Booking",
|
||||
"description": "AI-driven appointment booking via WhatsApp with interactive department, doctor, date, and slot selection.",
|
||||
"trigger": { "type": "default" },
|
||||
"version": 1,
|
||||
"status": "published",
|
||||
"variables": [
|
||||
{ "id": "v1", "name": "intent", "type": "string" },
|
||||
{ "id": "v2", "name": "selectedDepartment", "type": "string" },
|
||||
{ "id": "v3", "name": "selectedDepartmentTitle", "type": "string" },
|
||||
{ "id": "v4", "name": "selectedDoctor", "type": "string" },
|
||||
{ "id": "v5", "name": "selectedDoctorTitle", "type": "string" },
|
||||
{ "id": "v6", "name": "doctorId", "type": "string" },
|
||||
{ "id": "v7", "name": "dateChoice", "type": "string" },
|
||||
{ "id": "v8", "name": "selectedDate", "type": "string" },
|
||||
{ "id": "v9", "name": "selectedSlot", "type": "string" },
|
||||
{ "id": "v10", "name": "confirmation", "type": "string" },
|
||||
{ "id": "v11", "name": "bookingResult", "type": "object" },
|
||||
{ "id": "v12", "name": "deptListResult", "type": "object" },
|
||||
{ "id": "v13", "name": "docListResult", "type": "object" },
|
||||
{ "id": "v14", "name": "slotListResult", "type": "object" },
|
||||
{ "id": "v15", "name": "aiGreeting", "type": "string" },
|
||||
{ "id": "v16", "name": "reason", "type": "string" },
|
||||
{ "id": "v17", "name": "scheduledDateTime", "type": "string" }
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": "g1",
|
||||
"title": "Greeting",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "b1",
|
||||
"type": "ai",
|
||||
"prompt": "Greet the patient {{_senderName}} warmly in 1-2 sentences. They messaged: \"{{_initialMessage}}\". You are a WhatsApp assistant for Ramaiah Hospital. Be concise, no markdown.",
|
||||
"outputVariableId": "aiGreeting",
|
||||
"sendToPatient": true
|
||||
},
|
||||
{
|
||||
"id": "b2",
|
||||
"type": "message",
|
||||
"content": {
|
||||
"format": "buttons",
|
||||
"text": "How can I help you today?",
|
||||
"buttons": [
|
||||
{ "id": "intent:book", "title": "Book Appointment" },
|
||||
{ "id": "intent:check", "title": "Check Appointment" },
|
||||
{ "id": "intent:question", "title": "Ask a Question" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b3",
|
||||
"type": "input",
|
||||
"inputType": "any",
|
||||
"variableId": "intent"
|
||||
},
|
||||
{
|
||||
"id": "b4",
|
||||
"type": "condition",
|
||||
"conditions": [
|
||||
{ "id": "c1", "variableId": "intent", "operator": "contains", "value": "book" },
|
||||
{ "id": "c2", "variableId": "intent", "operator": "contains", "value": "check" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g2",
|
||||
"title": "Department Selection",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "b5",
|
||||
"type": "tool_call",
|
||||
"toolName": "send_department_list",
|
||||
"inputs": {},
|
||||
"outputVariableId": "deptListResult"
|
||||
},
|
||||
{
|
||||
"id": "b6",
|
||||
"type": "input",
|
||||
"inputType": "any",
|
||||
"variableId": "selectedDepartment"
|
||||
},
|
||||
{
|
||||
"id": "b7",
|
||||
"type": "set_variable",
|
||||
"variableId": "selectedDepartmentTitle",
|
||||
"value": "selectedDepartment",
|
||||
"expression": "extract_id"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g3",
|
||||
"title": "Doctor Selection",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "b8",
|
||||
"type": "tool_call",
|
||||
"toolName": "send_doctor_list",
|
||||
"inputs": { "department": "{{selectedDepartmentTitle}}" },
|
||||
"outputVariableId": "docListResult"
|
||||
},
|
||||
{
|
||||
"id": "b9",
|
||||
"type": "input",
|
||||
"inputType": "any",
|
||||
"variableId": "selectedDoctor"
|
||||
},
|
||||
{
|
||||
"id": "b10",
|
||||
"type": "set_variable",
|
||||
"variableId": "doctorId",
|
||||
"value": "selectedDoctor",
|
||||
"expression": "extract_id"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g4",
|
||||
"title": "Date Selection",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "b11",
|
||||
"type": "message",
|
||||
"content": {
|
||||
"format": "buttons",
|
||||
"text": "When would you like to visit?",
|
||||
"buttons": [
|
||||
{ "id": "date:tomorrow", "title": "Tomorrow" },
|
||||
{ "id": "date:day_after", "title": "Day After Tomorrow" },
|
||||
{ "id": "date:other", "title": "Choose Another Date" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b12",
|
||||
"type": "input",
|
||||
"inputType": "any",
|
||||
"variableId": "dateChoice"
|
||||
},
|
||||
{
|
||||
"id": "b13",
|
||||
"type": "condition",
|
||||
"conditions": [
|
||||
{ "id": "c3", "variableId": "dateChoice", "operator": "contains", "value": "tomorrow" },
|
||||
{ "id": "c4", "variableId": "dateChoice", "operator": "contains", "value": "day_after" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b14",
|
||||
"type": "set_variable",
|
||||
"variableId": "selectedDate",
|
||||
"value": "",
|
||||
"expression": "date_tomorrow"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g4a",
|
||||
"title": "Date - Day After",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "b15",
|
||||
"type": "set_variable",
|
||||
"variableId": "selectedDate",
|
||||
"value": "",
|
||||
"expression": "date_day_after"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g5",
|
||||
"title": "Slot Selection",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "b16",
|
||||
"type": "tool_call",
|
||||
"toolName": "send_slot_list",
|
||||
"inputs": {
|
||||
"doctorId": "{{doctorId}}",
|
||||
"doctorName": "{{selectedDoctor_title}}",
|
||||
"date": "{{selectedDate}}"
|
||||
},
|
||||
"outputVariableId": "slotListResult"
|
||||
},
|
||||
{
|
||||
"id": "b17",
|
||||
"type": "input",
|
||||
"inputType": "any",
|
||||
"variableId": "selectedSlot"
|
||||
},
|
||||
{
|
||||
"id": "b17a",
|
||||
"type": "set_variable",
|
||||
"variableId": "scheduledDateTime",
|
||||
"value": "selectedSlot",
|
||||
"expression": "extract_datetime"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g6",
|
||||
"title": "Reason",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "b18",
|
||||
"type": "message",
|
||||
"content": {
|
||||
"format": "text",
|
||||
"text": "What is the reason for your visit? (e.g., General Consultation, Follow-up, etc.)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b19",
|
||||
"type": "input",
|
||||
"inputType": "text",
|
||||
"variableId": "reason"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g7",
|
||||
"title": "Confirmation",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "b20",
|
||||
"type": "tool_call",
|
||||
"toolName": "send_confirm_buttons",
|
||||
"inputs": {
|
||||
"summary": "Appointment Summary:\nDoctor: {{selectedDoctor_title}}\nDate: {{selectedDate}}\nTime: {{selectedSlot_title}}\nReason: {{reason}}\n\nShall I confirm this booking?"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b21",
|
||||
"type": "input",
|
||||
"inputType": "any",
|
||||
"variableId": "confirmation"
|
||||
},
|
||||
{
|
||||
"id": "b22",
|
||||
"type": "condition",
|
||||
"conditions": [
|
||||
{ "id": "c5", "variableId": "confirmation", "operator": "contains", "value": "confirm" },
|
||||
{ "id": "c6", "variableId": "confirmation", "operator": "contains", "value": "cancel" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g8",
|
||||
"title": "Booking",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "b23",
|
||||
"type": "tool_call",
|
||||
"toolName": "book_appointment",
|
||||
"inputs": {
|
||||
"patientName": "{{_senderName}}",
|
||||
"phoneNumber": "{{_phone}}",
|
||||
"department": "{{selectedDepartmentTitle}}",
|
||||
"doctorName": "{{selectedDoctor_title}}",
|
||||
"scheduledAt": "{{scheduledDateTime}}",
|
||||
"reason": "{{reason}}"
|
||||
},
|
||||
"outputVariableId": "bookingResult"
|
||||
},
|
||||
{
|
||||
"id": "b24",
|
||||
"type": "message",
|
||||
"content": {
|
||||
"format": "text",
|
||||
"text": "Your appointment is confirmed!\n\nDoctor: {{selectedDoctor_title}}\nDate: {{selectedDate}}\nTime: {{selectedSlot_title}}\nReason: {{reason}}\n\nThank you for choosing Ramaiah Hospital. See you soon!"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g9",
|
||||
"title": "Cancelled",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "b25",
|
||||
"type": "message",
|
||||
"content": {
|
||||
"format": "text",
|
||||
"text": "No problem! Your booking has been cancelled. Feel free to message us again whenever you'd like to book an appointment."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g10",
|
||||
"title": "Check Appointments",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "b26",
|
||||
"type": "tool_call",
|
||||
"toolName": "lookup_appointments",
|
||||
"inputs": {},
|
||||
"outputVariableId": "existingAppts"
|
||||
},
|
||||
{
|
||||
"id": "b27",
|
||||
"type": "ai",
|
||||
"prompt": "The patient {{_senderName}} asked to check their appointments. Here are their appointments: {{existingAppts}}. Summarize them in a friendly WhatsApp message. If no appointments, say they have none and offer to book one. Be concise, no markdown.",
|
||||
"outputVariableId": "apptSummary",
|
||||
"sendToPatient": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "id": "e1", "from": { "blockId": "b4", "conditionId": "c1" }, "to": { "groupId": "g2" } },
|
||||
{ "id": "e2", "from": { "blockId": "b4", "conditionId": "c2" }, "to": { "groupId": "g10" } },
|
||||
{ "id": "e3", "from": { "blockId": "b7" }, "to": { "groupId": "g3" } },
|
||||
{ "id": "e4", "from": { "blockId": "b10" }, "to": { "groupId": "g4" } },
|
||||
{ "id": "e5", "from": { "blockId": "b13", "conditionId": "c3" }, "to": { "groupId": "g5" } },
|
||||
{ "id": "e6", "from": { "blockId": "b13", "conditionId": "c4" }, "to": { "groupId": "g4a" } },
|
||||
{ "id": "e7", "from": { "blockId": "b14" }, "to": { "groupId": "g5" } },
|
||||
{ "id": "e8", "from": { "blockId": "b15" }, "to": { "groupId": "g5" } },
|
||||
{ "id": "e9", "from": { "blockId": "b17a" }, "to": { "groupId": "g6" } },
|
||||
{ "id": "e10", "from": { "blockId": "b19" }, "to": { "groupId": "g7" } },
|
||||
{ "id": "e11", "from": { "blockId": "b22", "conditionId": "c5" }, "to": { "groupId": "g8" } },
|
||||
{ "id": "e12", "from": { "blockId": "b22", "conditionId": "c6" }, "to": { "groupId": "g9" } }
|
||||
]
|
||||
}
|
||||
325
src/messaging/flow/flow-execution.service.ts
Normal file
325
src/messaging/flow/flow-execution.service.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { generateText, stepCountIs } from 'ai';
|
||||
import { createAiModel } from '../../ai/ai-provider';
|
||||
import { AiConfigService } from '../../config/ai-config.service';
|
||||
import { CallerResolutionService } from '../../caller/caller-resolution.service';
|
||||
import { CallerContextService } from '../../caller/caller-context.service';
|
||||
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||
import { MessagingProvider } from '../providers/messaging-provider.interface';
|
||||
import { FlowSessionService } from './flow-session.service';
|
||||
import { FlowStoreService } from './flow-store.service';
|
||||
import { FlowVariableService } from './flow-variable.service';
|
||||
import { ToolRegistry } from './tool-registry';
|
||||
import type { Flow, FlowSession, Group, Block, ConditionBlock, ToolContext } from './flow-types';
|
||||
import type { NormalizedMessage } from '../types';
|
||||
import type { LanguageModel } from 'ai';
|
||||
|
||||
@Injectable()
|
||||
export class FlowExecutionService {
|
||||
private readonly logger = new Logger(FlowExecutionService.name);
|
||||
private readonly aiModel: LanguageModel | null;
|
||||
private readonly auth: string;
|
||||
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private provider: MessagingProvider,
|
||||
private sessions: FlowSessionService,
|
||||
private store: FlowStoreService,
|
||||
private variables: FlowVariableService,
|
||||
private tools: ToolRegistry,
|
||||
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}` : '';
|
||||
}
|
||||
|
||||
async handleMessage(message: NormalizedMessage): Promise<void> {
|
||||
const { phone } = message;
|
||||
|
||||
// 1. Load existing session or start new flow
|
||||
let session = await this.sessions.load(phone);
|
||||
let flow: Flow | null = null;
|
||||
|
||||
if (session) {
|
||||
flow = this.store.getById(session.flowId);
|
||||
if (!flow) {
|
||||
this.logger.warn(`[FLOW] Flow ${session.flowId} not found — clearing session`);
|
||||
await this.sessions.clear(phone);
|
||||
session = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
flow = this.store.matchFlow(message.text);
|
||||
if (!flow) {
|
||||
this.logger.log(`[FLOW] No matching flow for: ${message.text.substring(0, 50)}`);
|
||||
await this.provider.sendText(phone, 'Sorry, I didn\'t understand. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize session
|
||||
const firstGroup = flow.groups[0];
|
||||
if (!firstGroup) {
|
||||
this.logger.error(`[FLOW] Flow ${flow.id} has no groups`);
|
||||
return;
|
||||
}
|
||||
|
||||
session = {
|
||||
flowId: flow.id,
|
||||
currentGroupId: firstGroup.id,
|
||||
currentBlockIndex: 0,
|
||||
variables: this.initializeVariables(flow, message),
|
||||
startedAt: Date.now(),
|
||||
lastActiveAt: Date.now(),
|
||||
};
|
||||
|
||||
// Resolve caller and inject context variables
|
||||
const resolved = await this.caller.resolve(phone, this.auth).catch(() => null);
|
||||
if (resolved) {
|
||||
session.variables['_callerName'] = `${resolved.firstName} ${resolved.lastName}`.trim();
|
||||
session.variables['_leadId'] = resolved.leadId;
|
||||
session.variables['_patientId'] = resolved.patientId;
|
||||
session.variables['_isNew'] = resolved.isNew;
|
||||
session.variables['_phone'] = phone;
|
||||
}
|
||||
|
||||
this.logger.log(`[FLOW] Started flow "${flow.name}" for ${phone}`);
|
||||
}
|
||||
|
||||
// 2. If paused at an InputBlock, process the reply
|
||||
const currentGroup = flow!.groups.find(g => g.id === session!.currentGroupId);
|
||||
if (currentGroup) {
|
||||
const currentBlock = currentGroup.blocks[session!.currentBlockIndex];
|
||||
if (currentBlock?.type === 'input') {
|
||||
const value = message.interactiveReply?.id ?? message.text;
|
||||
session!.variables[currentBlock.variableId] = value;
|
||||
|
||||
// Also store the display title for interactive replies
|
||||
if (message.interactiveReply?.title) {
|
||||
session!.variables[currentBlock.variableId + '_title'] = message.interactiveReply.title;
|
||||
}
|
||||
|
||||
this.logger.log(`[FLOW] Input received: ${currentBlock.variableId}=${value}`);
|
||||
session!.currentBlockIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Walk forward
|
||||
await this.walkForward(phone, session!, flow!);
|
||||
}
|
||||
|
||||
private async walkForward(phone: string, session: FlowSession, flow: Flow): Promise<void> {
|
||||
let iterations = 0;
|
||||
const maxIterations = 50; // safety valve
|
||||
|
||||
while (iterations++ < maxIterations) {
|
||||
const group = flow.groups.find(g => g.id === session.currentGroupId);
|
||||
if (!group) {
|
||||
this.logger.log(`[FLOW] Group ${session.currentGroupId} not found — flow complete`);
|
||||
await this.sessions.clear(phone);
|
||||
return;
|
||||
}
|
||||
|
||||
// End of group — follow outgoing edge
|
||||
if (session.currentBlockIndex >= group.blocks.length) {
|
||||
const edge = this.findGroupEdge(flow, group);
|
||||
if (!edge) {
|
||||
this.logger.log(`[FLOW] No outgoing edge from group "${group.title}" — flow complete`);
|
||||
await this.sessions.clear(phone);
|
||||
return;
|
||||
}
|
||||
session.currentGroupId = edge.to.groupId;
|
||||
session.currentBlockIndex = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
const block = group.blocks[session.currentBlockIndex];
|
||||
this.logger.log(`[FLOW] Executing block ${block.id} (${block.type}) in group "${group.title}"`);
|
||||
|
||||
const shouldStop = await this.executeBlock(block, phone, session, flow);
|
||||
if (shouldStop) {
|
||||
await this.sessions.save(phone, session);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.error(`[FLOW] Max iterations reached for ${phone} — possible infinite loop`);
|
||||
await this.sessions.clear(phone);
|
||||
}
|
||||
|
||||
// Returns true if execution should pause (InputBlock)
|
||||
private async executeBlock(block: Block, phone: string, session: FlowSession, flow: Flow): Promise<boolean> {
|
||||
const ctx: ToolContext = {
|
||||
phone,
|
||||
session,
|
||||
provider: this.provider,
|
||||
platform: this.platform,
|
||||
auth: this.auth,
|
||||
};
|
||||
|
||||
switch (block.type) {
|
||||
case 'message': {
|
||||
const content = block.content;
|
||||
if (content.format === 'text') {
|
||||
const text = this.variables.interpolate(content.text, session.variables);
|
||||
await this.provider.sendText(phone, text);
|
||||
} else if (content.format === 'buttons') {
|
||||
const text = this.variables.interpolate(content.text, session.variables);
|
||||
await this.provider.sendButtons(phone, text, content.buttons);
|
||||
} else if (content.format === 'list') {
|
||||
const text = this.variables.interpolate(content.text, session.variables);
|
||||
await this.provider.sendList(phone, text, content.buttonText, content.sections);
|
||||
}
|
||||
session.currentBlockIndex++;
|
||||
return false;
|
||||
}
|
||||
|
||||
case 'input': {
|
||||
// Pause — wait for next message
|
||||
this.logger.log(`[FLOW] Waiting for input → ${block.variableId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'condition': {
|
||||
const matched = this.evaluateConditions(block, session);
|
||||
if (matched) {
|
||||
const edge = flow.edges.find(e =>
|
||||
e.from.blockId === block.id && e.from.conditionId === matched.id,
|
||||
);
|
||||
if (edge) {
|
||||
session.currentGroupId = edge.to.groupId;
|
||||
session.currentBlockIndex = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// No match — fall through to next block
|
||||
session.currentBlockIndex++;
|
||||
return false;
|
||||
}
|
||||
|
||||
case 'set_variable': {
|
||||
if (block.expression) {
|
||||
const rawValue = session.variables[block.value] ?? block.value;
|
||||
session.variables[block.variableId] = this.variables.evaluateExpression(
|
||||
block.expression, String(rawValue), session.variables,
|
||||
);
|
||||
} else {
|
||||
session.variables[block.variableId] = this.variables.interpolate(block.value, session.variables);
|
||||
}
|
||||
this.logger.log(`[FLOW] Set ${block.variableId}=${session.variables[block.variableId]}`);
|
||||
session.currentBlockIndex++;
|
||||
return false;
|
||||
}
|
||||
|
||||
case 'tool_call': {
|
||||
const inputs = this.variables.interpolateObject(block.inputs, session.variables);
|
||||
const result = await this.tools.execute(block.toolName, inputs, ctx);
|
||||
if (block.outputVariableId) {
|
||||
session.variables[block.outputVariableId] = result;
|
||||
}
|
||||
session.currentBlockIndex++;
|
||||
return false;
|
||||
}
|
||||
|
||||
case 'ai': {
|
||||
if (!this.aiModel) {
|
||||
session.currentBlockIndex++;
|
||||
return false;
|
||||
}
|
||||
const prompt = this.variables.interpolate(block.prompt, session.variables);
|
||||
try {
|
||||
const result = await generateText({
|
||||
model: this.aiModel,
|
||||
prompt,
|
||||
stopWhen: stepCountIs(1),
|
||||
});
|
||||
const text = result.text?.trim() ?? '';
|
||||
if (block.outputVariableId) {
|
||||
session.variables[block.outputVariableId] = text;
|
||||
}
|
||||
if (block.sendToPatient && text) {
|
||||
await this.provider.sendText(phone, text);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[FLOW] AI block failed: ${err.message}`);
|
||||
}
|
||||
session.currentBlockIndex++;
|
||||
return false;
|
||||
}
|
||||
|
||||
case 'jump': {
|
||||
session.currentGroupId = block.targetGroupId;
|
||||
session.currentBlockIndex = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
default:
|
||||
this.logger.warn(`[FLOW] Unknown block type: ${(block as any).type}`);
|
||||
session.currentBlockIndex++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateConditions(block: ConditionBlock, session: FlowSession) {
|
||||
for (const cond of block.conditions) {
|
||||
const value = session.variables[cond.variableId];
|
||||
const target = cond.value ? this.variables.interpolate(cond.value, session.variables) : undefined;
|
||||
|
||||
let match = false;
|
||||
switch (cond.operator) {
|
||||
case 'equals': match = String(value) === target; break;
|
||||
case 'contains': match = String(value ?? '').toLowerCase().includes((target ?? '').toLowerCase()); break;
|
||||
case 'exists': match = value !== undefined && value !== null && value !== ''; break;
|
||||
case 'not_exists': match = value === undefined || value === null || value === ''; break;
|
||||
case 'starts_with': match = String(value ?? '').startsWith(target ?? ''); break;
|
||||
case 'gt': match = Number(value) > Number(target); break;
|
||||
case 'lt': match = Number(value) < Number(target); break;
|
||||
}
|
||||
|
||||
if (match) return cond;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private findGroupEdge(flow: Flow, group: Group) {
|
||||
// Find edge from the last block in the group (default outgoing)
|
||||
const lastBlock = group.blocks[group.blocks.length - 1];
|
||||
if (lastBlock) {
|
||||
const edge = flow.edges.find(e => e.from.blockId === lastBlock.id && !e.from.conditionId);
|
||||
if (edge) return edge;
|
||||
}
|
||||
// Fallback: any edge from any block in this group without conditionId
|
||||
for (const block of group.blocks) {
|
||||
const edge = flow.edges.find(e => e.from.blockId === block.id && !e.from.conditionId);
|
||||
if (edge) return edge;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private initializeVariables(flow: Flow, message: NormalizedMessage): Record<string, any> {
|
||||
const vars: Record<string, any> = {};
|
||||
for (const v of flow.variables) {
|
||||
vars[v.name] = v.defaultValue ?? null;
|
||||
}
|
||||
// Inject message context
|
||||
vars['_initialMessage'] = message.text;
|
||||
vars['_senderName'] = message.name;
|
||||
return vars;
|
||||
}
|
||||
|
||||
// Check if flow engine has any published flows
|
||||
hasFlows(): boolean {
|
||||
return this.store.getAll().some(f => f.status === 'published');
|
||||
}
|
||||
}
|
||||
39
src/messaging/flow/flow-session.service.ts
Normal file
39
src/messaging/flow/flow-session.service.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
import type { FlowSession } from './flow-types';
|
||||
|
||||
@Injectable()
|
||||
export class FlowSessionService {
|
||||
private readonly logger = new Logger(FlowSessionService.name);
|
||||
private readonly redis: Redis;
|
||||
private readonly ttlSec = 24 * 60 * 60; // 24h
|
||||
|
||||
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:flow:${phone}`;
|
||||
}
|
||||
|
||||
async load(phone: string): Promise<FlowSession | null> {
|
||||
const raw = await this.redis.get(this.key(phone));
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async save(phone: string, session: FlowSession): Promise<void> {
|
||||
session.lastActiveAt = Date.now();
|
||||
await this.redis.setex(this.key(phone), this.ttlSec, JSON.stringify(session));
|
||||
}
|
||||
|
||||
async clear(phone: string): Promise<void> {
|
||||
await this.redis.del(this.key(phone));
|
||||
}
|
||||
}
|
||||
102
src/messaging/flow/flow-store.service.ts
Normal file
102
src/messaging/flow/flow-store.service.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { Flow } from './flow-types';
|
||||
|
||||
const FLOWS_DIR = join(process.cwd(), 'data', 'flows');
|
||||
const DEFAULTS_DIR = join(__dirname, 'default-flows');
|
||||
|
||||
@Injectable()
|
||||
export class FlowStoreService implements OnModuleInit {
|
||||
private readonly logger = new Logger(FlowStoreService.name);
|
||||
private flows: Map<string, Flow> = new Map();
|
||||
|
||||
onModuleInit() {
|
||||
this.ensureDirectory();
|
||||
this.seedDefaults();
|
||||
this.loadAll();
|
||||
}
|
||||
|
||||
private ensureDirectory() {
|
||||
const { mkdirSync } = require('fs');
|
||||
if (!existsSync(FLOWS_DIR)) {
|
||||
mkdirSync(FLOWS_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
private seedDefaults() {
|
||||
// Copy default flows if data/flows/ is empty
|
||||
if (!existsSync(DEFAULTS_DIR)) return;
|
||||
const existing = readdirSync(FLOWS_DIR).filter(f => f.endsWith('.json'));
|
||||
if (existing.length > 0) return;
|
||||
|
||||
const defaults = readdirSync(DEFAULTS_DIR).filter(f => f.endsWith('.json'));
|
||||
for (const file of defaults) {
|
||||
const src = join(DEFAULTS_DIR, file);
|
||||
const dest = join(FLOWS_DIR, file);
|
||||
const content = readFileSync(src, 'utf-8');
|
||||
writeFileSync(dest, content);
|
||||
this.logger.log(`[FLOW-STORE] Seeded default flow: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
private loadAll() {
|
||||
this.flows.clear();
|
||||
const files = readdirSync(FLOWS_DIR).filter(f => f.endsWith('.json'));
|
||||
for (const file of files) {
|
||||
try {
|
||||
const raw = readFileSync(join(FLOWS_DIR, file), 'utf-8');
|
||||
const flow: Flow = JSON.parse(raw);
|
||||
this.flows.set(flow.id, flow);
|
||||
this.logger.log(`[FLOW-STORE] Loaded flow: ${flow.name} (${flow.id}) status=${flow.status}`);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[FLOW-STORE] Failed to load ${file}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
this.logger.log(`[FLOW-STORE] ${this.flows.size} flow(s) loaded`);
|
||||
}
|
||||
|
||||
getById(id: string): Flow | null {
|
||||
return this.flows.get(id) ?? null;
|
||||
}
|
||||
|
||||
// Match inbound message to a published flow by trigger
|
||||
matchFlow(messageText: string): Flow | null {
|
||||
let defaultFlow: Flow | null = null;
|
||||
|
||||
for (const flow of this.flows.values()) {
|
||||
if (flow.status !== 'published') continue;
|
||||
|
||||
if (flow.trigger.type === 'default') {
|
||||
defaultFlow = flow;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (flow.trigger.type === 'message' && flow.trigger.conditions) {
|
||||
const { keywords, regex } = flow.trigger.conditions;
|
||||
const lower = messageText.toLowerCase();
|
||||
|
||||
if (keywords?.some(k => lower.includes(k.toLowerCase()))) {
|
||||
return flow;
|
||||
}
|
||||
if (regex && new RegExp(regex, 'i').test(messageText)) {
|
||||
return flow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return defaultFlow;
|
||||
}
|
||||
|
||||
// CRUD for admin API (future)
|
||||
getAll(): Flow[] {
|
||||
return Array.from(this.flows.values());
|
||||
}
|
||||
|
||||
save(flow: Flow): void {
|
||||
this.flows.set(flow.id, flow);
|
||||
const file = join(FLOWS_DIR, `${flow.id}.json`);
|
||||
writeFileSync(file, JSON.stringify(flow, null, 2));
|
||||
this.logger.log(`[FLOW-STORE] Saved flow: ${flow.name} (${flow.id})`);
|
||||
}
|
||||
}
|
||||
133
src/messaging/flow/flow-types.ts
Normal file
133
src/messaging/flow/flow-types.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// ── Flow Definition ──
|
||||
|
||||
export type Flow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
trigger: FlowTrigger;
|
||||
groups: Group[];
|
||||
edges: Edge[];
|
||||
variables: VariableDefinition[];
|
||||
version: number;
|
||||
status: 'draft' | 'published';
|
||||
};
|
||||
|
||||
export type FlowTrigger =
|
||||
| { type: 'message'; conditions?: { keywords?: string[]; regex?: string } }
|
||||
| { type: 'default' };
|
||||
|
||||
export type VariableDefinition = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||
defaultValue?: any;
|
||||
};
|
||||
|
||||
// ── Groups & Edges ──
|
||||
|
||||
export type Group = {
|
||||
id: string;
|
||||
title: string;
|
||||
blocks: Block[];
|
||||
};
|
||||
|
||||
export type Edge = {
|
||||
id: string;
|
||||
from: { blockId: string; conditionId?: string };
|
||||
to: { groupId: string; blockId?: string };
|
||||
};
|
||||
|
||||
// ── Blocks ──
|
||||
|
||||
export type Block =
|
||||
| MessageBlock
|
||||
| InputBlock
|
||||
| ConditionBlock
|
||||
| SetVariableBlock
|
||||
| ToolCallBlock
|
||||
| AIBlock
|
||||
| JumpBlock;
|
||||
|
||||
export type MessageBlock = {
|
||||
id: string;
|
||||
type: 'message';
|
||||
content:
|
||||
| { format: 'text'; text: string }
|
||||
| { format: 'buttons'; text: string; buttons: { id: string; title: string }[] }
|
||||
| { format: 'list'; text: string; buttonText: string; sections: { title: string; rows: { id: string; title: string; description?: string }[] }[] };
|
||||
};
|
||||
|
||||
export type InputBlock = {
|
||||
id: string;
|
||||
type: 'input';
|
||||
inputType: 'text' | 'interactive_reply' | 'any';
|
||||
variableId: string;
|
||||
validation?: { regex?: string; errorMessage?: string };
|
||||
};
|
||||
|
||||
export type ConditionBlock = {
|
||||
id: string;
|
||||
type: 'condition';
|
||||
conditions: {
|
||||
id: string;
|
||||
variableId: string;
|
||||
operator: 'equals' | 'contains' | 'exists' | 'not_exists' | 'gt' | 'lt' | 'starts_with';
|
||||
value?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type SetVariableBlock = {
|
||||
id: string;
|
||||
type: 'set_variable';
|
||||
variableId: string;
|
||||
value: string;
|
||||
expression?: 'extract_id' | 'extract_datetime' | 'date_tomorrow' | 'date_day_after';
|
||||
};
|
||||
|
||||
export type ToolCallBlock = {
|
||||
id: string;
|
||||
type: 'tool_call';
|
||||
toolName: string;
|
||||
inputs: Record<string, string>;
|
||||
outputVariableId?: string;
|
||||
};
|
||||
|
||||
export type AIBlock = {
|
||||
id: string;
|
||||
type: 'ai';
|
||||
prompt: string;
|
||||
outputVariableId?: string;
|
||||
sendToPatient: boolean;
|
||||
};
|
||||
|
||||
export type JumpBlock = {
|
||||
id: string;
|
||||
type: 'jump';
|
||||
targetGroupId: string;
|
||||
};
|
||||
|
||||
// ── Session State ──
|
||||
|
||||
export type FlowSession = {
|
||||
flowId: string;
|
||||
currentGroupId: string;
|
||||
currentBlockIndex: number;
|
||||
variables: Record<string, any>;
|
||||
startedAt: number;
|
||||
lastActiveAt: number;
|
||||
};
|
||||
|
||||
// ── Tool Registry ──
|
||||
|
||||
export type ToolHandler = (
|
||||
inputs: Record<string, any>,
|
||||
context: ToolContext,
|
||||
) => Promise<any>;
|
||||
|
||||
export type ToolContext = {
|
||||
phone: string;
|
||||
session: FlowSession;
|
||||
provider: import('../providers/messaging-provider.interface').MessagingProvider;
|
||||
platform: import('../../platform/platform-graphql.service').PlatformGraphqlService;
|
||||
auth: string;
|
||||
};
|
||||
50
src/messaging/flow/flow-variable.service.ts
Normal file
50
src/messaging/flow/flow-variable.service.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class FlowVariableService {
|
||||
// Replace {{variableName}} with values from session variables
|
||||
interpolate(template: string, variables: Record<string, any>): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, name) => {
|
||||
const value = variables[name];
|
||||
if (value === undefined || value === null) return match; // keep placeholder if unresolved
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
// Interpolate all string values in an object
|
||||
interpolateObject(obj: Record<string, string>, variables: Record<string, any>): Record<string, any> {
|
||||
const result: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
result[key] = this.interpolate(value, variables);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Execute expressions for SetVariableBlock
|
||||
evaluateExpression(expression: string, value: string, variables: Record<string, any>): any {
|
||||
switch (expression) {
|
||||
case 'extract_id': {
|
||||
// Extract second segment: "doc:{uuid}:{name}" → uuid, "dept:{name}" → name
|
||||
const parts = value.split(':');
|
||||
return parts.length >= 2 ? parts[1] : value;
|
||||
}
|
||||
case 'extract_datetime': {
|
||||
// Extract datetime from "slot:{doctorId}:{datetime}" → "2026-04-21T14:00:00"
|
||||
const parts = value.split(':');
|
||||
// Rejoin from index 2 onwards (datetime contains colons: 2026-04-21T14:00:00)
|
||||
return parts.length >= 3 ? parts.slice(2).join(':') : value;
|
||||
}
|
||||
case 'date_tomorrow': {
|
||||
const d = new Date(Date.now() + 86400000);
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
case 'date_day_after': {
|
||||
const d = new Date(Date.now() + 2 * 86400000);
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
default:
|
||||
return this.interpolate(value, variables);
|
||||
}
|
||||
}
|
||||
}
|
||||
219
src/messaging/flow/tool-registry.ts
Normal file
219
src/messaging/flow/tool-registry.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||
import { CallerResolutionService } from '../../caller/caller-resolution.service';
|
||||
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../../shared/doctor-utils';
|
||||
import type { ToolHandler, ToolContext } from './flow-types';
|
||||
import type { ListSection, InteractiveButton } from '../types';
|
||||
|
||||
@Injectable()
|
||||
export class ToolRegistry {
|
||||
private readonly logger = new Logger(ToolRegistry.name);
|
||||
private readonly tools: Map<string, ToolHandler> = new Map();
|
||||
|
||||
constructor(
|
||||
private platform: PlatformGraphqlService,
|
||||
private caller: CallerResolutionService,
|
||||
) {
|
||||
this.registerDefaults();
|
||||
}
|
||||
|
||||
register(name: string, handler: ToolHandler) {
|
||||
this.tools.set(name, handler);
|
||||
}
|
||||
|
||||
async execute(name: string, inputs: Record<string, any>, context: ToolContext): Promise<any> {
|
||||
const handler = this.tools.get(name);
|
||||
if (!handler) {
|
||||
this.logger.error(`[TOOL] Unknown tool: ${name}`);
|
||||
return { error: `Unknown tool: ${name}` };
|
||||
}
|
||||
this.logger.log(`[TOOL] ${name} inputs=${JSON.stringify(inputs).substring(0, 200)}`);
|
||||
const result = await handler(inputs, context);
|
||||
this.logger.log(`[TOOL] ${name} result=${JSON.stringify(result).substring(0, 200)}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
private registerDefaults() {
|
||||
this.register('resolve_caller', async (inputs, ctx) => {
|
||||
const phone = inputs.phone ?? ctx.phone;
|
||||
const resolved = await this.caller.resolve(phone, ctx.auth).catch(() => null);
|
||||
return resolved ?? { isNew: true, leadId: '', patientId: '', phone };
|
||||
});
|
||||
|
||||
this.register('send_department_list', async (_inputs, ctx) => {
|
||||
const data = await this.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 ctx.provider.sendList(ctx.phone, 'Which department would you like to visit?', 'View Departments', sections);
|
||||
return { sent: true, departments };
|
||||
});
|
||||
|
||||
this.register('send_doctor_list', async (inputs, ctx) => {
|
||||
const department = inputs.department;
|
||||
const data = await this.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.substring(0, 24),
|
||||
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 ctx.provider.sendList(ctx.phone, `Doctors in ${department}:`, 'View Doctors', sections);
|
||||
return { sent: true, count: deptDocs.length };
|
||||
});
|
||||
|
||||
this.register('send_slot_list', async (inputs, ctx) => {
|
||||
const { doctorId, doctorName, date } = inputs;
|
||||
const targetDate = date ?? new Date(Date.now() + 86400000).toISOString().split('T')[0];
|
||||
const dayNames = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
|
||||
const targetDay = dayNames[new Date(targetDate + 'T00:00:00+05:30').getDay()];
|
||||
|
||||
const data = await this.platform.query<any>(
|
||||
`{ doctors(first: 50) { edges { node {
|
||||
id fullName { firstName lastName }
|
||||
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||
} } } }`,
|
||||
);
|
||||
const rawDocs = data.doctors.edges.map((e: any) => e.node);
|
||||
const doctor = rawDocs.find((d: any) => d.id === doctorId);
|
||||
if (!doctor) return { sent: false, message: 'Doctor not found.' };
|
||||
|
||||
const rawSlots = doctor.visitSlots?.edges?.map((e: any) => e.node) ?? [];
|
||||
const daySlots = rawSlots.filter((s: any) => s.dayOfWeek === targetDay);
|
||||
|
||||
if (!daySlots.length) {
|
||||
const dayLabel = targetDay.charAt(0) + targetDay.slice(1).toLowerCase();
|
||||
return { sent: false, message: `${doctorName} is not available on ${dayLabel} (${targetDate}).` };
|
||||
}
|
||||
|
||||
const timeSlots: { time: string; clinic: string }[] = [];
|
||||
for (const ds of daySlots) {
|
||||
const startHour = parseInt(ds.startTime?.split(':')[0] ?? '9', 10);
|
||||
const endHour = parseInt(ds.endTime?.split(':')[0] ?? '17', 10);
|
||||
const clinicName = ds.clinic?.clinicName ?? '';
|
||||
for (let h = startHour; h < endHour && timeSlots.length < 10; h++) {
|
||||
timeSlots.push({ time: `${String(h).padStart(2, '0')}:00`, clinic: clinicName });
|
||||
}
|
||||
}
|
||||
|
||||
if (!timeSlots.length) return { sent: false, message: `No slots for ${doctorName} on ${targetDate}.` };
|
||||
|
||||
const sections: ListSection[] = [{
|
||||
title: targetDate,
|
||||
rows: timeSlots.map(s => ({
|
||||
id: `slot:${doctorId}:${targetDate}T${s.time}:00`,
|
||||
title: s.time,
|
||||
description: s.clinic || undefined,
|
||||
})),
|
||||
}];
|
||||
await ctx.provider.sendList(ctx.phone, `Available slots for ${doctorName}:`, 'View Slots', sections);
|
||||
return { sent: true, slots: timeSlots.length };
|
||||
});
|
||||
|
||||
this.register('send_confirm_buttons', async (inputs, ctx) => {
|
||||
const buttons: InteractiveButton[] = [
|
||||
{ id: 'confirm_booking', title: 'Confirm' },
|
||||
{ id: 'cancel_booking', title: 'Cancel' },
|
||||
];
|
||||
await ctx.provider.sendButtons(ctx.phone, inputs.summary, buttons);
|
||||
return { sent: true };
|
||||
});
|
||||
|
||||
this.register('book_appointment', async (inputs, ctx) => {
|
||||
const { patientName, phoneNumber, department, doctorName, scheduledAt, reason } = inputs;
|
||||
const cleanPhone = (phoneNumber ?? ctx.phone).replace(/[^0-9]/g, '').slice(-10);
|
||||
|
||||
// Conflict check
|
||||
const bookingDate = scheduledAt.split('T')[0];
|
||||
const existingAppts = await this.platform.query<any>(
|
||||
`{ appointments(first: 50, filter: { doctorName: { eq: "${doctorName}" } }, orderBy: [{ scheduledAt: AscNullsLast }]) { edges { node { id scheduledAt status patientName } } } }`,
|
||||
).catch(() => ({ appointments: { edges: [] } }));
|
||||
|
||||
const conflicts = existingAppts.appointments.edges
|
||||
.map((e: any) => e.node)
|
||||
.filter((a: any) => a.status === 'SCHEDULED' && a.scheduledAt?.startsWith(bookingDate));
|
||||
|
||||
const slotConflicts = conflicts.filter((a: any) => a.scheduledAt === scheduledAt);
|
||||
if (slotConflicts.length >= 3) {
|
||||
return { booked: false, message: `${doctorName} is fully booked at this time.` };
|
||||
}
|
||||
|
||||
// Resolve caller — creates lead/patient if new
|
||||
const resolved = await this.caller.resolve(cleanPhone, ctx.auth).catch(() => null);
|
||||
let patientId = resolved?.patientId;
|
||||
|
||||
if (resolved?.isNew && patientName) {
|
||||
const firstName = patientName.split(' ')[0];
|
||||
const lastName = patientName.split(' ').slice(1).join(' ') || '';
|
||||
try {
|
||||
const p = await this.platform.query<any>(
|
||||
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||
{ data: { fullName: { firstName, lastName }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } },
|
||||
);
|
||||
patientId = p?.createPatient?.id;
|
||||
await this.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 {}
|
||||
}
|
||||
|
||||
// Book — include patientId so appointment is linked to patient record
|
||||
const result = await this.platform.query<any>(
|
||||
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||
{ data: { name: `WhatsApp Booking — ${patientName} (${department})`, scheduledAt, status: 'SCHEDULED', doctorName, department, reasonForVisit: reason ?? 'General Consultation', ...(patientId ? { patientId } : {}) } },
|
||||
);
|
||||
const id = result?.createAppointment?.id;
|
||||
if (id) {
|
||||
return { booked: true, appointmentId: id, reference: id.substring(0, 8) };
|
||||
}
|
||||
return { booked: false, message: 'Booking failed.' };
|
||||
});
|
||||
|
||||
this.register('lookup_appointments', async (inputs, ctx) => {
|
||||
const resolved = await this.caller.resolve(ctx.phone, ctx.auth).catch(() => null);
|
||||
if (!resolved?.patientId) return { appointments: [], message: 'No patient record found.' };
|
||||
|
||||
const data = await this.platform.query<any>(
|
||||
`{ appointments(first: 10, filter: { patientId: { eq: "${resolved.patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id scheduledAt status doctorName department reasonForVisit
|
||||
} } } }`,
|
||||
);
|
||||
return { appointments: data.appointments.edges.map((e: any) => e.node) };
|
||||
});
|
||||
}
|
||||
}
|
||||
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, 500)}`);
|
||||
|
||||
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' };
|
||||
}
|
||||
}
|
||||
36
src/messaging/messaging.module.ts
Normal file
36
src/messaging/messaging.module.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
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';
|
||||
import { FlowExecutionService } from './flow/flow-execution.service';
|
||||
import { FlowSessionService } from './flow/flow-session.service';
|
||||
import { FlowStoreService } from './flow/flow-store.service';
|
||||
import { FlowVariableService } from './flow/flow-variable.service';
|
||||
import { ToolRegistry } from './flow/tool-registry';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule, CallerResolutionModule],
|
||||
controllers: [MessagingController],
|
||||
providers: [
|
||||
MessagingService,
|
||||
MessagingConversationService,
|
||||
FlowExecutionService,
|
||||
FlowSessionService,
|
||||
FlowStoreService,
|
||||
FlowVariableService,
|
||||
ToolRegistry,
|
||||
{
|
||||
provide: MessagingProvider,
|
||||
useFactory: (config: ConfigService) => {
|
||||
return new GupshupProvider(config);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
})
|
||||
export class MessagingModule {}
|
||||
420
src/messaging/messaging.service.ts
Normal file
420
src/messaging/messaging.service.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import { Injectable, Inject, Logger, Optional } 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 { FlowExecutionService } from './flow/flow-execution.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,
|
||||
@Optional() private flowExecution: FlowExecutionService,
|
||||
) {
|
||||
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;
|
||||
const replyId = message.interactiveReply?.id;
|
||||
this.logger.log(`[WA] Inbound from ${phone} (${name}): ${text.substring(0, 100)}${replyId ? ` [reply_id=${replyId}]` : ''}`);
|
||||
|
||||
// Delegate to flow engine if published flows exist
|
||||
if (this.flowExecution?.hasFlows()) {
|
||||
this.logger.log(`[WA] Delegating to flow engine`);
|
||||
await this.flowExecution.handleMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: hardcoded AI chat (legacy — will be removed once flows are validated)
|
||||
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);
|
||||
// For interactive replies, include the selection ID so the AI can
|
||||
// extract structured data (e.g. "doc:{uuid}:{name}" → doctorId)
|
||||
let userContent = text;
|
||||
if (message.type === 'interactive_reply' && message.interactiveReply?.id) {
|
||||
userContent = `[Selected: ${message.interactiveReply.title}] (selection_id: ${message.interactiveReply.id})`;
|
||||
}
|
||||
const messages = [
|
||||
...history.map(h => ({ role: h.role as 'user' | 'assistant', content: h.content })),
|
||||
{ role: 'user' as const, content: userContent },
|
||||
];
|
||||
|
||||
// 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 {
|
||||
// Pull hospital name from theme config if available
|
||||
const hospitalName = this.config.get<string>('theme.hospitalName') ?? 'our hospital';
|
||||
|
||||
return `You are a friendly WhatsApp assistant for ${hospitalName}. You help patients with:
|
||||
- Answering questions about departments, doctors, timings, fees
|
||||
- Booking appointments
|
||||
- Checking existing appointments
|
||||
|
||||
APPOINTMENT BOOKING FLOW — follow this exact sequence:
|
||||
1. When the patient wants to book, IMMEDIATELY call send_department_list. Do NOT ask "which department" in text.
|
||||
2. When the patient picks a department (selection_id starts with "dept:"), IMMEDIATELY call send_doctor_list with the department name after "dept:".
|
||||
3. When the patient picks a doctor (selection_id starts with "doc:"), IMMEDIATELY call send_slot_list. Extract the doctorId from the selection_id format "doc:{doctorId}:{doctorName}" — use the UUID between the first and second colon as doctorId, and the text after the second colon as doctorName.
|
||||
4. When the patient picks a slot (selection_id starts with "slot:"), call send_confirm_buttons with a summary. Extract the datetime from "slot:{doctorId}:{datetime}".
|
||||
5. When the patient taps Confirm (selection_id = "confirm_booking"), call book_appointment with all collected details.
|
||||
6. After booking, send a confirmation with doctor name, date, time, and reference number.
|
||||
|
||||
CRITICAL: Always use the interactive list/button tools. Never ask questions in text when a tool exists. When a user message contains "selection_id:", parse it and call the appropriate tool immediately.
|
||||
|
||||
OTHER RULES:
|
||||
- Be concise — WhatsApp messages should be short (2-3 sentences max).
|
||||
- No markdown formatting (no **, ##, bullets). Plain text only.
|
||||
- If the patient mentions a specific department or doctor upfront, skip ahead in the flow.
|
||||
- If the patient asks something you can't help with, suggest they call ${hospitalName} 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.substring(0, 24),
|
||||
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 }) => {
|
||||
// Default to tomorrow, use IST for day-of-week matching
|
||||
const targetDate = date ?? new Date(Date.now() + 86400000).toISOString().split('T')[0];
|
||||
const dayNames = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
|
||||
const targetDay = dayNames[new Date(targetDate + 'T00:00:00+05:30').getDay()];
|
||||
|
||||
const data = await platform.query<any>(
|
||||
`{ doctors(first: 50) { edges { node {
|
||||
id fullName { firstName lastName }
|
||||
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||
} } } }`,
|
||||
);
|
||||
const rawDocs = data.doctors.edges.map((e: any) => e.node);
|
||||
const doctor = rawDocs.find((d: any) => d.id === doctorId);
|
||||
if (!doctor) {
|
||||
return { sent: false, message: `Doctor not found.` };
|
||||
}
|
||||
|
||||
// Find visit slots for the target day-of-week
|
||||
const rawSlots = doctor.visitSlots?.edges?.map((e: any) => e.node) ?? [];
|
||||
const daySlots = rawSlots.filter((s: any) => s.dayOfWeek === targetDay);
|
||||
|
||||
if (!daySlots.length) {
|
||||
return { sent: false, message: `${doctorName} is not available on ${targetDay.charAt(0) + targetDay.slice(1).toLowerCase()} (${targetDate}). Please choose a different date.` };
|
||||
}
|
||||
|
||||
// Generate hourly time slots from startTime-endTime
|
||||
const timeSlots: { time: string; clinic: string }[] = [];
|
||||
for (const ds of daySlots) {
|
||||
const startHour = parseInt(ds.startTime?.split(':')[0] ?? '9', 10);
|
||||
const endHour = parseInt(ds.endTime?.split(':')[0] ?? '17', 10);
|
||||
const clinicName = ds.clinic?.clinicName ?? '';
|
||||
for (let h = startHour; h < endHour && timeSlots.length < 10; h++) {
|
||||
timeSlots.push({ time: `${String(h).padStart(2, '0')}:00`, clinic: clinicName });
|
||||
}
|
||||
}
|
||||
|
||||
if (!timeSlots.length) {
|
||||
return { sent: false, message: `No slots available for ${doctorName} on ${targetDate}.` };
|
||||
}
|
||||
|
||||
const sections: ListSection[] = [{
|
||||
title: targetDate, // section title max 24 chars
|
||||
rows: timeSlots.map((s) => ({
|
||||
id: `slot:${doctorId}:${targetDate}T${s.time}:00`,
|
||||
title: s.time, // row title max 24 chars
|
||||
description: s.clinic || undefined,
|
||||
})),
|
||||
}];
|
||||
await provider.sendList(phone, `Available slots for ${doctorName}:`, 'View Slots', sections);
|
||||
logger.log(`[WA-TOOL] send_slot_list: ${timeSlots.length} slots for ${doctorName} on ${targetDate} (${targetDay})`);
|
||||
return { sent: true, slots: timeSlots.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);
|
||||
|
||||
// Conflict check: same doctor + same date
|
||||
const bookingDate = scheduledAt.split('T')[0];
|
||||
const existingAppts = await platform.query<any>(
|
||||
`{ appointments(first: 50, filter: { doctorName: { eq: "${doctorName}" } }, orderBy: [{ scheduledAt: AscNullsLast }]) { edges { node { id scheduledAt status patientName } } } }`,
|
||||
).catch(() => ({ appointments: { edges: [] } }));
|
||||
|
||||
const conflicts = existingAppts.appointments.edges
|
||||
.map((e: any) => e.node)
|
||||
.filter((a: any) => a.status === 'SCHEDULED' && a.scheduledAt?.startsWith(bookingDate));
|
||||
|
||||
// Check if this patient already has a booking with this doctor on the same date
|
||||
const patientConflict = conflicts.find((a: any) =>
|
||||
a.patientName?.toLowerCase().includes(patientName.split(' ')[0].toLowerCase()),
|
||||
);
|
||||
if (patientConflict) {
|
||||
logger.log(`[WA-BOOK] Conflict: patient already booked with ${doctorName} on ${bookingDate}`);
|
||||
return { booked: false, message: `You already have an appointment with ${doctorName} on ${bookingDate}. Would you like to choose a different date?` };
|
||||
}
|
||||
|
||||
// Check if the doctor has too many appointments at this exact time
|
||||
const slotConflicts = conflicts.filter((a: any) => a.scheduledAt === scheduledAt);
|
||||
if (slotConflicts.length >= 3) {
|
||||
logger.log(`[WA-BOOK] Conflict: ${doctorName} fully booked at ${scheduledAt} (${slotConflicts.length} existing)`);
|
||||
return { booked: false, message: `${doctorName} is fully booked at this time. Please choose a different slot.` };
|
||||
}
|
||||
|
||||
let patientId = resolved?.patientId;
|
||||
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, status: 'SCHEDULED', doctorName, department, reasonForVisit: reason, ...(patientId ? { patientId } : {}) } },
|
||||
);
|
||||
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.' };
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
144
src/messaging/providers/gupshup.provider.ts
Normal file
144
src/messaging/providers/gupshup.provider.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
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') {
|
||||
// Gupshup sends postbackText (our ID), id can be empty string
|
||||
const replyId = payload.payload?.postbackText || payload.payload?.id || payload.payload?.reply || '';
|
||||
return {
|
||||
phone, name,
|
||||
text: payload.payload?.title ?? '',
|
||||
type: 'interactive_reply',
|
||||
interactiveReply: {
|
||||
id: replyId,
|
||||
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, 500)}`);
|
||||
|
||||
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