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:
parent
c7fec1d3d9
commit
15b41de880
@ -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";
|
||||
@ -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:*"
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
148
extensions/sms/clawdbot.plugin.json
Normal file
148
extensions/sms/clawdbot.plugin.json
Normal 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
47
extensions/sms/index.ts
Normal 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";
|
||||
29
extensions/sms/package.json
Normal file
29
extensions/sms/package.json
Normal 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:*"
|
||||
}
|
||||
}
|
||||
@ -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[] }) => {
|
||||
@ -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,
|
||||
};
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
109
extensions/sms/src/providers/base.ts
Normal file
109
extensions/sms/src/providers/base.ts
Normal 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 }>;
|
||||
}
|
||||
3
extensions/sms/src/providers/index.ts
Normal file
3
extensions/sms/src/providers/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export type { SMSProvider, ProviderName } from "./base.js";
|
||||
export { PlivoProvider, type PlivoProviderConfig } from "./plivo.js";
|
||||
export { MockProvider } from "./mock.js";
|
||||
65
extensions/sms/src/providers/mock.ts
Normal file
65
extensions/sms/src/providers/mock.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
161
extensions/sms/src/providers/plivo.ts
Normal file
161
extensions/sms/src/providers/plivo.ts
Normal 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) };
|
||||
}
|
||||
}
|
||||
}
|
||||
35
extensions/sms/src/runtime.ts
Normal file
35
extensions/sms/src/runtime.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
218
extensions/sms/src/webhook.ts
Normal file
218
extensions/sms/src/webhook.ts
Normal 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());
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
182
extensions/sms/test-integration.ts
Normal file
182
extensions/sms/test-integration.ts
Normal 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
10
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user