This guide provides a step-by-step walkthrough for integrating WhatsApp messaging capabilities into your Node.js application using the NestJS framework and the Plivo communications API platform. We'll build a robust backend service capable of sending various WhatsApp message types (templated, text, media, interactive) and handling incoming messages via webhooks.
By the end of this tutorial, you will have a functional NestJS application that can:
- Send templated WhatsApp messages.
- Send free-form text and media WhatsApp messages (within active conversation windows).
- Send interactive WhatsApp messages (lists, buttons, CTAs).
- Receive incoming WhatsApp messages via Plivo webhooks.
- Include essential production considerations like configuration management, basic error handling, logging, and security practices.
This guide is designed for developers familiar with Node.js and basic NestJS concepts. We prioritize clear, step-by-step instructions, production best practices, and thorough explanations.
Project Overview and Goals
Goal: To create a reliable backend service using NestJS that leverages the Plivo API to send and receive WhatsApp messages, suitable for integration into larger applications (e.g., customer support platforms, notification systems, chatbots).
Problem Solved: Provides a structured, scalable, and maintainable way to manage WhatsApp communications programmatically, abstracting the complexities of the Plivo API within a dedicated NestJS service.
Technologies Used:
- Node.js: The underlying JavaScript runtime environment.
- NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Chosen for its modular architecture, dependency injection, and built-in support for features like configuration and validation.
- Plivo Node.js SDK: Simplifies interaction with the Plivo REST API.
- Plivo Communications Platform: Provides the API infrastructure for sending and receiving WhatsApp messages.
- dotenv / @nestjs/config: For managing environment variables securely.
- class-validator / class-transformer: For robust request data validation using Data Transfer Objects (DTOs).
- (Optional) ngrok: For exposing local development server to the internet to test incoming webhooks.
System Architecture:
+-----------------+ +---------------------+ +-------------+ +------------+
| User/Client App |----->| NestJS Backend App |----->| Plivo API |----->| WhatsApp |
| (e.g., Web/Mobile)| | (Controller, Service) | | | | Network |
+-----------------+ +----------^----------+ +-------------+ +-----^------+
| |
+-----------------+ | Webhook Notification | User's Phone
| Plivo Webhook |<-----------------+ (Incoming Message) |
+-----------------+
Prerequisites:
- Node.js and npm/yarn: Installed on your system (LTS version recommended).
- NestJS CLI: Installed globally (
npm install -g @nestjs/cli
). - Plivo Account: A registered Plivo account (Sign up here).
- Plivo Auth ID and Auth Token: Found on your Plivo Console dashboard homepage.
- WhatsApp-Enabled Plivo Number: You need to purchase a Plivo number and enable it for WhatsApp use via the Plivo console (Messaging -> WhatsApp -> Senders). This often involves linking your WhatsApp Business Account (WABA). Follow Plivo's onboarding process carefully.
- Approved WhatsApp Templates (for outbound): Business-initiated conversations must start with pre-approved templates. You can create and submit these via the Plivo console.
- (Optional) ngrok: For testing incoming webhooks locally (Download here).
1. Setting up the NestJS Project
Let's initialize our NestJS project and install the necessary dependencies.
Step 1: Create a New NestJS Project
Open your terminal and run:
nest new plivo-whatsapp-integration
cd plivo-whatsapp-integration
This creates a standard NestJS project structure.
Step 2: Install Dependencies
We need the Plivo Node.js SDK and NestJS's configuration module:
npm install plivo @nestjs/config dotenv
npm install --save-dev @types/node # Ensure latest Node types
plivo
: The official Plivo SDK for Node.js.@nestjs/config
: Handles environment variables and configuration management.dotenv
: Loads environment variables from a.env
file intoprocess.env
.
Step 3: Configure Environment Variables
Create a .env
file in the project root directory:
#.env
PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID
PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN
PLIVO_WHATSAPP_SENDER_NUMBER=+14155551234 # Your WhatsApp-enabled Plivo number
# Optional: Base URL for exposing webhook (useful with ngrok)
BASE_URL=http://localhost:3000
Important:
- You must replace
YOUR_PLIVO_AUTH_ID
andYOUR_PLIVO_AUTH_TOKEN
with your actual credentials obtained from the Plivo console. - You must replace
+14155551234
with your actual WhatsApp-enabled Plivo number in E.164 format. - Security: Never commit your
.env
file to version control. Ensure.env
is listed in your.gitignore
file.
Step 4: Setup Configuration Module
Modify src/app.module.ts
to load and manage environment variables using @nestjs/config
:
// src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from '@nestjs/config';
import { PlivoModule } from './plivo/plivo.module'; // We will create this next
import { WhatsappModule } from './whatsapp/whatsapp.module'; // We will create this next
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // Makes ConfigModule available globally
envFilePath: '.env',
}),
PlivoModule, // Import Plivo Module
WhatsappModule, // Import WhatsApp Module
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' })
: Loads the.env
file and makes configuration accessible throughout the application viaConfigService
.
Project Structure (Initial):
Your project structure should now look something like this:
plivo-whatsapp-integration/
├── node_modules/
├── src/
│ ├── app.controller.spec.ts
│ ├── app.controller.ts
│ ├── app.module.ts
│ ├── app.service.ts
│ ├── main.ts
│ ├── plivo/ <-- To be created
│ └── whatsapp/ <-- To be created
├── test/
├── .env <-- Your credentials
├── .eslintrc.js
├── .gitignore
├── .prettierrc
├── nest-cli.json
├── package.json
├── package-lock.json
├── README.md
├── tsconfig.build.json
└── tsconfig.json
2. Implementing the Plivo Service
We'll create a dedicated NestJS module and service to encapsulate all Plivo API interactions. This promotes modularity and reusability.
Step 1: Generate the Plivo Module and Service
Use the NestJS CLI to generate the module and service files:
nest generate module plivo
nest generate service plivo --no-spec # --no-spec skips test file generation for now
This creates src/plivo/plivo.module.ts
and src/plivo/plivo.service.ts
.
Step 2: Implement the Plivo Service
Open src/plivo/plivo.service.ts
and implement the Plivo client initialization:
// src/plivo/plivo.service.ts
import { Injectable_ OnModuleInit_ Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as plivo from 'plivo';
@Injectable()
export class PlivoService implements OnModuleInit {
private client: plivo.Client;
private readonly logger = new Logger(PlivoService.name);
private senderNumber: string;
constructor(private configService: ConfigService) {}
onModuleInit() {
const authId = this.configService.get<string>('PLIVO_AUTH_ID');
const authToken = this.configService.get<string>('PLIVO_AUTH_TOKEN');
this.senderNumber = this.configService.get<string>('PLIVO_WHATSAPP_SENDER_NUMBER');
if (!authId || !authToken || !this.senderNumber) {
this.logger.error('Plivo Auth ID, Auth Token, or Sender Number is missing in environment variables. Plivo integration will not function.');
// Throw an error to prevent application startup if Plivo is critical
throw new Error('Plivo credentials or sender number missing. Application cannot start.');
// Alternatively, allow startup but log error (less safe if Plivo is essential):
// return;
}
try {
this.client = new plivo.Client(authId, authToken);
this.logger.log('Plivo client initialized successfully.');
} catch (error) {
this.logger.error('Failed to initialize Plivo client:', error);
// Consider throwing here as well, as the app might be non-functional
throw new Error(`Plivo client initialization failed: ${error.message}`);
}
}
// Expose the client for advanced use cases if needed, or create specific methods
getPlivoClient(): plivo.Client {
if (!this.client) {
// This should ideally not happen if onModuleInit throws on failure
this.logger.error('Attempted to get Plivo client, but it was not initialized. Check configuration and startup logs.');
throw new Error('Plivo client is not initialized. Check configuration.');
}
return this.client;
}
getSenderNumber(): string {
if (!this.senderNumber) {
// This should ideally not happen if onModuleInit throws on failure
throw new Error('Plivo sender number is not configured.');
}
return this.senderNumber;
}
// We will add methods here to send different types of messages
}
- We inject
ConfigService
to access environment variables. OnModuleInit
ensures the Plivo client is initialized when the module loads.- We retrieve
PLIVO_AUTH_ID
,PLIVO_AUTH_TOKEN
, andPLIVO_WHATSAPP_SENDER_NUMBER
from the configuration. - Crucially: If credentials or the sender number are missing, it now throws an error by default, preventing the application from starting in a non-functional state regarding Plivo.
- Error handling is included for initialization failures.
- Getters
getPlivoClient()
andgetSenderNumber()
provide access to the initialized client instance and sender number, including checks to ensure they are available.
Step 3: Configure the Plivo Module
Open src/plivo/plivo.module.ts
and ensure the service is provided and exported:
// src/plivo/plivo.module.ts
import { Module } from '@nestjs/common';
import { PlivoService } from './plivo.service';
import { ConfigModule } from '@nestjs/config'; // Import ConfigModule if not global
@Module({
imports: [ConfigModule], // Import ConfigModule here if it's not set globally in app.module
providers: [PlivoService],
exports: [PlivoService], // Export the service so other modules can use it
})
export class PlivoModule {}
Remember we already imported PlivoModule
into AppModule
in the previous section.
3. Implementing Core Functionality: Sending WhatsApp Messages
Now, let's create a controller and add methods to the PlivoService
to send various types of WhatsApp messages.
Step 1: Generate the WhatsApp Module and Controller
nest generate module whatsapp
nest generate controller whatsapp --no-spec
This creates src/whatsapp/whatsapp.module.ts
and src/whatsapp/whatsapp.controller.ts
.
Step 2: Configure the WhatsApp Module
Open src/whatsapp/whatsapp.module.ts
and import the PlivoModule
:
// src/whatsapp/whatsapp.module.ts
import { Module } from '@nestjs/common';
import { WhatsappController } from './whatsapp.controller';
import { PlivoModule } from '../plivo/plivo.module'; // Import PlivoModule
import { ConfigModule } from '@nestjs/config'; // Import if needed
@Module({
imports: [PlivoModule, ConfigModule], // Make PlivoService available
controllers: [WhatsappController],
})
export class WhatsappModule {}
Remember we already imported WhatsappModule
into AppModule
.
Step 3: Install Validation Dependencies
We'll use DTOs (Data Transfer Objects) with decorators for validating incoming request bodies.
npm install class-validator class-transformer
Step 4: Enable Global Validation Pipe
Modify src/main.ts
to enable automatic validation for all incoming requests based on DTOs:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe, Logger } from '@nestjs/common'; // Import ValidationPipe and Logger
import { ConfigService } from '@nestjs/config'; // Import ConfigService
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const logger = new Logger('Bootstrap'); // Create a logger instance
// Enable CORS if your frontend is on a different domain
app.enableCors();
// Global Validation Pipe
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // Strip properties that don't have decorators
forbidNonWhitelisted: true, // Throw errors for non-whitelisted properties
transform: true, // Automatically transform payloads to DTO instances
transformOptions: {
enableImplicitConversion: true, // Allow basic type conversions
},
}));
const configService = app.get(ConfigService); // Get ConfigService instance
const port = configService.get<number>('PORT') || 3000; // Use PORT from env or default to 3000
await app.listen(port);
logger.log(`Application listening on port ${port}`);
// Construct the base URL for logging the webhook URL
const baseUrl = configService.get<string>('BASE_URL') || `http://localhost:${port}`;
logger.log(`Expected WhatsApp Webhook URL for Plivo: ${baseUrl}/whatsapp/webhook/incoming`);
}
bootstrap();
- We added a global
ValidationPipe
to automatically validate incoming data against DTOs. - Added basic CORS enablement (
app.enableCors()
). - Dynamically set the port from environment variable
PORT
or default to 3000. - Log the expected webhook URL for convenience, constructing it from
BASE_URL
or localhost.
Step 5: Create DTOs for Sending Messages
Create a directory src/whatsapp/dto
and add the following DTO files:
// src/whatsapp/dto/send-template.dto.ts
import { IsNotEmpty, IsString, IsPhoneNumber, IsObject, IsOptional, IsUrl, ValidateNested, IsArray } from 'class-validator';
import { Type } from 'class-transformer';
// NOTE: Define more specific component DTOs if needed, or rely on IsObject for flexibility.
// Refer to Plivo documentation for the exact structure required for template components.
class TemplateComponentHeader {
@IsString()
type: 'header'; // Example, adjust based on Plivo spec
// Add other header properties and validation
}
class TemplateComponentBody {
@IsString()
type: 'body'; // Example
// Add other body properties and validation
}
// Define ButtonComponent etc.
export class SendTemplateDto {
@IsNotEmpty()
@IsPhoneNumber(null) // Use null for generic E.164 format validation
readonly dst: string; // Destination WhatsApp number
@IsNotEmpty()
@IsString()
readonly templateName: string; // Name of the approved Plivo WhatsApp template
@IsOptional()
@IsString()
readonly language?: string = 'en_US'; // Language code (default: en_US)
@IsOptional()
@IsArray() // Ensure it's an array
@ValidateNested({ each: true }) // Validate each object in the array if you define nested DTOs
@Type(() => Object) // Basic type hint for transformation, replace Object with specific DTO if defined
// Plivo expects a specific structure for template components based on the template definition.
// Accepting a generic object array provides flexibility but less compile-time safety.
// Consider creating detailed nested DTOs for components for stricter validation if desired.
// Refer to Plivo's documentation for the required 'components' array structure.
// Example: components: [{ type: 'body', parameters: [...] }, { type: 'header', ...}]
readonly templateComponents?: Record<string_ any>[]; // Expects an array of component objects
@IsOptional()
@IsUrl()
readonly callbackUrl?: string; // Optional status callback URL
}
// src/whatsapp/dto/send-text.dto.ts
import { IsNotEmpty, IsString, IsPhoneNumber, IsOptional, IsUrl } from 'class-validator';
export class SendTextDto {
@IsNotEmpty()
@IsPhoneNumber(null)
readonly dst: string;
@IsNotEmpty()
@IsString()
readonly text: string;
@IsOptional()
@IsUrl()
readonly callbackUrl?: string;
}
// src/whatsapp/dto/send-media.dto.ts
import { IsNotEmpty, IsString, IsPhoneNumber, IsUrl, IsArray, IsOptional } from 'class-validator';
export class SendMediaDto {
@IsNotEmpty()
@IsPhoneNumber(null)
readonly dst: string;
@IsNotEmpty()
@IsArray()
@IsUrl({}, { each: true }) // Validate each item in the array is a URL
readonly mediaUrls: string[]; // Array containing URL(s) of the media
@IsOptional()
@IsString()
readonly caption?: string; // Optional caption for the media
@IsOptional()
@IsUrl()
readonly callbackUrl?: string;
}
// src/whatsapp/dto/send-interactive.dto.ts
import { IsNotEmpty, IsPhoneNumber, IsObject, IsOptional, IsUrl } from 'class-validator';
export class SendInteractiveDto {
@IsNotEmpty()
@IsPhoneNumber(null)
readonly dst: string;
@IsNotEmpty()
@IsObject()
// Plivo expects a specific structure for the interactive payload (buttons, lists, etc.).
// Accepting a generic object provides flexibility. For stricter validation, create detailed
// nested DTOs reflecting Plivo's interactive message structure.
// Refer to Plivo's documentation for the required 'interactive' object structure.
readonly interactive: Record<string_ any>;
@IsOptional()
@IsUrl()
readonly callbackUrl?: string;
}
Step 6: Add Sending Methods to PlivoService
Now, add the methods to src/plivo/plivo.service.ts
to handle the actual API calls using the Plivo client.
// src/plivo/plivo.service.ts
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as plivo from 'plivo';
// Keep existing constructor, onModuleInit, getPlivoClient, getSenderNumber
@Injectable()
export class PlivoService implements OnModuleInit {
private client: plivo.Client;
private readonly logger = new Logger(PlivoService.name);
private senderNumber: string;
constructor(private configService: ConfigService) {}
onModuleInit() {
const authId = this.configService.get<string>('PLIVO_AUTH_ID');
const authToken = this.configService.get<string>('PLIVO_AUTH_TOKEN');
this.senderNumber = this.configService.get<string>('PLIVO_WHATSAPP_SENDER_NUMBER');
if (!authId || !authToken || !this.senderNumber) {
this.logger.error('Plivo Auth ID, Auth Token, or Sender Number is missing in environment variables. Plivo integration will not function.');
throw new Error('Plivo credentials or sender number missing. Application cannot start.');
}
try {
this.client = new plivo.Client(authId, authToken);
this.logger.log('Plivo client initialized successfully.');
} catch (error) {
this.logger.error('Failed to initialize Plivo client:', error);
throw new Error(`Plivo client initialization failed: ${error.message}`);
}
}
getPlivoClient(): plivo.Client {
if (!this.client) {
this.logger.error('Attempted to get Plivo client, but it was not initialized. Check configuration and startup logs.');
throw new Error('Plivo client is not initialized. Check configuration.');
}
return this.client;
}
getSenderNumber(): string {
if (!this.senderNumber) {
throw new Error('Plivo sender number is not configured.');
}
return this.senderNumber;
}
// --- NEW METHODS ---
async sendWhatsAppTemplate(
dst: string,
templateName: string,
language: string = 'en_US',
components?: Record<string_ any>[], // Expecting array of components
callbackUrl?: string,
): Promise<any> {
const client = this.getPlivoClient();
const src = this.getSenderNumber();
this.logger.log(`Sending template '${templateName}' to ${dst} from ${src}`);
// Construct the template object expected by Plivo SDK
// Refer to Plivo Node SDK documentation for exact structure confirmation
const templatePayload = {
name: templateName,
language: language,
...(components && { components: components }), // Only add components if provided
};
try {
const response = await client.messages.create({
src: src,
dst: dst,
powerpack_uuid: undefined, // Ensure powerpack_uuid is not sent for WhatsApp via number
type: 'whatsapp', // Ensure type is set for WhatsApp
template: templatePayload,
...(callbackUrl && { url: callbackUrl }), // Add callback URL if provided
});
this.logger.log(`Template message queued successfully: ${response.messageUuid[0]}`); // UUID is usually in an array
return response;
} catch (error) {
this.logger.error(`Failed to send template message to ${dst}:`, error.message || error);
// Re-throw or handle specific Plivo errors (e.g., invalid number, template not approved)
throw error; // Let higher layers handle or use custom exceptions
}
}
async sendWhatsAppText(
dst: string,
text: string,
callbackUrl?: string,
): Promise<any> {
const client = this.getPlivoClient();
const src = this.getSenderNumber();
this.logger.log(`Sending text message to ${dst} from ${src}`);
// IMPORTANT: Free-form messages only allowed within 24-hour window after user initiates
try {
const response = await client.messages.create({
src: src,
dst: dst,
powerpack_uuid: undefined,
type: 'whatsapp',
text: text,
...(callbackUrl && { url: callbackUrl }),
});
this.logger.log(`Text message queued successfully: ${response.messageUuid[0]}`);
return response;
} catch (error) {
this.logger.error(`Failed to send text message to ${dst}:`, error.message || error);
throw error;
}
}
async sendWhatsAppMedia(
dst: string,
mediaUrls: string[],
caption?: string,
callbackUrl?: string,
): Promise<any> {
const client = this.getPlivoClient();
const src = this.getSenderNumber();
this.logger.log(`Sending media message to ${dst} from ${src}`);
// IMPORTANT: Free-form messages only allowed within 24-hour window
// Verify SDK payload structure with Plivo documentation
try {
const response = await client.messages.create({
src: src,
dst: dst,
powerpack_uuid: undefined,
type: 'whatsapp',
media_urls: mediaUrls, // Plivo SDK typically uses 'media_urls' for WhatsApp
...(caption && { text: caption }), // Caption often goes in 'text' field for media messages
...(callbackUrl && { url: callbackUrl }),
});
this.logger.log(`Media message queued successfully: ${response.messageUuid[0]}`);
return response;
} catch (error) {
this.logger.error(`Failed to send media message to ${dst}:`, error.message || error);
throw error;
}
}
async sendWhatsAppInteractive(
dst: string,
interactivePayload: Record<string_ any>, // Expects the structured interactive object
callbackUrl?: string,
): Promise<any> {
const client = this.getPlivoClient();
const src = this.getSenderNumber();
this.logger.log(`Sending interactive message to ${dst} from ${src}`);
// IMPORTANT: Free-form messages only allowed within 24-hour window
// Verify SDK payload structure with Plivo documentation
try {
const response = await client.messages.create({
src: src,
dst: dst,
powerpack_uuid: undefined,
type: 'whatsapp',
interactive: interactivePayload, // Pass the interactive object directly
...(callbackUrl && { url: callbackUrl }),
});
this.logger.log(`Interactive message queued successfully: ${response.messageUuid[0]}`);
return response;
} catch (error) {
this.logger.error(`Failed to send interactive message to ${dst}:`, error.message || error);
throw error;
}
}
// Add methods for other message types (Location, Contact, etc.) as needed
// Example: sendWhatsAppLocation(...)
}
- Each method takes necessary parameters (destination, content, optional callback URL).
- It retrieves the initialized Plivo client and sender number.
- It constructs the payload specific to the message type according to the expected Plivo Node.js SDK structure. Always verify against the latest Plivo SDK documentation. Note: Added
powerpack_uuid: undefined
as it's often required to be explicitly absent when sending via number. AdjustedmessageUuid
access assuming it's an array. - It calls the appropriate
client.messages.create({...})
method. - Includes basic logging and error handling using
try...catch
. More sophisticated error handling could be added (Section 5). - Important: Added comments reminding that free-form messages (text, media, interactive) are generally only allowed as replies within the 24-hour customer service window initiated by the user. Business-initiated messages must use approved templates.
Step 7: Implement Controller Endpoints
Open src/whatsapp/whatsapp.controller.ts
and define the API endpoints that will use the PlivoService
.
// src/whatsapp/whatsapp.controller.ts
import { Controller, Post, Body, HttpCode, HttpStatus, Logger, UseFilters, Req, UnauthorizedException, RawBodyRequest } from '@nestjs/common';
import { PlivoService } from '../plivo/plivo.service';
import { SendTemplateDto } from './dto/send-template.dto';
import { SendTextDto } from './dto/send-text.dto';
import { SendMediaDto } from './dto/send-media.dto';
import { SendInteractiveDto } from './dto/send-interactive.dto';
import { ConfigService } from '@nestjs/config';
import * as plivo from 'plivo';
import { Request } from 'express'; // Import Request for webhook signature validation
// Import an exception filter if you create one (See Section 5)
// import { AllExceptionsFilter } from '../common/filters/all-exceptions.filter';
@Controller('whatsapp')
// @UseFilters(new AllExceptionsFilter()) // Apply custom filter if created (Update path if needed)
export class WhatsappController {
private readonly logger = new Logger(WhatsappController.name);
constructor(
private readonly plivoService: PlivoService,
private readonly configService: ConfigService // Inject ConfigService for Auth Token
) {}
@Post('/send/template')
@HttpCode(HttpStatus.ACCEPTED) // Use 202 Accepted as sending is asynchronous
async sendTemplateMessage(@Body() sendTemplateDto: SendTemplateDto) {
this.logger.log(`Received request to send template: ${sendTemplateDto.templateName} to ${sendTemplateDto.dst}`);
try {
const result = await this.plivoService.sendWhatsAppTemplate(
sendTemplateDto.dst,
sendTemplateDto.templateName,
sendTemplateDto.language,
sendTemplateDto.templateComponents,
sendTemplateDto.callbackUrl,
);
// Return minimal confirmation, status updates via webhook are better
return { messageId: result.messageUuid[0], status: 'queued' };
} catch (error) {
// Error is logged in the service, controller can re-throw or return specific HTTP error
// Consider using an Exception Filter for cleaner error handling (Section 5)
this.logger.error(`Error in /send/template endpoint: ${error.message}`, error.stack);
throw error; // Let NestJS handle the error (default 500 or specific if thrown/filtered)
}
}
@Post('/send/text')
@HttpCode(HttpStatus.ACCEPTED)
async sendTextMessage(@Body() sendTextDto: SendTextDto) {
this.logger.log(`Received request to send text to ${sendTextDto.dst}`);
try {
const result = await this.plivoService.sendWhatsAppText(
sendTextDto.dst,
sendTextDto.text,
sendTextDto.callbackUrl,
);
return { messageId: result.messageUuid[0], status: 'queued' };
} catch (error) {
this.logger.error(`Error in /send/text endpoint: ${error.message}`, error.stack);
throw error;
}
}
@Post('/send/media')
@HttpCode(HttpStatus.ACCEPTED)
async sendMediaMessage(@Body() sendMediaDto: SendMediaDto) {
this.logger.log(`Received request to send media to ${sendMediaDto.dst}`);
try {
const result = await this.plivoService.sendWhatsAppMedia(
sendMediaDto.dst,
sendMediaDto.mediaUrls,
sendMediaDto.caption,
sendMediaDto.callbackUrl,
);
return { messageId: result.messageUuid[0], status: 'queued' };
} catch (error) {
this.logger.error(`Error in /send/media endpoint: ${error.message}`, error.stack);
throw error;
}
}
@Post('/send/interactive')
@HttpCode(HttpStatus.ACCEPTED)
async sendInteractiveMessage(@Body() sendInteractiveDto: SendInteractiveDto) {
this.logger.log(`Received request to send interactive message to ${sendInteractiveDto.dst}`);
try {
const result = await this.plivoService.sendWhatsAppInteractive(
sendInteractiveDto.dst,
sendInteractiveDto.interactive,
sendInteractiveDto.callbackUrl,
);
return { messageId: result.messageUuid[0], status: 'queued' };
} catch (error) {
this.logger.error(`Error in /send/interactive endpoint: ${error.message}`, error.stack);
throw error;
}
}
// --- INCOMING WEBHOOK HANDLER (Section 4 & 7) ---
// NOTE: Requires rawBody parser enabled in main.ts for signature validation
@Post('/webhook/incoming')
@HttpCode(HttpStatus.OK) // Plivo expects a 200 OK for webhooks
handleIncomingMessage(@Req() req: RawBodyRequest<Request>, @Body() payload: any) { // Use RawBodyRequest
this.logger.log('Received incoming WhatsApp message webhook request.');
// --- Webhook Signature Validation (Section 7) ---
const signature = req.headers['x-plivo-signature-v3'] as string;
const nonce = req.headers['x-plivo-signature-v3-nonce'] as string; // Get nonce as well
// Construct the full URL (protocol + host + originalUrl)
const url = req.protocol + '://' + req.get('host') + req.originalUrl;
const authToken = this.configService.get<string>('PLIVO_AUTH_TOKEN');
// IMPORTANT: Plivo's validateV3Signature requires the raw body.
// NestJS's default JSON body parser consumes the raw stream.
// You MUST configure NestJS to preserve the raw body for this validation to work.
// See `main.ts` notes in Section 7 for configuration details.
// Assuming rawBody is available on `req.rawBody` after configuration:
const rawBody = req.rawBody; // Access rawBody from RawBodyRequest
if (!rawBody) {
this.logger.error('Raw body not available for webhook signature validation. Ensure rawBody middleware is configured in main.ts.');
// Potentially throw an error or skip validation with a warning depending on security posture
throw new Error('Webhook validation configuration error: Raw body missing.');
}
if (!signature || !nonce) {
this.logger.warn('Missing Plivo signature headers. Rejecting request.');
throw new UnauthorizedException('Missing signature');
}
try {
const valid = plivo.validateV3Signature(url, nonce, signature, rawBody.toString('utf8'), authToken);
if (!valid) {
this.logger.warn('Invalid Plivo signature. Rejecting request.');
throw new UnauthorizedException('Invalid signature');
}
this.logger.log('Plivo webhook signature validated successfully.');
} catch (error) {
this.logger.error(`Error during signature validation: ${error.message}`);
throw new UnauthorizedException('Signature validation failed');
}
// --- Process Payload (Only if signature is valid) ---
this.logger.log('Processing incoming WhatsApp message payload:');
this.logger.debug(`Payload: ${JSON.stringify(payload, null, 2)}`); // Log full payload for debugging
// Basic Payload Parsing (Consult Plivo docs for definitive structure)
const fromNumber = payload.From;
const toNumber = payload.To;
const messageUuid = payload.MessageUUID;
const type = payload.Type; // e.g., 'text', 'media', 'location'
const eventType = payload.EventType; // e.g., 'message_delivered', 'message_read', 'interactive'
this.logger.log(`Incoming event ${eventType || type} (${messageUuid}) from ${fromNumber} to ${toNumber}`);
// Handle Different Message Types/Events (Add your business logic)
if (type === 'text') {
const text = payload.Text;
this.logger.log(`Text received: ""${text}""`);
// ** ACTION: Implement your business logic here **
// Example: Route to a chatbot, save to DB, trigger a reply
} else if (type === 'media') {
const mediaUrl = payload.MediaUrl;
const mediaContentType = payload.MediaContentType;
const caption = payload.Text; // Caption might be in Text field
this.logger.log(`Media received: ${mediaContentType} at ${mediaUrl}, Caption: ""${caption || ''}""`);
// ** ACTION: Implement logic to handle media (e.g., download, analyze) **
} else if (type === 'location') {
const latitude = payload.Latitude;
const longitude = payload.Longitude;
this.logger.log(`Location received: Lat ${latitude}, Lon ${longitude}`);
// ** ACTION: Handle location data **
} else if (eventType === 'interactive' && payload.Interactive) {
// Handle replies from interactive messages (buttons, lists)
const interactiveData = payload.Interactive;
const interactiveType = interactiveData.type; // 'button_reply' or 'list_reply'
this.logger.log(`Interactive reply received: Type - ${interactiveType}`);
if (interactiveType === 'button_reply') {
const buttonId = interactiveData.button_reply?.id;
const buttonTitle = interactiveData.button_reply?.title;
this.logger.log(`Button Clicked: ID='${buttonId}', Title='${buttonTitle}'`);
// ** ACTION: Handle button click based on ID **
} else if (interactiveType === 'list_reply') {
const listItemId = interactiveData.list_reply?.id;
const listItemTitle = interactiveData.list_reply?.title;
const listItemDescription = interactiveData.list_reply?.description;
this.logger.log(`List Item Selected: ID='${listItemId}', Title='${listItemTitle}', Desc='${listItemDescription}'`);
// ** ACTION: Handle list selection based on ID **
}
} else if (eventType && eventType.startsWith('message_')) {
// Handle status updates (delivered, read, failed, etc.)
this.logger.log(`Message status update: ${eventType} for UUID ${messageUuid}`);
// ** ACTION: Update message status in your database if tracking **
} else {
this.logger.log(`Received unhandled message type '${type}' or event type '${eventType}'.`);
}
// ** IMPORTANT: Always return 200 OK quickly to Plivo **
// Perform time-consuming actions asynchronously (e.g., using queues or background jobs)
// If you don't return 200 OK promptly, Plivo might retry the webhook.
}
}