feat(channels): add Plivo SMS/MMS channel extension

Add a new channel extension for Plivo SMS/MMS messaging, enabling
universal phone-based access to Clawdbot from any device.

Features:
- Two-way SMS messaging via Plivo API
- MMS media support (images, videos, documents)
- Quick command shortcuts (e.g., "cal" -> "show my calendar")
- Auto-configuration of Plivo webhooks on startup
- Multi-account support
- DM policies (pairing, allowlist, open)

Implements ideas 3.1, 3.3, 3.4, 3.7 from PLIVO_INTEGRATION_IDEAS.md:
- 3.1 SMS Channel Adapter
- 3.3 Quick Commands via SMS
- 3.4 MMS Media Sharing
- 3.7 Critical Alerts via SMS

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Narayan Vyas 2026-01-26 13:58:42 -08:00
parent 552b2956d4
commit 40538b5a30
13 changed files with 11812 additions and 0 deletions

4
extensions/plivo/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
*.log
.env

45
extensions/plivo/index.ts Normal file
View File

@ -0,0 +1,45 @@
/**
* Plivo SMS Channel Extension for Clawdbot
*
* This extension provides SMS/MMS messaging capabilities via Plivo,
* enabling universal phone-based access to your AI assistant.
*
* Features:
* - Two-way SMS messaging
* - MMS media support (images, videos, documents)
* - Quick command shortcuts (e.g., "cal" -> "show my calendar")
* - Auto-configuration of Plivo webhooks
* - Multi-account support
*/
import { plivoPlugin } from "./src/channel.js";
import { setPlivoRuntime } from "./src/runtime.js";
// Plugin definition for Clawdbot
const plugin = {
id: "plivo",
name: "Plivo SMS",
description: "SMS/MMS channel via Plivo - Universal phone access to your AI assistant",
register(api: {
runtime: unknown;
registerChannel: (opts: { plugin: typeof plivoPlugin }) => void;
}) {
// Store runtime reference for access in adapters
setPlivoRuntime(api.runtime);
// Register the Plivo channel
api.registerChannel({ plugin: plivoPlugin });
},
};
export default plugin;
// Re-export types and utilities for external use
export { plivoPlugin } from "./src/channel.js";
export type {
PlivoConfig,
PlivoAccountConfig,
PlivoResolvedAccount,
QuickCommand,
} from "./src/types.js";

10435
extensions/plivo/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,56 @@
{
"name": "@clawdbot/plivo",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot Plivo SMS/MMS channel plugin - Universal SMS access to your AI assistant",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist"
},
"keywords": [
"clawdbot",
"plivo",
"sms",
"mms",
"channel",
"messaging"
],
"author": "",
"license": "MIT",
"clawdbot": {
"extensions": ["./dist/index.js"],
"channel": {
"id": "plivo",
"label": "Plivo",
"selectionLabel": "Plivo SMS",
"docsPath": "/channels/plivo",
"docsLabel": "plivo",
"blurb": "SMS/MMS via Plivo; universal phone access to your AI assistant.",
"order": 45
},
"install": {
"npmSpec": "@clawdbot/plivo",
"localPath": "extensions/plivo",
"defaultChoice": "local"
}
},
"dependencies": {
"plivo": "^4.58.0"
},
"peerDependencies": {
"clawdbot": "*"
},
"devDependencies": {
"@types/node": "^20.10.0",
"typescript": "^5.3.2"
}
}

View File

@ -0,0 +1,148 @@
/**
* Plivo SMS Channel Plugin
* Main channel implementation for Clawdbot
*/
import { configAdapter } from "./config.js";
import { gatewayAdapter } from "./gateway.js";
import { outboundAdapter } from "./outbound.js";
import type { PlivoResolvedAccount } from "./types.js";
const CHANNEL_ID = "plivo";
/**
* Channel metadata for UI/docs
*/
const meta = {
id: CHANNEL_ID,
label: "Plivo",
selectionLabel: "Plivo SMS",
docsPath: "/channels/plivo",
docsLabel: "plivo",
blurb: "SMS/MMS via Plivo; universal phone access to your AI assistant.",
};
/**
* Channel capabilities
*/
const capabilities = {
supportsText: true,
supportsMedia: true,
supportsVoice: false,
supportsVideo: false,
supportsStickers: false,
supportsPolls: false,
supportsButtons: false,
supportsFormatting: false, // SMS is plain text
supportsThreading: false,
supportsReactions: false,
supportsEditing: false,
supportsDeleting: false,
maxTextLength: 1600, // SMS concatenation limit
maxMediaSize: 5 * 1024 * 1024, // 5MB MMS limit
maxMediaCount: 10,
};
/**
* Plivo channel plugin definition
*/
export const plivoPlugin = {
id: CHANNEL_ID,
meta,
capabilities,
// Configuration adapter
config: {
listAccountIds: (cfg: { channels?: Record<string, unknown> }) =>
configAdapter.listAccountIds(cfg),
resolveAccount: (cfg: { channels?: Record<string, unknown> }, accountId?: string) =>
configAdapter.resolveAccount(cfg, accountId),
isConfigured: (cfg: { channels?: Record<string, unknown> }, accountId?: string) =>
configAdapter.isConfigured(cfg, accountId),
resolveAllowFrom: (cfg: { channels?: Record<string, unknown> }, accountId?: string) =>
configAdapter.resolveAllowFrom(cfg, accountId),
describeAccount: (cfg: { channels?: Record<string, unknown> }, accountId?: string) =>
configAdapter.describeAccount(cfg, accountId),
},
// Gateway adapter for starting/stopping
gateway: {
startAccount: gatewayAdapter.startAccount,
stopAccount: gatewayAdapter.stopAccount,
},
// Outbound adapter for sending messages
outbound: {
deliveryMode: outboundAdapter.deliveryMode,
sendText: async (ctx: {
to: string;
text: string;
accountId: string;
account: PlivoResolvedAccount;
}) => {
const result = await outboundAdapter.sendText({
to: ctx.to,
text: ctx.text,
accountId: ctx.accountId,
account: ctx.account,
});
return { ok: result.ok, externalId: result.externalId, error: result.error };
},
sendMedia: async (ctx: {
to: string;
text?: string;
mediaUrl?: string;
mediaUrls?: string[];
accountId: string;
account: PlivoResolvedAccount;
}) => {
const result = await outboundAdapter.sendMedia({
to: ctx.to,
text: ctx.text,
mediaUrl: ctx.mediaUrl,
mediaUrls: ctx.mediaUrls,
accountId: ctx.accountId,
account: ctx.account,
});
return { ok: result.ok, externalId: result.externalId, error: result.error };
},
resolveTarget: (target: string) => outboundAdapter.resolveTarget(target),
},
// Status adapter for health checks
status: {
probeAccount: async (ctx: { accountId: string }) => {
// Basic connectivity check
return { ok: true, message: "Plivo account is running" };
},
buildAccountSnapshot: (ctx: { accountId: string; account: PlivoResolvedAccount }) => {
return {
accountId: ctx.accountId,
phoneNumber: ctx.account.phoneNumber,
dmPolicy: ctx.account.dmPolicy,
};
},
},
// Heartbeat adapter
heartbeat: {
checkReady: async (params: { accountId: string }) => {
return { ok: true, reason: "Plivo ready" };
},
resolveRecipients: (params: { accountId: string; allowFrom: string[] }) => {
return {
recipients: params.allowFrom,
source: "allowlist",
};
},
},
};

View File

@ -0,0 +1,176 @@
/**
* Plivo Configuration Adapter
* Handles account configuration, credential resolution, and DM policies
*/
import { readFileSync, existsSync } from "node:fs";
import type {
PlivoConfig,
PlivoAccountConfig,
PlivoResolvedAccount,
} from "./types.js";
import { DEFAULT_QUICK_COMMANDS } from "./types.js";
const CHANNEL_ID = "plivo";
const DEFAULT_WEBHOOK_PATH = "/plivo";
/**
* Get Plivo config from Clawdbot config
*/
function getPlivoConfig(cfg: { channels?: Record<string, unknown> }): PlivoConfig | undefined {
return cfg?.channels?.[CHANNEL_ID] as PlivoConfig | undefined;
}
/**
* Read credential from file if path provided
*/
function readCredentialFile(filePath: string | undefined): string | undefined {
if (!filePath) return undefined;
if (!existsSync(filePath)) return undefined;
return readFileSync(filePath, "utf-8").trim();
}
/**
* List all configured account IDs
*/
export function listAccountIds(cfg: { channels?: Record<string, unknown> }): string[] {
const plivoConfig = getPlivoConfig(cfg);
if (!plivoConfig) return [];
// Check for multi-account setup
if (plivoConfig.accounts) {
return Object.keys(plivoConfig.accounts);
}
// Single account (default)
if (plivoConfig.authId || plivoConfig.authIdFile || plivoConfig.phoneNumber) {
return ["default"];
}
return [];
}
/**
* Get account configuration by ID
*/
function getAccountConfig(
cfg: { channels?: Record<string, unknown> },
accountId?: string
): PlivoAccountConfig | undefined {
const plivoConfig = getPlivoConfig(cfg);
if (!plivoConfig) return undefined;
const id = accountId || "default";
// Multi-account lookup
if (plivoConfig.accounts?.[id]) {
return { ...plivoConfig, ...plivoConfig.accounts[id] };
}
// Single account (only for "default")
if (id === "default") {
return plivoConfig;
}
return undefined;
}
/**
* Resolve account configuration with defaults and credential files
*/
export function resolveAccount(
cfg: { channels?: Record<string, unknown> },
accountId?: string
): PlivoResolvedAccount | undefined {
const accountConfig = getAccountConfig(cfg, accountId);
if (!accountConfig) return undefined;
// Resolve credentials (direct or from file)
const authId = accountConfig.authId || readCredentialFile(accountConfig.authIdFile);
const authToken = accountConfig.authToken || readCredentialFile(accountConfig.authTokenFile);
const phoneNumber = accountConfig.phoneNumber;
// Require essential credentials
if (!authId || !authToken || !phoneNumber) {
return undefined;
}
return {
authId,
authToken,
phoneNumber,
webhookUrl: accountConfig.webhookUrl,
webhookPath: accountConfig.webhookPath || DEFAULT_WEBHOOK_PATH,
webhookSecret: accountConfig.webhookSecret,
dmPolicy: accountConfig.dmPolicy || "pairing",
allowFrom: accountConfig.allowFrom || [],
enableQuickCommands: accountConfig.enableQuickCommands ?? true,
quickCommands: accountConfig.quickCommands || DEFAULT_QUICK_COMMANDS,
};
}
/**
* Check if account is configured with required credentials
*/
export function isConfigured(
cfg: { channels?: Record<string, unknown> },
accountId?: string
): boolean {
return resolveAccount(cfg, accountId) !== undefined;
}
/**
* Check if account is enabled
*/
export function isEnabled(
cfg: { channels?: Record<string, unknown> },
accountId?: string
): boolean {
const accountConfig = getAccountConfig(cfg, accountId);
return accountConfig?.enabled !== false;
}
/**
* Get DM allowlist for account
*/
export function resolveAllowFrom(
cfg: { channels?: Record<string, unknown> },
accountId?: string
): string[] {
const account = resolveAccount(cfg, accountId);
return account?.allowFrom || [];
}
/**
* Describe account for UI display
*/
export function describeAccount(
cfg: { channels?: Record<string, unknown> },
accountId?: string
): {
configured: boolean;
enabled: boolean;
phoneNumber?: string;
dmPolicy?: string;
} {
const accountConfig = getAccountConfig(cfg, accountId);
const resolved = resolveAccount(cfg, accountId);
return {
configured: resolved !== undefined,
enabled: accountConfig?.enabled !== false,
phoneNumber: resolved?.phoneNumber,
dmPolicy: resolved?.dmPolicy,
};
}
/**
* Config adapter for Clawdbot channel plugin
*/
export const configAdapter = {
listAccountIds,
resolveAccount,
isConfigured,
resolveAllowFrom,
describeAccount,
};

View File

@ -0,0 +1,161 @@
/**
* Plivo Gateway Adapter
* Handles channel startup, shutdown, and webhook configuration
*/
import * as Plivo from "plivo";
import { setAccountState, removeAccountState } from "./runtime.js";
import { startWebhookServer, autoConfigureWebhooks } from "./webhook.js";
import type { PlivoResolvedAccount, PlivoRuntimeState } from "./types.js";
export type GatewayContext = {
cfg: { channels?: Record<string, unknown> };
accountId: string;
account: PlivoResolvedAccount;
runtime: unknown;
abortSignal?: AbortSignal;
log: (message: string, data?: Record<string, unknown>) => void;
getStatus: () => unknown;
setStatus: (status: Partial<{
running: boolean;
connected: boolean;
lastConnectedAt: number;
lastError: string;
webhookUrl: string;
}>) => void;
onInboundMessage?: (message: {
from: string;
text: string;
accountId: string;
channelId: string;
}) => Promise<string | void>;
};
/**
* Start Plivo account - initialize client and webhook server
*/
export async function startAccount(ctx: GatewayContext): Promise<void> {
const { account, accountId, log, setStatus } = ctx;
log("Starting Plivo account", { accountId, phoneNumber: account.phoneNumber });
// Create Plivo client
const client = new Plivo.Client(account.authId, account.authToken);
// Verify credentials
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (client.accounts as any).get();
log("Plivo credentials verified", { accountId });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
log("Failed to verify Plivo credentials", { error: errorMessage });
setStatus({ running: false, lastError: errorMessage });
throw new Error(`Invalid Plivo credentials: ${errorMessage}`);
}
// Determine webhook URL
const webhookPort = parseInt(process.env.PLIVO_WEBHOOK_PORT || "8787", 10);
const webhookHost = process.env.PLIVO_WEBHOOK_HOST || "0.0.0.0";
const publicUrl = account.webhookUrl || process.env.PUBLIC_URL || `http://localhost:${webhookPort}`;
const fullWebhookUrl = `${publicUrl}${account.webhookPath}`;
// Start webhook server
const { server, stop } = await startWebhookServer({
account,
accountId,
path: account.webhookPath,
port: webhookPort,
host: webhookHost,
onMessage: async (message) => {
// Route to Clawdbot's message handler
if (ctx.onInboundMessage) {
return ctx.onInboundMessage({
from: message.from,
text: message.text,
accountId,
channelId: "plivo",
});
}
return undefined;
},
onError: (error) => {
log("Webhook error", { error: error.message });
setStatus({ lastError: error.message });
},
log,
});
// Auto-configure Plivo phone number to point to our webhook
const configResult = await autoConfigureWebhooks(
client,
account.phoneNumber,
fullWebhookUrl,
log
);
if (!configResult.success) {
log("Webhook auto-configuration failed, manual setup may be required", {
error: configResult.error,
manualUrl: fullWebhookUrl,
});
}
// Store runtime state
const state: PlivoRuntimeState = {
client,
server,
phoneNumber: account.phoneNumber,
webhookConfigured: configResult.success,
};
setAccountState(accountId, state);
// Update channel status
setStatus({
running: true,
connected: true,
lastConnectedAt: Date.now(),
webhookUrl: fullWebhookUrl,
});
log("Plivo account started successfully", {
accountId,
phoneNumber: account.phoneNumber,
webhookUrl: fullWebhookUrl,
webhookConfigured: configResult.success,
});
// Handle abort signal
if (ctx.abortSignal) {
ctx.abortSignal.addEventListener("abort", async () => {
await stop();
removeAccountState(accountId);
setStatus({ running: false, connected: false });
log("Plivo account stopped via abort signal", { accountId });
});
}
}
/**
* Stop Plivo account
*/
export async function stopAccount(ctx: GatewayContext): Promise<void> {
const { accountId, log, setStatus } = ctx;
log("Stopping Plivo account", { accountId });
// Clean up runtime state
removeAccountState(accountId);
setStatus({ running: false, connected: false });
log("Plivo account stopped", { accountId });
}
/**
* Gateway adapter for Clawdbot channel plugin
*/
export const gatewayAdapter = {
startAccount,
stopAccount,
};

View File

@ -0,0 +1,142 @@
/**
* Plivo Outbound Message Adapter
* Handles sending SMS and MMS messages
*/
import * as Plivo from "plivo";
import { getAccountState } from "./runtime.js";
import type { PlivoResolvedAccount } from "./types.js";
export type OutboundContext = {
to: string;
text?: string;
mediaUrl?: string;
mediaUrls?: string[];
accountId: string;
account: PlivoResolvedAccount;
};
export type OutboundResult = {
ok: boolean;
externalId?: string;
error?: string;
};
/**
* Get Plivo client for account
*/
function getClient(accountId: string): Plivo.Client | undefined {
const state = getAccountState(accountId);
return state?.client as Plivo.Client | undefined;
}
/**
* Normalize phone number to E.164 format
*/
function normalizePhoneNumber(phoneNumber: string): string {
// Remove any non-digit characters except leading +
let normalized = phoneNumber.replace(/[^\d+]/g, "");
// Ensure it starts with +
if (!normalized.startsWith("+")) {
// Assume US number if 10 digits
if (normalized.length === 10) {
normalized = "+1" + normalized;
} else {
normalized = "+" + normalized;
}
}
return normalized;
}
/**
* Send SMS text message
*/
export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
const client = getClient(ctx.accountId);
if (!client) {
return { ok: false, error: "Plivo client not initialized" };
}
if (!ctx.text) {
return { ok: false, error: "No text content provided" };
}
const to = normalizePhoneNumber(ctx.to);
const from = ctx.account.phoneNumber;
try {
const response = await client.messages.create(from, to, ctx.text);
const messageId = Array.isArray(response.messageUuid)
? response.messageUuid[0]
: response.messageUuid;
return { ok: true, externalId: messageId };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return { ok: false, error: errorMessage };
}
}
/**
* Send MMS with media attachments
*/
export async function sendMedia(ctx: OutboundContext): Promise<OutboundResult> {
const client = getClient(ctx.accountId);
if (!client) {
return { ok: false, error: "Plivo client not initialized" };
}
const mediaUrls = ctx.mediaUrls || (ctx.mediaUrl ? [ctx.mediaUrl] : []);
if (mediaUrls.length === 0) {
return { ok: false, error: "No media URLs provided" };
}
const to = normalizePhoneNumber(ctx.to);
const from = ctx.account.phoneNumber;
const text = ctx.text || "";
try {
const response = await client.messages.create(from, to, text, {
type: "mms",
media_urls: mediaUrls,
});
const messageId = Array.isArray(response.messageUuid)
? response.messageUuid[0]
: response.messageUuid;
return { ok: true, externalId: messageId };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return { ok: false, error: errorMessage };
}
}
/**
* Resolve target phone number
*/
export function resolveTarget(target: string): { ok: boolean; to?: string; error?: string } {
if (!target) {
return { ok: false, error: "No target provided" };
}
// Validate it looks like a phone number
const digitsOnly = target.replace(/\D/g, "");
if (digitsOnly.length < 10 || digitsOnly.length > 15) {
return { ok: false, error: "Invalid phone number format" };
}
return { ok: true, to: normalizePhoneNumber(target) };
}
/**
* Outbound adapter for Clawdbot channel plugin
*/
export const outboundAdapter = {
deliveryMode: "gateway" as const,
sendText,
sendMedia,
resolveTarget,
};

View File

@ -0,0 +1,35 @@
/**
* Plivo Runtime State Management
*/
import type { PlivoRuntimeState } from "./types.js";
// Runtime reference from Clawdbot plugin API
let plivoRuntime: unknown = null;
// Per-account runtime state
const accountStates = new Map<string, PlivoRuntimeState>();
export function setPlivoRuntime(runtime: unknown): void {
plivoRuntime = runtime;
}
export function getPlivoRuntime(): unknown {
return plivoRuntime;
}
export function setAccountState(accountId: string, state: PlivoRuntimeState): void {
accountStates.set(accountId, state);
}
export function getAccountState(accountId: string): PlivoRuntimeState | undefined {
return accountStates.get(accountId);
}
export function removeAccountState(accountId: string): void {
accountStates.delete(accountId);
}
export function getAllAccountStates(): Map<string, PlivoRuntimeState> {
return accountStates;
}

View File

@ -0,0 +1,89 @@
/**
* Plivo SMS Channel Types
*/
// Plivo account configuration
export type PlivoAccountConfig = {
name?: string;
enabled?: boolean;
authId?: string;
authToken?: string;
authIdFile?: string;
authTokenFile?: string;
phoneNumber?: string;
webhookUrl?: string;
webhookPath?: string;
webhookSecret?: string;
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: string[];
enableQuickCommands?: boolean;
quickCommands?: QuickCommand[];
};
// Multi-account configuration
export type PlivoConfig = {
accounts?: Record<string, PlivoAccountConfig>;
} & PlivoAccountConfig;
// Resolved account with required fields
export type PlivoResolvedAccount = {
authId: string;
authToken: string;
phoneNumber: string;
webhookUrl?: string;
webhookPath: string;
webhookSecret?: string;
dmPolicy: "pairing" | "allowlist" | "open" | "disabled";
allowFrom: string[];
enableQuickCommands: boolean;
quickCommands: QuickCommand[];
};
// Quick command shortcut
export type QuickCommand = {
trigger: string;
fullCommand: string;
description: string;
};
// Inbound webhook payload from Plivo
export type PlivoInboundWebhook = {
From: string;
To: string;
Text: string;
MessageUUID: string;
Type: "sms" | "mms";
MediaUrl0?: string;
MediaUrl1?: string;
MediaUrl2?: string;
MediaContentType0?: string;
MediaContentType1?: string;
MediaContentType2?: string;
};
// Delivery report from Plivo
export type PlivoDeliveryReport = {
MessageUUID: string;
Status: string;
From: string;
To: string;
ErrorCode?: string;
};
// Runtime state for a Plivo account
export type PlivoRuntimeState = {
client: unknown; // Plivo.Client
server?: unknown; // HTTP server
phoneNumber: string;
webhookConfigured: boolean;
};
// Default quick commands
export const DEFAULT_QUICK_COMMANDS: QuickCommand[] = [
{ trigger: "cal", fullCommand: "show my calendar for today", description: "View today's calendar" },
{ trigger: "todo", fullCommand: "show my todo list", description: "View todo list" },
{ trigger: "weather", fullCommand: "what's the weather today", description: "Get weather forecast" },
{ trigger: "remind", fullCommand: "set a reminder", description: "Create a reminder" },
{ trigger: "note", fullCommand: "save a note", description: "Save a quick note" },
{ trigger: "help", fullCommand: "show available commands", description: "List all quick commands" },
];

View File

@ -0,0 +1,292 @@
/**
* Plivo Webhook Handler
* Handles inbound SMS/MMS messages and delivery reports
*/
import { createServer, IncomingMessage, ServerResponse } from "node:http";
import { parse as parseUrl } from "node:url";
import * as Plivo from "plivo";
import type { PlivoResolvedAccount, PlivoInboundWebhook, QuickCommand } from "./types.js";
export type WebhookMessageHandler = (message: {
from: string;
to: string;
text: string;
messageId: string;
isMedia: boolean;
mediaUrls: string[];
accountId: string;
}) => Promise<string | void>;
export type WebhookServerOptions = {
account: PlivoResolvedAccount;
accountId: string;
path: string;
port: number;
host?: string;
onMessage: WebhookMessageHandler;
onError?: (error: Error) => void;
log?: (message: string, data?: Record<string, unknown>) => void;
};
/**
* Parse URL-encoded body from request
*/
async function parseBody(req: IncomingMessage): Promise<Record<string, string>> {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => {
try {
const params = new URLSearchParams(body);
const result: Record<string, string> = {};
for (const [key, value] of params) {
result[key] = value;
}
resolve(result);
} catch (error) {
reject(error);
}
});
req.on("error", reject);
});
}
/**
* Extract media URLs from webhook payload
*/
function extractMediaUrls(body: Record<string, string>): string[] {
const urls: string[] = [];
for (let i = 0; i < 10; i++) {
const url = body[`MediaUrl${i}`];
if (url) urls.push(url);
}
return urls;
}
/**
* Process quick commands - expand shortcuts to full commands
*/
function processQuickCommand(
text: string,
commands: QuickCommand[],
enabled: boolean
): string {
if (!enabled || !commands.length) return text;
const commandMap = new Map(commands.map((c) => [c.trigger.toLowerCase(), c]));
const trimmedText = text.trim().toLowerCase();
// Exact match
const exactMatch = commandMap.get(trimmedText);
if (exactMatch) return exactMatch.fullCommand;
// Prefix match (e.g., "cal tomorrow" -> "show my calendar for tomorrow")
const words = trimmedText.split(/\s+/);
const prefixMatch = commandMap.get(words[0]);
if (prefixMatch && words.length > 1) {
return `${prefixMatch.fullCommand} ${words.slice(1).join(" ")}`;
}
return text;
}
/**
* Validate Plivo webhook signature
*/
function validateSignature(
req: IncomingMessage,
body: Record<string, string>,
secret: string | undefined
): boolean {
if (!secret) return true; // Skip validation if no secret configured
const signature = req.headers["x-plivo-signature-v3"] as string;
const nonce = req.headers["x-plivo-signature-v3-nonce"] as string;
if (!signature || !nonce) return false;
const fullUrl = `http://${req.headers.host}${req.url}`;
try {
const isValid = Plivo.validateV3Signature(
req.method || "POST",
fullUrl,
nonce,
secret,
signature,
body
);
return Boolean(isValid);
} catch {
return false;
}
}
/**
* Create and start webhook server
*/
export async function startWebhookServer(
options: WebhookServerOptions
): Promise<{ server: ReturnType<typeof createServer>; stop: () => Promise<void> }> {
const {
account,
accountId,
path,
port,
host = "0.0.0.0",
onMessage,
onError,
log = console.log,
} = options;
const inboundPath = path;
const statusPath = `${path}/status`;
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
const parsedUrl = parseUrl(req.url || "", true);
const urlPath = parsedUrl.pathname || "";
// Health check
if (urlPath === `${path}/health` && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "healthy", channel: "plivo", accountId }));
return;
}
// Only handle POST requests
if (req.method !== "POST") {
res.writeHead(405);
res.end("Method Not Allowed");
return;
}
try {
const body = await parseBody(req);
// Validate signature in production
if (account.webhookSecret && !validateSignature(req, body, account.webhookSecret)) {
log("Invalid webhook signature", { path: urlPath });
res.writeHead(401);
res.end("Unauthorized");
return;
}
// Inbound message
if (urlPath === inboundPath) {
const webhook: PlivoInboundWebhook = {
From: body.From,
To: body.To,
Text: body.Text || "",
MessageUUID: body.MessageUUID,
Type: (body.Type as "sms" | "mms") || "sms",
};
const mediaUrls = extractMediaUrls(body);
const isMedia = webhook.Type === "mms" || mediaUrls.length > 0;
// Process quick commands
const processedText = processQuickCommand(
webhook.Text,
account.quickCommands,
account.enableQuickCommands
);
log("Received inbound message", {
from: webhook.From,
messageId: webhook.MessageUUID,
isMedia,
});
// Call message handler
const reply = await onMessage({
from: webhook.From,
to: webhook.To,
text: processedText,
messageId: webhook.MessageUUID,
isMedia,
mediaUrls,
accountId,
});
// If handler returns a string, send as immediate reply via XML
if (reply && typeof reply === "string") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = (Plivo as any).Response();
response.addMessage(reply, {
src: webhook.To,
dst: webhook.From,
});
res.writeHead(200, { "Content-Type": "application/xml" });
res.end(response.toXML());
return;
}
res.writeHead(200);
res.end("OK");
return;
}
// Delivery status report
if (urlPath === statusPath) {
log("Received delivery report", {
messageId: body.MessageUUID,
status: body.Status,
});
res.writeHead(200);
res.end("OK");
return;
}
res.writeHead(404);
res.end("Not Found");
} catch (error) {
log("Webhook error", { error: String(error) });
onError?.(error as Error);
res.writeHead(500);
res.end("Internal Server Error");
}
});
return new Promise((resolve, reject) => {
server.on("error", reject);
server.listen(port, host, () => {
log(`Plivo webhook server started`, { port, path: inboundPath });
resolve({
server,
stop: () =>
new Promise<void>((res) => {
server.close(() => res());
}),
});
});
});
}
/**
* Auto-configure Plivo phone number webhooks
*/
export async function autoConfigureWebhooks(
client: Plivo.Client,
phoneNumber: string,
webhookUrl: string,
log?: (message: string, data?: Record<string, unknown>) => void
): Promise<{ success: boolean; error?: string }> {
const normalizedNumber = phoneNumber.replace(/^\+/, "");
const logger = log || console.log;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (client.numbers as any).update(normalizedNumber, {
message_url: webhookUrl,
message_method: "POST",
});
logger("Auto-configured Plivo webhooks", { phoneNumber, webhookUrl });
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
logger("Failed to auto-configure webhooks", { error: errorMessage });
return { success: false, error: errorMessage };
}
}

View File

@ -0,0 +1,209 @@
/**
* Integration test for Plivo SMS extension
* Tests core functionality without requiring real Plivo credentials
*/
import { createServer } from "node:http";
import { plivoPlugin } from "./src/channel.js";
import { startWebhookServer } from "./src/webhook.js";
import type { PlivoResolvedAccount } from "./src/types.js";
const TEST_PORT = 19876;
// Mock account config
const mockAccount: PlivoResolvedAccount = {
authId: "test_auth_id",
authToken: "test_auth_token",
phoneNumber: "+15551234567",
webhookPath: "/plivo",
dmPolicy: "open",
allowFrom: [],
enableQuickCommands: true,
quickCommands: [
{ trigger: "cal", fullCommand: "show my calendar for today", description: "Calendar" },
{ trigger: "todo", fullCommand: "show my todo list", description: "Todos" },
],
};
// Mock Clawdbot config
const mockConfig = {
channels: {
plivo: {
authId: "test_auth_id",
authToken: "test_auth_token",
phoneNumber: "+15551234567",
enabled: true,
},
},
};
async function testConfigAdapter() {
console.log("\n📋 Testing Config Adapter...");
// Test listAccountIds
const accountIds = plivoPlugin.config.listAccountIds(mockConfig);
console.log(` ✓ listAccountIds: ${JSON.stringify(accountIds)}`);
if (accountIds.length !== 1 || accountIds[0] !== "default") {
throw new Error("listAccountIds failed");
}
// Test isConfigured
const isConfigured = plivoPlugin.config.isConfigured(mockConfig);
console.log(` ✓ isConfigured: ${isConfigured}`);
if (!isConfigured) {
throw new Error("isConfigured should be true");
}
// Test describeAccount
const description = plivoPlugin.config.describeAccount(mockConfig);
console.log(` ✓ describeAccount: ${JSON.stringify(description)}`);
console.log(" ✅ Config adapter tests passed!");
}
async function testOutboundAdapter() {
console.log("\n📤 Testing Outbound Adapter...");
// Test resolveTarget
const validTarget = plivoPlugin.outbound.resolveTarget("+15559876543");
console.log(` ✓ resolveTarget (valid): ${JSON.stringify(validTarget)}`);
if (!validTarget.ok) {
throw new Error("resolveTarget should succeed for valid number");
}
const invalidTarget = plivoPlugin.outbound.resolveTarget("abc");
console.log(` ✓ resolveTarget (invalid): ${JSON.stringify(invalidTarget)}`);
if (invalidTarget.ok) {
throw new Error("resolveTarget should fail for invalid number");
}
// Test 10-digit US number normalization
const usNumber = plivoPlugin.outbound.resolveTarget("5559876543");
console.log(` ✓ resolveTarget (10-digit): ${JSON.stringify(usNumber)}`);
if (usNumber.to !== "+15559876543") {
throw new Error("10-digit number should be normalized to +1");
}
console.log(" ✅ Outbound adapter tests passed!");
}
async function testWebhookServer() {
console.log("\n🌐 Testing Webhook Server...");
let receivedMessage: unknown = null;
const { server, stop } = await startWebhookServer({
account: mockAccount,
accountId: "test",
path: "/plivo",
port: TEST_PORT,
onMessage: async (message) => {
receivedMessage = message;
return "Test reply";
},
log: () => {}, // Silent logging for tests
});
console.log(` ✓ Webhook server started on port ${TEST_PORT}`);
// Test health endpoint
const healthResponse = await fetch(`http://localhost:${TEST_PORT}/plivo/health`);
const healthData = await healthResponse.json();
console.log(` ✓ Health check: ${JSON.stringify(healthData)}`);
if (healthData.status !== "healthy") {
throw new Error("Health check failed");
}
// Test inbound SMS webhook
const inboundPayload = new URLSearchParams({
From: "+15559876543",
To: "+15551234567",
Text: "Hello world",
MessageUUID: "test-uuid-123",
Type: "sms",
});
const inboundResponse = await fetch(`http://localhost:${TEST_PORT}/plivo`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: inboundPayload.toString(),
});
console.log(` ✓ Inbound webhook status: ${inboundResponse.status}`);
if (inboundResponse.status !== 200) {
throw new Error("Inbound webhook failed");
}
// Verify message was received
if (!receivedMessage) {
throw new Error("Message handler was not called");
}
console.log(` ✓ Received message: ${JSON.stringify(receivedMessage)}`);
// Test quick command expansion
receivedMessage = null;
const quickCommandPayload = new URLSearchParams({
From: "+15559876543",
To: "+15551234567",
Text: "cal",
MessageUUID: "test-uuid-456",
Type: "sms",
});
await fetch(`http://localhost:${TEST_PORT}/plivo`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: quickCommandPayload.toString(),
});
const msg = receivedMessage as { text: string };
console.log(` ✓ Quick command expanded: "${msg?.text}"`);
if (msg?.text !== "show my calendar for today") {
throw new Error("Quick command not expanded correctly");
}
// Stop server
await stop();
console.log(" ✓ Webhook server stopped");
console.log(" ✅ Webhook server tests passed!");
}
async function testCapabilities() {
console.log("\n🔧 Testing Channel Capabilities...");
console.log(` ✓ Channel ID: ${plivoPlugin.id}`);
console.log(` ✓ Supports text: ${plivoPlugin.capabilities.supportsText}`);
console.log(` ✓ Supports media: ${plivoPlugin.capabilities.supportsMedia}`);
console.log(` ✓ Max text length: ${plivoPlugin.capabilities.maxTextLength}`);
if (!plivoPlugin.capabilities.supportsText) {
throw new Error("Should support text");
}
if (!plivoPlugin.capabilities.supportsMedia) {
throw new Error("Should support media");
}
console.log(" ✅ Capabilities tests passed!");
}
async function runAllTests() {
console.log("🧪 Plivo SMS Extension Integration Tests\n");
console.log("=========================================");
try {
await testCapabilities();
await testConfigAdapter();
await testOutboundAdapter();
await testWebhookServer();
console.log("\n=========================================");
console.log("✅ All integration tests passed!\n");
process.exit(0);
} catch (error) {
console.error("\n❌ Test failed:", error);
process.exit(1);
}
}
runAllTests();

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"resolveJsonModule": true
},
"include": ["index.ts", "src/**/*"],
"exclude": ["node_modules", "dist"]
}