diff --git a/extensions/plivo/index.ts b/extensions/plivo/index.ts deleted file mode 100644 index cc69602d1..000000000 --- a/extensions/plivo/index.ts +++ /dev/null @@ -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"; diff --git a/extensions/plivo/package.json b/extensions/plivo/package.json deleted file mode 100644 index de86b4c9e..000000000 --- a/extensions/plivo/package.json +++ /dev/null @@ -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:*" - } -} diff --git a/extensions/plivo/src/runtime.ts b/extensions/plivo/src/runtime.ts deleted file mode 100644 index 7d78c33a1..000000000 --- a/extensions/plivo/src/runtime.ts +++ /dev/null @@ -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(); - -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 { - return accountStates; -} diff --git a/extensions/plivo/src/webhook.ts b/extensions/plivo/src/webhook.ts deleted file mode 100644 index 2562eae32..000000000 --- a/extensions/plivo/src/webhook.ts +++ /dev/null @@ -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; - -export type WebhookServerOptions = { - account: PlivoResolvedAccount; - accountId: string; - path: string; - port: number; - host?: string; - onMessage: WebhookMessageHandler; - onError?: (error: Error) => void; - log?: (message: string, data?: Record) => void; -}; - -/** - * Parse URL-encoded body from request - */ -async function parseBody(req: IncomingMessage): Promise> { - 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 = {}; - 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[] { - 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, - 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; stop: () => Promise }> { - 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((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) => 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 }; - } -} diff --git a/extensions/plivo/test-integration.ts b/extensions/plivo/test-integration.ts deleted file mode 100644 index 1d00d5df5..000000000 --- a/extensions/plivo/test-integration.ts +++ /dev/null @@ -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(); diff --git a/extensions/sms/clawdbot.plugin.json b/extensions/sms/clawdbot.plugin.json new file mode 100644 index 000000000..f4e22757b --- /dev/null +++ b/extensions/sms/clawdbot.plugin.json @@ -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" } + } + } + } + } + } +} diff --git a/extensions/sms/index.ts b/extensions/sms/index.ts new file mode 100644 index 000000000..2ab886e37 --- /dev/null +++ b/extensions/sms/index.ts @@ -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"; diff --git a/extensions/sms/package.json b/extensions/sms/package.json new file mode 100644 index 000000000..dd4dd7442 --- /dev/null +++ b/extensions/sms/package.json @@ -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:*" + } +} diff --git a/extensions/plivo/src/channel.ts b/extensions/sms/src/channel.ts similarity index 80% rename from extensions/plivo/src/channel.ts rename to extensions/sms/src/channel.ts index 3ec0063ac..28dc8e36f 100644 --- a/extensions/plivo/src/channel.ts +++ b/extensions/sms/src/channel.ts @@ -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[] }) => { diff --git a/extensions/plivo/src/config.ts b/extensions/sms/src/config.ts similarity index 64% rename from extensions/plivo/src/config.ts rename to extensions/sms/src/config.ts index c6fb5ea26..4db433974 100644 --- a/extensions/plivo/src/config.ts +++ b/extensions/sms/src/config.ts @@ -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 }): PlivoConfig | undefined { - return cfg?.channels?.[CHANNEL_ID] as PlivoConfig | undefined; +function getSMSConfig(cfg: { channels?: Record }): 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[] { - 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 }): str function getAccountConfig( cfg: { channels?: Record }, 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 }, 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, }; diff --git a/extensions/plivo/src/gateway.ts b/extensions/sms/src/gateway.ts similarity index 59% rename from extensions/plivo/src/gateway.ts rename to extensions/sms/src/gateway.ts index dab0a7f87..d6f6107a8 100644 --- a/extensions/plivo/src/gateway.ts +++ b/extensions/sms/src/gateway.ts @@ -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 }; accountId: string; - account: PlivoResolvedAccount; + account: SMSResolvedAccount; runtime: unknown; abortSignal?: AbortSignal; log: (message: string, data?: Record) => 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 { 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 { from: message.from, text: message.text, accountId, - channelId: "plivo", + channelId: "sms", }); } return undefined; @@ -86,13 +105,11 @@ export async function startAccount(ctx: GatewayContext): Promise { 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 { } // 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 { 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 { 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 { 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 }); } /** diff --git a/extensions/plivo/src/outbound.ts b/extensions/sms/src/outbound.ts similarity index 58% rename from extensions/plivo/src/outbound.ts rename to extensions/sms/src/outbound.ts index e3a88824d..6d845e1e2 100644 --- a/extensions/plivo/src/outbound.ts +++ b/extensions/sms/src/outbound.ts @@ -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 { - 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 { 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 { - 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 { 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, + }; } /** diff --git a/extensions/sms/src/providers/base.ts b/extensions/sms/src/providers/base.ts new file mode 100644 index 000000000..b2cfd937b --- /dev/null +++ b/extensions/sms/src/providers/base.ts @@ -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; + 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; + + /** + * 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; + + /** + * Send MMS with media attachments + */ + sendMedia(input: SendMediaInput): Promise; + + /** + * Configure phone number webhooks (auto-setup) + */ + configureWebhook(input: ConfigureWebhookInput): Promise<{ success: boolean; error?: string }>; +} diff --git a/extensions/sms/src/providers/index.ts b/extensions/sms/src/providers/index.ts new file mode 100644 index 000000000..0e332a8a0 --- /dev/null +++ b/extensions/sms/src/providers/index.ts @@ -0,0 +1,3 @@ +export type { SMSProvider, ProviderName } from "./base.js"; +export { PlivoProvider, type PlivoProviderConfig } from "./plivo.js"; +export { MockProvider } from "./mock.js"; diff --git a/extensions/sms/src/providers/mock.ts b/extensions/sms/src/providers/mock.ts new file mode 100644 index 000000000..c14bf85cf --- /dev/null +++ b/extensions/sms/src/providers/mock.ts @@ -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 = []; + + async initialize(): Promise { + // 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 `${text}`; + } + + async sendText(input: SendTextInput): Promise { + this.sentMessages.push(input); + return { ok: true, messageId: `mock-${Date.now()}` }; + } + + async sendMedia(input: SendMediaInput): Promise { + this.sentMessages.push(input); + return { ok: true, messageId: `mock-${Date.now()}` }; + } + + async configureWebhook(_input: ConfigureWebhookInput): Promise<{ success: boolean; error?: string }> { + return { success: true }; + } +} diff --git a/extensions/sms/src/providers/plivo.ts b/extensions/sms/src/providers/plivo.ts new file mode 100644 index 000000000..1caea4eff --- /dev/null +++ b/extensions/sms/src/providers/plivo.ts @@ -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 { + // 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 { + 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 { + 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) }; + } + } +} diff --git a/extensions/sms/src/runtime.ts b/extensions/sms/src/runtime.ts new file mode 100644 index 000000000..ccb0f26a3 --- /dev/null +++ b/extensions/sms/src/runtime.ts @@ -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(); + +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 { + return accountStates; +} diff --git a/extensions/plivo/src/types.ts b/extensions/sms/src/types.ts similarity index 64% rename from extensions/plivo/src/types.ts rename to extensions/sms/src/types.ts index 1c8d13c29..c16d36951 100644 --- a/extensions/plivo/src/types.ts +++ b/extensions/sms/src/types.ts @@ -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; -} & 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; + 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; +}; diff --git a/extensions/sms/src/webhook.ts b/extensions/sms/src/webhook.ts new file mode 100644 index 000000000..d3b2913e9 --- /dev/null +++ b/extensions/sms/src/webhook.ts @@ -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; + +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) => void; +}; + +/** + * Parse URL-encoded body from request + */ +async function parseBody(req: IncomingMessage): Promise<{ body: Record; 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 = {}; + 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; stop: () => Promise }> { + 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((res) => { + server.close(() => res()); + }), + }); + }); + }); +} diff --git a/extensions/sms/test-integration.ts b/extensions/sms/test-integration.ts new file mode 100644 index 000000000..fd3cfd3a8 --- /dev/null +++ b/extensions/sms/test-integration.ts @@ -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(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0e651c98..2603a66fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: