refactor(channels): rename plivo extension to sms with provider pattern

Restructure the SMS channel extension to follow the voice-call pattern:
- Rename from "plivo" to "sms" (technology-based naming)
- Add provider abstraction (SMSProvider interface)
- Implement PlivoProvider and MockProvider
- Support for future providers (Twilio, etc.)

This makes the extension more extensible and consistent with
the voice-call extension architecture.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Narayan Vyas 2026-01-26 17:47:41 -08:00
parent c7fec1d3d9
commit 15b41de880
21 changed files with 1204 additions and 808 deletions

View File

@ -1,45 +0,0 @@
/**
* 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";

View File

@ -1,29 +0,0 @@
{
"name": "@clawdbot/plivo",
"version": "2026.1.25",
"type": "module",
"description": "Clawdbot Plivo SMS/MMS channel plugin",
"clawdbot": {
"extensions": ["./index.ts"],
"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"
},
"devDependencies": {
"clawdbot": "workspace:*"
}
}

View File

@ -1,35 +0,0 @@
/**
* 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

@ -1,292 +0,0 @@
/**
* 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

@ -1,209 +0,0 @@
/**
* 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,148 @@
{
"id": "sms",
"uiHints": {
"provider": {
"label": "Provider",
"help": "Use plivo, twilio, or mock for dev/no-network."
},
"phoneNumber": {
"label": "Phone Number",
"placeholder": "+15550001234"
},
"webhookPath": {
"label": "Webhook Path"
},
"webhookUrl": {
"label": "Public Webhook URL"
},
"webhookSecret": {
"label": "Webhook Secret",
"sensitive": true
},
"dmPolicy": {
"label": "DM Policy"
},
"allowFrom": {
"label": "Allowlist"
},
"enableQuickCommands": {
"label": "Enable Quick Commands"
},
"plivo.authId": {
"label": "Plivo Auth ID",
"sensitive": true
},
"plivo.authToken": {
"label": "Plivo Auth Token",
"sensitive": true
},
"twilio.accountSid": {
"label": "Twilio Account SID"
},
"twilio.authToken": {
"label": "Twilio Auth Token",
"sensitive": true
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"provider": {
"type": "string",
"enum": ["plivo", "twilio", "mock"]
},
"phoneNumber": {
"type": "string",
"pattern": "^\\+[1-9]\\d{1,14}$"
},
"webhookPath": {
"type": "string"
},
"webhookUrl": {
"type": "string"
},
"webhookSecret": {
"type": "string"
},
"dmPolicy": {
"type": "string",
"enum": ["pairing", "allowlist", "open", "disabled"]
},
"allowFrom": {
"type": "array",
"items": {
"type": "string",
"pattern": "^\\+[1-9]\\d{1,14}$"
}
},
"enableQuickCommands": {
"type": "boolean"
},
"quickCommands": {
"type": "array",
"items": {
"type": "object",
"properties": {
"trigger": { "type": "string" },
"fullCommand": { "type": "string" },
"description": { "type": "string" }
}
}
},
"plivo": {
"type": "object",
"additionalProperties": false,
"properties": {
"authId": {
"type": "string"
},
"authToken": {
"type": "string"
},
"authIdFile": {
"type": "string"
},
"authTokenFile": {
"type": "string"
}
}
},
"twilio": {
"type": "object",
"additionalProperties": false,
"properties": {
"accountSid": {
"type": "string"
},
"authToken": {
"type": "string"
}
}
},
"accounts": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"name": { "type": "string" },
"enabled": { "type": "boolean" },
"provider": { "type": "string" },
"phoneNumber": { "type": "string" },
"webhookPath": { "type": "string" },
"webhookUrl": { "type": "string" },
"webhookSecret": { "type": "string" },
"dmPolicy": { "type": "string" },
"allowFrom": { "type": "array" },
"enableQuickCommands": { "type": "boolean" },
"plivo": { "type": "object" },
"twilio": { "type": "object" }
}
}
}
}
}
}

47
extensions/sms/index.ts Normal file
View File

@ -0,0 +1,47 @@
/**
* SMS Channel Extension for Clawdbot
*
* This extension provides SMS/MMS messaging capabilities via various providers
* (Plivo, Twilio, etc.), 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 provider webhooks
* - Multi-account and multi-provider support
*/
import { smsPlugin } from "./src/channel.js";
import { setSMSRuntime } from "./src/runtime.js";
// Plugin definition for Clawdbot
const plugin = {
id: "sms",
name: "SMS",
description: "SMS/MMS channel - Universal phone access to your AI assistant",
register(api: {
runtime: unknown;
registerChannel: (opts: { plugin: typeof smsPlugin }) => void;
}) {
// Store runtime reference for access in adapters
setSMSRuntime(api.runtime);
// Register the SMS channel
api.registerChannel({ plugin: smsPlugin });
},
};
export default plugin;
// Re-export types and utilities for external use
export { smsPlugin } from "./src/channel.js";
export type {
SMSConfig,
SMSAccountConfig,
SMSResolvedAccount,
QuickCommand,
} from "./src/types.js";
export type { SMSProvider, ProviderName } from "./src/providers/index.js";
export { PlivoProvider } from "./src/providers/index.js";

View File

@ -0,0 +1,29 @@
{
"name": "@clawdbot/sms",
"version": "2026.1.25",
"type": "module",
"description": "Clawdbot SMS/MMS channel plugin with multi-provider support (Plivo, Twilio)",
"clawdbot": {
"extensions": ["./index.ts"],
"channel": {
"id": "sms",
"label": "SMS",
"selectionLabel": "SMS (Plivo/Twilio)",
"docsPath": "/channels/sms",
"docsLabel": "sms",
"blurb": "SMS/MMS messaging; universal phone access to your AI assistant.",
"order": 45
},
"install": {
"npmSpec": "@clawdbot/sms",
"localPath": "extensions/sms",
"defaultChoice": "local"
}
},
"dependencies": {
"plivo": "^4.58.0"
},
"devDependencies": {
"clawdbot": "workspace:*"
}
}

View File

@ -1,25 +1,25 @@
/**
* Plivo SMS Channel Plugin
* 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";
import type { SMSResolvedAccount } from "./types.js";
const CHANNEL_ID = "plivo";
const CHANNEL_ID = "sms";
/**
* 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.",
label: "SMS",
selectionLabel: "SMS (Plivo/Twilio)",
docsPath: "/channels/sms",
docsLabel: "sms",
blurb: "SMS/MMS messaging; universal phone access to your AI assistant.",
};
/**
@ -44,9 +44,9 @@ const capabilities = {
};
/**
* Plivo channel plugin definition
* SMS channel plugin definition
*/
export const plivoPlugin = {
export const smsPlugin = {
id: CHANNEL_ID,
meta,
capabilities,
@ -83,7 +83,7 @@ export const plivoPlugin = {
to: string;
text: string;
accountId: string;
account: PlivoResolvedAccount;
account: SMSResolvedAccount;
}) => {
const result = await outboundAdapter.sendText({
to: ctx.to,
@ -100,7 +100,7 @@ export const plivoPlugin = {
mediaUrl?: string;
mediaUrls?: string[];
accountId: string;
account: PlivoResolvedAccount;
account: SMSResolvedAccount;
}) => {
const result = await outboundAdapter.sendMedia({
to: ctx.to,
@ -118,14 +118,14 @@ export const plivoPlugin = {
// Status adapter for health checks
status: {
probeAccount: async (ctx: { accountId: string }) => {
// Basic connectivity check
return { ok: true, message: "Plivo account is running" };
probeAccount: async (_ctx: { accountId: string }) => {
return { ok: true, message: "SMS account is running" };
},
buildAccountSnapshot: (ctx: { accountId: string; account: PlivoResolvedAccount }) => {
buildAccountSnapshot: (ctx: { accountId: string; account: SMSResolvedAccount }) => {
return {
accountId: ctx.accountId,
provider: ctx.account.provider,
phoneNumber: ctx.account.phoneNumber,
dmPolicy: ctx.account.dmPolicy,
};
@ -134,8 +134,8 @@ export const plivoPlugin = {
// Heartbeat adapter
heartbeat: {
checkReady: async (params: { accountId: string }) => {
return { ok: true, reason: "Plivo ready" };
checkReady: async (_params: { accountId: string }) => {
return { ok: true, reason: "SMS ready" };
},
resolveRecipients: (params: { accountId: string; allowFrom: string[] }) => {

View File

@ -1,24 +1,20 @@
/**
* Plivo Configuration Adapter
* SMS 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 type { SMSConfig, SMSAccountConfig, SMSResolvedAccount } from "./types.js";
import { DEFAULT_QUICK_COMMANDS } from "./types.js";
const CHANNEL_ID = "plivo";
const DEFAULT_WEBHOOK_PATH = "/plivo";
const CHANNEL_ID = "sms";
const DEFAULT_WEBHOOK_PATH = "/sms";
/**
* Get Plivo config from Clawdbot config
* Get SMS config from Clawdbot config
*/
function getPlivoConfig(cfg: { channels?: Record<string, unknown> }): PlivoConfig | undefined {
return cfg?.channels?.[CHANNEL_ID] as PlivoConfig | undefined;
function getSMSConfig(cfg: { channels?: Record<string, unknown> }): SMSConfig | undefined {
return cfg?.channels?.[CHANNEL_ID] as SMSConfig | undefined;
}
/**
@ -34,16 +30,16 @@ function readCredentialFile(filePath: string | undefined): string | undefined {
* List all configured account IDs
*/
export function listAccountIds(cfg: { channels?: Record<string, unknown> }): string[] {
const plivoConfig = getPlivoConfig(cfg);
if (!plivoConfig) return [];
const smsConfig = getSMSConfig(cfg);
if (!smsConfig) return [];
// Check for multi-account setup
if (plivoConfig.accounts) {
return Object.keys(plivoConfig.accounts);
if (smsConfig.accounts) {
return Object.keys(smsConfig.accounts);
}
// Single account (default)
if (plivoConfig.authId || plivoConfig.authIdFile || plivoConfig.phoneNumber) {
if (smsConfig.phoneNumber || smsConfig.plivo || smsConfig.twilio) {
return ["default"];
}
@ -56,20 +52,20 @@ export function listAccountIds(cfg: { channels?: Record<string, unknown> }): str
function getAccountConfig(
cfg: { channels?: Record<string, unknown> },
accountId?: string
): PlivoAccountConfig | undefined {
const plivoConfig = getPlivoConfig(cfg);
if (!plivoConfig) return undefined;
): SMSAccountConfig | undefined {
const smsConfig = getSMSConfig(cfg);
if (!smsConfig) return undefined;
const id = accountId || "default";
// Multi-account lookup
if (plivoConfig.accounts?.[id]) {
return { ...plivoConfig, ...plivoConfig.accounts[id] };
if (smsConfig.accounts?.[id]) {
return { ...smsConfig, ...smsConfig.accounts[id] };
}
// Single account (only for "default")
if (id === "default") {
return plivoConfig;
return smsConfig;
}
return undefined;
@ -81,23 +77,39 @@ function getAccountConfig(
export function resolveAccount(
cfg: { channels?: Record<string, unknown> },
accountId?: string
): PlivoResolvedAccount | undefined {
): SMSResolvedAccount | 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;
if (!phoneNumber) return undefined;
// Require essential credentials
if (!authId || !authToken || !phoneNumber) {
return undefined;
// Determine provider
const provider = accountConfig.provider || "plivo";
// Resolve provider-specific config
let providerConfig: SMSResolvedAccount["providerConfig"];
if (provider === "plivo") {
const plivoConfig = accountConfig.plivo || {};
const authId = plivoConfig.authId || readCredentialFile(plivoConfig.authIdFile);
const authToken = plivoConfig.authToken || readCredentialFile(plivoConfig.authTokenFile);
if (!authId || !authToken) return undefined;
providerConfig = { authId, authToken };
} else if (provider === "twilio") {
const twilioConfig = accountConfig.twilio || {};
if (!twilioConfig.accountSid || !twilioConfig.authToken) return undefined;
providerConfig = twilioConfig;
} else {
// Mock or unknown provider
providerConfig = {};
}
return {
authId,
authToken,
provider,
phoneNumber,
webhookUrl: accountConfig.webhookUrl,
webhookPath: accountConfig.webhookPath || DEFAULT_WEBHOOK_PATH,
@ -106,6 +118,7 @@ export function resolveAccount(
allowFrom: accountConfig.allowFrom || [],
enableQuickCommands: accountConfig.enableQuickCommands ?? true,
quickCommands: accountConfig.quickCommands || DEFAULT_QUICK_COMMANDS,
providerConfig,
};
}
@ -150,6 +163,7 @@ export function describeAccount(
): {
configured: boolean;
enabled: boolean;
provider?: string;
phoneNumber?: string;
dmPolicy?: string;
} {
@ -159,6 +173,7 @@ export function describeAccount(
return {
configured: resolved !== undefined,
enabled: accountConfig?.enabled !== false,
provider: resolved?.provider,
phoneNumber: resolved?.phoneNumber,
dmPolicy: resolved?.dmPolicy,
};

View File

@ -1,17 +1,18 @@
/**
* Plivo Gateway Adapter
* SMS 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";
import { startWebhookServer } from "./webhook.js";
import { PlivoProvider, MockProvider } from "./providers/index.js";
import type { SMSProvider } from "./providers/index.js";
import type { SMSResolvedAccount, SMSRuntimeState, PlivoProviderConfig } from "./types.js";
export type GatewayContext = {
cfg: { channels?: Record<string, unknown> };
accountId: string;
account: PlivoResolvedAccount;
account: SMSResolvedAccount;
runtime: unknown;
abortSignal?: AbortSignal;
log: (message: string, data?: Record<string, unknown>) => void;
@ -32,36 +33,54 @@ export type GatewayContext = {
};
/**
* Start Plivo account - initialize client and webhook server
* Create provider instance based on configuration
*/
function createProvider(account: SMSResolvedAccount): SMSProvider {
switch (account.provider) {
case "plivo": {
const config = account.providerConfig as PlivoProviderConfig;
if (!config.authId || !config.authToken) {
throw new Error("Plivo provider requires authId and authToken");
}
return new PlivoProvider({ authId: config.authId, authToken: config.authToken });
}
case "mock":
return new MockProvider();
default:
throw new Error(`Unknown SMS provider: ${account.provider}`);
}
}
/**
* Start SMS account - initialize provider 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 });
log("Starting SMS account", { accountId, provider: account.provider, phoneNumber: account.phoneNumber });
// Create Plivo client
const client = new Plivo.Client(account.authId, account.authToken);
// Create and initialize provider
const provider = createProvider(account);
// Verify credentials
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (client.accounts as any).get();
log("Plivo credentials verified", { accountId });
await provider.initialize();
log("SMS provider initialized", { accountId, provider: account.provider });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
log("Failed to verify Plivo credentials", { error: errorMessage });
log("Failed to initialize SMS provider", { error: errorMessage });
setStatus({ running: false, lastError: errorMessage });
throw new Error(`Invalid Plivo credentials: ${errorMessage}`);
throw new Error(`Invalid SMS 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 webhookPort = parseInt(process.env.SMS_WEBHOOK_PORT || "8787", 10);
const webhookHost = process.env.SMS_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({
provider,
account,
accountId,
path: account.webhookPath,
@ -74,7 +93,7 @@ export async function startAccount(ctx: GatewayContext): Promise<void> {
from: message.from,
text: message.text,
accountId,
channelId: "plivo",
channelId: "sms",
});
}
return undefined;
@ -86,13 +105,11 @@ export async function startAccount(ctx: GatewayContext): Promise<void> {
log,
});
// Auto-configure Plivo phone number to point to our webhook
const configResult = await autoConfigureWebhooks(
client,
account.phoneNumber,
fullWebhookUrl,
log
);
// Auto-configure provider webhooks
const configResult = await provider.configureWebhook({
phoneNumber: account.phoneNumber,
webhookUrl: fullWebhookUrl,
});
if (!configResult.success) {
log("Webhook auto-configuration failed, manual setup may be required", {
@ -102,8 +119,8 @@ export async function startAccount(ctx: GatewayContext): Promise<void> {
}
// Store runtime state
const state: PlivoRuntimeState = {
client,
const state: SMSRuntimeState = {
provider,
server,
phoneNumber: account.phoneNumber,
webhookConfigured: configResult.success,
@ -118,8 +135,9 @@ export async function startAccount(ctx: GatewayContext): Promise<void> {
webhookUrl: fullWebhookUrl,
});
log("Plivo account started successfully", {
log("SMS account started successfully", {
accountId,
provider: account.provider,
phoneNumber: account.phoneNumber,
webhookUrl: fullWebhookUrl,
webhookConfigured: configResult.success,
@ -131,25 +149,25 @@ export async function startAccount(ctx: GatewayContext): Promise<void> {
await stop();
removeAccountState(accountId);
setStatus({ running: false, connected: false });
log("Plivo account stopped via abort signal", { accountId });
log("SMS account stopped via abort signal", { accountId });
});
}
}
/**
* Stop Plivo account
* Stop SMS account
*/
export async function stopAccount(ctx: GatewayContext): Promise<void> {
const { accountId, log, setStatus } = ctx;
log("Stopping Plivo account", { accountId });
log("Stopping SMS account", { accountId });
// Clean up runtime state
removeAccountState(accountId);
setStatus({ running: false, connected: false });
log("Plivo account stopped", { accountId });
log("SMS account stopped", { accountId });
}
/**

View File

@ -1,11 +1,10 @@
/**
* Plivo Outbound Message Adapter
* SMS 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";
import type { SMSResolvedAccount } from "./types.js";
export type OutboundContext = {
to: string;
@ -13,7 +12,7 @@ export type OutboundContext = {
mediaUrl?: string;
mediaUrls?: string[];
accountId: string;
account: PlivoResolvedAccount;
account: SMSResolvedAccount;
};
export type OutboundResult = {
@ -22,14 +21,6 @@ export type OutboundResult = {
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
*/
@ -54,9 +45,9 @@ function normalizePhoneNumber(phoneNumber: string): string {
* 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" };
const state = getAccountState(ctx.accountId);
if (!state?.provider) {
return { ok: false, error: "SMS provider not initialized" };
}
if (!ctx.text) {
@ -66,26 +57,22 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
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;
const result = await state.provider.sendText({ from, to, text: ctx.text });
return { ok: true, externalId: messageId };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return { ok: false, error: errorMessage };
}
return {
ok: result.ok,
externalId: result.messageId,
error: result.error,
};
}
/**
* 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 state = getAccountState(ctx.accountId);
if (!state?.provider) {
return { ok: false, error: "SMS provider not initialized" };
}
const mediaUrls = ctx.mediaUrls || (ctx.mediaUrl ? [ctx.mediaUrl] : []);
@ -95,23 +82,19 @@ export async function sendMedia(ctx: OutboundContext): Promise<OutboundResult> {
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 result = await state.provider.sendMedia({
from,
to,
text: ctx.text,
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 };
}
return {
ok: result.ok,
externalId: result.messageId,
error: result.error,
};
}
/**

View File

@ -0,0 +1,109 @@
/**
* Abstract base interface for SMS providers.
*
* Each provider (Plivo, Twilio, etc.) implements this interface to provide
* a consistent API for the SMS channel.
*/
import type { IncomingMessage } from "node:http";
export type ProviderName = "plivo" | "twilio" | "mock";
export type WebhookContext = {
req: IncomingMessage;
body: Record<string, string>;
rawBody: string;
url: string;
};
export type WebhookVerificationResult = {
valid: boolean;
error?: string;
};
export type InboundMessage = {
from: string;
to: string;
text: string;
messageId: string;
isMedia: boolean;
mediaUrls: string[];
};
export type WebhookParseResult = {
type: "message" | "status" | "unknown";
message?: InboundMessage;
status?: {
messageId: string;
status: string;
errorCode?: string;
};
response?: string; // XML response to send back
};
export type SendTextInput = {
from: string;
to: string;
text: string;
};
export type SendMediaInput = {
from: string;
to: string;
text?: string;
mediaUrls: string[];
};
export type SendResult = {
ok: boolean;
messageId?: string;
error?: string;
};
export type ConfigureWebhookInput = {
phoneNumber: string;
webhookUrl: string;
};
/**
* SMS Provider interface
*/
export interface SMSProvider {
/** Provider identifier */
readonly name: ProviderName;
/**
* Initialize the provider with credentials
*/
initialize(): Promise<void>;
/**
* Verify webhook signature/HMAC before processing
*/
verifyWebhook(ctx: WebhookContext, secret?: string): WebhookVerificationResult;
/**
* Parse provider-specific webhook payload into normalized message
*/
parseWebhook(ctx: WebhookContext): WebhookParseResult;
/**
* Build XML response for immediate reply
*/
buildReplyResponse(from: string, to: string, text: string): string;
/**
* Send SMS text message
*/
sendText(input: SendTextInput): Promise<SendResult>;
/**
* Send MMS with media attachments
*/
sendMedia(input: SendMediaInput): Promise<SendResult>;
/**
* Configure phone number webhooks (auto-setup)
*/
configureWebhook(input: ConfigureWebhookInput): Promise<{ success: boolean; error?: string }>;
}

View File

@ -0,0 +1,3 @@
export type { SMSProvider, ProviderName } from "./base.js";
export { PlivoProvider, type PlivoProviderConfig } from "./plivo.js";
export { MockProvider } from "./mock.js";

View File

@ -0,0 +1,65 @@
/**
* Mock SMS Provider for testing
*/
import type {
SMSProvider,
WebhookContext,
WebhookVerificationResult,
WebhookParseResult,
SendTextInput,
SendMediaInput,
SendResult,
ConfigureWebhookInput,
} from "./base.js";
export class MockProvider implements SMSProvider {
readonly name = "mock" as const;
public sentMessages: Array<SendTextInput | SendMediaInput> = [];
async initialize(): Promise<void> {
// No-op
}
verifyWebhook(_ctx: WebhookContext, _secret?: string): WebhookVerificationResult {
return { valid: true };
}
parseWebhook(ctx: WebhookContext): WebhookParseResult {
const { body } = ctx;
if (body.from && body.to) {
return {
type: "message",
message: {
from: body.from,
to: body.to,
text: body.text || "",
messageId: `mock-${Date.now()}`,
isMedia: false,
mediaUrls: [],
},
};
}
return { type: "unknown" };
}
buildReplyResponse(_from: string, _to: string, text: string): string {
return `<Response><Message>${text}</Message></Response>`;
}
async sendText(input: SendTextInput): Promise<SendResult> {
this.sentMessages.push(input);
return { ok: true, messageId: `mock-${Date.now()}` };
}
async sendMedia(input: SendMediaInput): Promise<SendResult> {
this.sentMessages.push(input);
return { ok: true, messageId: `mock-${Date.now()}` };
}
async configureWebhook(_input: ConfigureWebhookInput): Promise<{ success: boolean; error?: string }> {
return { success: true };
}
}

View File

@ -0,0 +1,161 @@
/**
* Plivo SMS Provider Implementation
*/
import * as Plivo from "plivo";
import type {
SMSProvider,
WebhookContext,
WebhookVerificationResult,
WebhookParseResult,
SendTextInput,
SendMediaInput,
SendResult,
ConfigureWebhookInput,
} from "./base.js";
export type PlivoProviderConfig = {
authId: string;
authToken: string;
};
export class PlivoProvider implements SMSProvider {
readonly name = "plivo" as const;
private client: Plivo.Client;
private config: PlivoProviderConfig;
constructor(config: PlivoProviderConfig) {
this.config = config;
this.client = new Plivo.Client(config.authId, config.authToken);
}
async initialize(): Promise<void> {
// Verify credentials
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (this.client.accounts as any).get();
}
verifyWebhook(ctx: WebhookContext, secret?: string): WebhookVerificationResult {
if (!secret) return { valid: true };
const signature = ctx.req.headers["x-plivo-signature-v3"] as string;
const nonce = ctx.req.headers["x-plivo-signature-v3-nonce"] as string;
if (!signature || !nonce) {
return { valid: false, error: "Missing signature headers" };
}
try {
const isValid = Plivo.validateV3Signature(
ctx.req.method || "POST",
ctx.url,
nonce,
secret,
signature,
ctx.body
);
return { valid: Boolean(isValid) };
} catch (error) {
return { valid: false, error: String(error) };
}
}
parseWebhook(ctx: WebhookContext): WebhookParseResult {
const { body } = ctx;
// Check if this is a status callback
if (body.Status && body.MessageUUID && !body.Text) {
return {
type: "status",
status: {
messageId: body.MessageUUID,
status: body.Status,
errorCode: body.ErrorCode,
},
};
}
// Inbound message
if (body.From && body.To) {
const mediaUrls: string[] = [];
for (let i = 0; i < 10; i++) {
const url = body[`MediaUrl${i}`];
if (url) mediaUrls.push(url);
}
const isMedia = body.Type === "mms" || mediaUrls.length > 0;
return {
type: "message",
message: {
from: body.From,
to: body.To,
text: body.Text || "",
messageId: body.MessageUUID,
isMedia,
mediaUrls,
},
};
}
return { type: "unknown" };
}
buildReplyResponse(from: string, to: string, text: string): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = (Plivo as any).Response();
response.addMessage(text, { src: from, dst: to });
return response.toXML();
}
async sendText(input: SendTextInput): Promise<SendResult> {
try {
const response = await this.client.messages.create(
input.from,
input.to,
input.text
);
const messageId = Array.isArray(response.messageUuid)
? response.messageUuid[0]
: response.messageUuid;
return { ok: true, messageId };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
async sendMedia(input: SendMediaInput): Promise<SendResult> {
try {
const response = await this.client.messages.create(
input.from,
input.to,
input.text || "",
{
type: "mms",
media_urls: input.mediaUrls,
}
);
const messageId = Array.isArray(response.messageUuid)
? response.messageUuid[0]
: response.messageUuid;
return { ok: true, messageId };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
async configureWebhook(input: ConfigureWebhookInput): Promise<{ success: boolean; error?: string }> {
const normalizedNumber = input.phoneNumber.replace(/^\+/, "");
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (this.client.numbers as any).update(normalizedNumber, {
message_url: input.webhookUrl,
message_method: "POST",
});
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
}

View File

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

View File

@ -1,43 +1,8 @@
/**
* Plivo SMS Channel Types
* 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[];
};
import type { ProviderName } from "./providers/index.js";
// Quick command shortcut
export type QuickCommand = {
@ -46,38 +11,6 @@ export type QuickCommand = {
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" },
@ -87,3 +20,63 @@ export const DEFAULT_QUICK_COMMANDS: QuickCommand[] = [
{ trigger: "note", fullCommand: "save a note", description: "Save a quick note" },
{ trigger: "help", fullCommand: "show available commands", description: "List all quick commands" },
];
// Provider-specific configuration
export type PlivoProviderConfig = {
authId?: string;
authToken?: string;
authIdFile?: string;
authTokenFile?: string;
};
export type TwilioProviderConfig = {
accountSid?: string;
authToken?: string;
};
// Account configuration
export type SMSAccountConfig = {
name?: string;
enabled?: boolean;
provider?: ProviderName;
phoneNumber?: string;
webhookUrl?: string;
webhookPath?: string;
webhookSecret?: string;
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: string[];
enableQuickCommands?: boolean;
quickCommands?: QuickCommand[];
plivo?: PlivoProviderConfig;
twilio?: TwilioProviderConfig;
};
// Multi-account configuration
export type SMSConfig = {
provider?: ProviderName;
accounts?: Record<string, SMSAccountConfig>;
plivo?: PlivoProviderConfig;
twilio?: TwilioProviderConfig;
} & SMSAccountConfig;
// Resolved account with required fields
export type SMSResolvedAccount = {
provider: ProviderName;
phoneNumber: string;
webhookUrl?: string;
webhookPath: string;
webhookSecret?: string;
dmPolicy: "pairing" | "allowlist" | "open" | "disabled";
allowFrom: string[];
enableQuickCommands: boolean;
quickCommands: QuickCommand[];
providerConfig: PlivoProviderConfig | TwilioProviderConfig;
};
// Runtime state for an SMS account
export type SMSRuntimeState = {
provider: import("./providers/index.js").SMSProvider;
server?: unknown; // HTTP server
phoneNumber: string;
webhookConfigured: boolean;
};

View File

@ -0,0 +1,218 @@
/**
* SMS 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 type { SMSProvider } from "./providers/index.js";
import type { SMSResolvedAccount, 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 = {
provider: SMSProvider;
account: SMSResolvedAccount;
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<{ body: Record<string, string>; raw: string }> {
return new Promise((resolve, reject) => {
let raw = "";
req.on("data", (chunk) => (raw += chunk));
req.on("end", () => {
try {
const params = new URLSearchParams(raw);
const body: Record<string, string> = {};
for (const [key, value] of params) {
body[key] = value;
}
resolve({ body, raw });
} catch (error) {
reject(error);
}
});
req.on("error", reject);
});
}
/**
* 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;
}
/**
* Create and start webhook server
*/
export async function startWebhookServer(
options: WebhookServerOptions
): Promise<{ server: ReturnType<typeof createServer>; stop: () => Promise<void> }> {
const {
provider,
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: "sms", provider: provider.name, accountId }));
return;
}
// Only handle POST requests
if (req.method !== "POST") {
res.writeHead(405);
res.end("Method Not Allowed");
return;
}
try {
const { body, raw } = await parseBody(req);
const fullUrl = `http://${req.headers.host}${req.url}`;
// Validate signature
if (account.webhookSecret) {
const verification = provider.verifyWebhook(
{ req, body, rawBody: raw, url: fullUrl },
account.webhookSecret
);
if (!verification.valid) {
log("Invalid webhook signature", { path: urlPath, error: verification.error });
res.writeHead(401);
res.end("Unauthorized");
return;
}
}
// Parse webhook
const parsed = provider.parseWebhook({ req, body, rawBody: raw, url: fullUrl });
// Inbound message
if (urlPath === inboundPath && parsed.type === "message" && parsed.message) {
const message = parsed.message;
// Process quick commands
const processedText = processQuickCommand(
message.text,
account.quickCommands,
account.enableQuickCommands
);
log("Received inbound message", {
from: message.from,
messageId: message.messageId,
isMedia: message.isMedia,
});
// Call message handler
const reply = await onMessage({
from: message.from,
to: message.to,
text: processedText,
messageId: message.messageId,
isMedia: message.isMedia,
mediaUrls: message.mediaUrls,
accountId,
});
// If handler returns a string, send as immediate reply
if (reply && typeof reply === "string") {
const xml = provider.buildReplyResponse(message.to, message.from, reply);
res.writeHead(200, { "Content-Type": "application/xml" });
res.end(xml);
return;
}
res.writeHead(200);
res.end("OK");
return;
}
// Delivery status report
if (urlPath === statusPath && parsed.type === "status" && parsed.status) {
log("Received delivery report", {
messageId: parsed.status.messageId,
status: parsed.status.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(`SMS webhook server started`, { port, path: inboundPath, provider: provider.name });
resolve({
server,
stop: () =>
new Promise<void>((res) => {
server.close(() => res());
}),
});
});
});
}

View File

@ -0,0 +1,182 @@
/**
* SMS Extension Integration Test
* Run with: npx tsx test-integration.ts
*/
import { smsPlugin } from "./src/channel.js";
import { PlivoProvider } from "./src/providers/index.js";
import { startWebhookServer } from "./src/webhook.js";
import type { SMSResolvedAccount } from "./src/types.js";
// Test configuration
// Set these environment variables before running:
// PLIVO_AUTH_ID, PLIVO_AUTH_TOKEN, SMS_PHONE_NUMBER, TEST_PHONE_NUMBER
const TEST_CONFIG = {
channels: {
sms: {
provider: "plivo",
phoneNumber: process.env.SMS_PHONE_NUMBER || "+15550001234",
plivo: {
authId: process.env.PLIVO_AUTH_ID,
authToken: process.env.PLIVO_AUTH_TOKEN,
},
webhookPath: "/sms",
enableQuickCommands: true,
},
},
};
async function testConfigAdapter() {
console.log("\n=== Testing Config Adapter ===");
const accountIds = smsPlugin.config.listAccountIds(TEST_CONFIG);
console.log("Account IDs:", accountIds);
const account = smsPlugin.config.resolveAccount(TEST_CONFIG, "default");
console.log("Resolved account:", {
provider: account?.provider,
phoneNumber: account?.phoneNumber,
dmPolicy: account?.dmPolicy,
enableQuickCommands: account?.enableQuickCommands,
});
const isConfigured = smsPlugin.config.isConfigured(TEST_CONFIG, "default");
console.log("Is configured:", isConfigured);
const description = smsPlugin.config.describeAccount(TEST_CONFIG, "default");
console.log("Account description:", description);
return account;
}
async function testPlivoProvider(account: SMSResolvedAccount) {
console.log("\n=== Testing Plivo Provider ===");
const providerConfig = account.providerConfig as { authId: string; authToken: string };
const provider = new PlivoProvider(providerConfig);
try {
await provider.initialize();
console.log("Provider initialized successfully");
} catch (error) {
console.error("Provider initialization failed:", error);
throw error;
}
return provider;
}
async function testOutboundAdapter(_account: SMSResolvedAccount) {
console.log("\n=== Testing Outbound Adapter ===");
// Test resolveTarget with fake 555 numbers
const target1 = smsPlugin.outbound.resolveTarget("+15550001234");
console.log("Resolve target (+15550001234):", target1);
const target2 = smsPlugin.outbound.resolveTarget("5550001234");
console.log("Resolve target (5550001234):", target2);
const target3 = smsPlugin.outbound.resolveTarget("invalid");
console.log("Resolve target (invalid):", target3);
}
async function testWebhookServer(provider: PlivoProvider, account: SMSResolvedAccount) {
console.log("\n=== Testing Webhook Server ===");
const { server, stop } = await startWebhookServer({
provider,
account,
accountId: "default",
path: "/sms",
port: 3456,
onMessage: async (message) => {
console.log("Received message:", message);
return `Echo: ${message.text}`;
},
log: (msg, data) => console.log(`[Webhook] ${msg}`, data || ""),
});
console.log("Webhook server running on http://localhost:3456/sms");
return { server, stop };
}
async function testSendSMS(provider: PlivoProvider, fromNumber: string) {
console.log("\n=== Testing SMS Send ===");
const testNumber = process.env.TEST_PHONE_NUMBER;
if (!testNumber) {
console.log("Skipping send test: TEST_PHONE_NUMBER not set");
return { ok: false, error: "TEST_PHONE_NUMBER not set" };
}
console.log(`Sending test SMS to ${testNumber}...`);
const result = await provider.sendText({
from: fromNumber,
to: testNumber,
text: "Hello from SMS extension test! Provider: Plivo",
});
console.log("Send result:", result);
return result;
}
async function testCapabilities() {
console.log("\n=== Testing Capabilities ===");
console.log("Channel ID:", smsPlugin.id);
console.log("Meta:", smsPlugin.meta);
console.log("Capabilities:", smsPlugin.capabilities);
}
async function main() {
console.log("🚀 Starting SMS extension integration test...\n");
try {
// Test config
const account = await testConfigAdapter();
if (!account) {
throw new Error("Failed to resolve account");
}
// Test capabilities
await testCapabilities();
// Test Plivo provider
const provider = await testPlivoProvider(account);
// Test outbound adapter
await testOutboundAdapter(account);
// Ask if user wants to test sending
const args = process.argv.slice(2);
if (args.includes("--send")) {
await testSendSMS(provider, account.phoneNumber);
} else {
console.log("\n(Skipping SMS send test. Use --send flag to test actual sending)");
}
// Test webhook server
if (args.includes("--webhook")) {
const { stop } = await testWebhookServer(provider, account);
console.log("\nWebhook server started. Press Ctrl+C to stop.");
console.log("Waiting for incoming messages...\n");
process.on("SIGINT", async () => {
console.log("\nShutting down...");
await stop();
process.exit(0);
});
// Keep running
await new Promise(() => {});
}
console.log("\n✅ All tests passed!");
} catch (error) {
console.error("\n❌ Test failed:", error);
process.exit(1);
}
}
main();

10
pnpm-lock.yaml generated
View File

@ -409,7 +409,11 @@ importers:
extensions/open-prose: {}
extensions/plivo:
extensions/signal: {}
extensions/slack: {}
extensions/sms:
dependencies:
plivo:
specifier: ^4.58.0
@ -419,10 +423,6 @@ importers:
specifier: workspace:*
version: link:../..
extensions/signal: {}
extensions/slack: {}
extensions/telegram: {}
extensions/tlon: