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
|
* Main channel implementation for Clawdbot
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { configAdapter } from "./config.js";
|
import { configAdapter } from "./config.js";
|
||||||
import { gatewayAdapter } from "./gateway.js";
|
import { gatewayAdapter } from "./gateway.js";
|
||||||
import { outboundAdapter } from "./outbound.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
|
* Channel metadata for UI/docs
|
||||||
*/
|
*/
|
||||||
const meta = {
|
const meta = {
|
||||||
id: CHANNEL_ID,
|
id: CHANNEL_ID,
|
||||||
label: "Plivo",
|
label: "SMS",
|
||||||
selectionLabel: "Plivo SMS",
|
selectionLabel: "SMS (Plivo/Twilio)",
|
||||||
docsPath: "/channels/plivo",
|
docsPath: "/channels/sms",
|
||||||
docsLabel: "plivo",
|
docsLabel: "sms",
|
||||||
blurb: "SMS/MMS via Plivo; universal phone access to your AI assistant.",
|
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,
|
id: CHANNEL_ID,
|
||||||
meta,
|
meta,
|
||||||
capabilities,
|
capabilities,
|
||||||
@ -83,7 +83,7 @@ export const plivoPlugin = {
|
|||||||
to: string;
|
to: string;
|
||||||
text: string;
|
text: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
account: PlivoResolvedAccount;
|
account: SMSResolvedAccount;
|
||||||
}) => {
|
}) => {
|
||||||
const result = await outboundAdapter.sendText({
|
const result = await outboundAdapter.sendText({
|
||||||
to: ctx.to,
|
to: ctx.to,
|
||||||
@ -100,7 +100,7 @@ export const plivoPlugin = {
|
|||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
mediaUrls?: string[];
|
mediaUrls?: string[];
|
||||||
accountId: string;
|
accountId: string;
|
||||||
account: PlivoResolvedAccount;
|
account: SMSResolvedAccount;
|
||||||
}) => {
|
}) => {
|
||||||
const result = await outboundAdapter.sendMedia({
|
const result = await outboundAdapter.sendMedia({
|
||||||
to: ctx.to,
|
to: ctx.to,
|
||||||
@ -118,14 +118,14 @@ export const plivoPlugin = {
|
|||||||
|
|
||||||
// Status adapter for health checks
|
// Status adapter for health checks
|
||||||
status: {
|
status: {
|
||||||
probeAccount: async (ctx: { accountId: string }) => {
|
probeAccount: async (_ctx: { accountId: string }) => {
|
||||||
// Basic connectivity check
|
return { ok: true, message: "SMS account is running" };
|
||||||
return { ok: true, message: "Plivo account is running" };
|
|
||||||
},
|
},
|
||||||
|
|
||||||
buildAccountSnapshot: (ctx: { accountId: string; account: PlivoResolvedAccount }) => {
|
buildAccountSnapshot: (ctx: { accountId: string; account: SMSResolvedAccount }) => {
|
||||||
return {
|
return {
|
||||||
accountId: ctx.accountId,
|
accountId: ctx.accountId,
|
||||||
|
provider: ctx.account.provider,
|
||||||
phoneNumber: ctx.account.phoneNumber,
|
phoneNumber: ctx.account.phoneNumber,
|
||||||
dmPolicy: ctx.account.dmPolicy,
|
dmPolicy: ctx.account.dmPolicy,
|
||||||
};
|
};
|
||||||
@ -134,8 +134,8 @@ export const plivoPlugin = {
|
|||||||
|
|
||||||
// Heartbeat adapter
|
// Heartbeat adapter
|
||||||
heartbeat: {
|
heartbeat: {
|
||||||
checkReady: async (params: { accountId: string }) => {
|
checkReady: async (_params: { accountId: string }) => {
|
||||||
return { ok: true, reason: "Plivo ready" };
|
return { ok: true, reason: "SMS ready" };
|
||||||
},
|
},
|
||||||
|
|
||||||
resolveRecipients: (params: { accountId: string; allowFrom: string[] }) => {
|
resolveRecipients: (params: { accountId: string; allowFrom: string[] }) => {
|
||||||
@ -1,24 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Plivo Configuration Adapter
|
* SMS Configuration Adapter
|
||||||
* Handles account configuration, credential resolution, and DM policies
|
* Handles account configuration, credential resolution, and DM policies
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync, existsSync } from "node:fs";
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
import type {
|
import type { SMSConfig, SMSAccountConfig, SMSResolvedAccount } from "./types.js";
|
||||||
PlivoConfig,
|
|
||||||
PlivoAccountConfig,
|
|
||||||
PlivoResolvedAccount,
|
|
||||||
} from "./types.js";
|
|
||||||
import { DEFAULT_QUICK_COMMANDS } from "./types.js";
|
import { DEFAULT_QUICK_COMMANDS } from "./types.js";
|
||||||
|
|
||||||
const CHANNEL_ID = "plivo";
|
const CHANNEL_ID = "sms";
|
||||||
const DEFAULT_WEBHOOK_PATH = "/plivo";
|
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 {
|
function getSMSConfig(cfg: { channels?: Record<string, unknown> }): SMSConfig | undefined {
|
||||||
return cfg?.channels?.[CHANNEL_ID] as PlivoConfig | 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
|
* List all configured account IDs
|
||||||
*/
|
*/
|
||||||
export function listAccountIds(cfg: { channels?: Record<string, unknown> }): string[] {
|
export function listAccountIds(cfg: { channels?: Record<string, unknown> }): string[] {
|
||||||
const plivoConfig = getPlivoConfig(cfg);
|
const smsConfig = getSMSConfig(cfg);
|
||||||
if (!plivoConfig) return [];
|
if (!smsConfig) return [];
|
||||||
|
|
||||||
// Check for multi-account setup
|
// Check for multi-account setup
|
||||||
if (plivoConfig.accounts) {
|
if (smsConfig.accounts) {
|
||||||
return Object.keys(plivoConfig.accounts);
|
return Object.keys(smsConfig.accounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single account (default)
|
// Single account (default)
|
||||||
if (plivoConfig.authId || plivoConfig.authIdFile || plivoConfig.phoneNumber) {
|
if (smsConfig.phoneNumber || smsConfig.plivo || smsConfig.twilio) {
|
||||||
return ["default"];
|
return ["default"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,20 +52,20 @@ export function listAccountIds(cfg: { channels?: Record<string, unknown> }): str
|
|||||||
function getAccountConfig(
|
function getAccountConfig(
|
||||||
cfg: { channels?: Record<string, unknown> },
|
cfg: { channels?: Record<string, unknown> },
|
||||||
accountId?: string
|
accountId?: string
|
||||||
): PlivoAccountConfig | undefined {
|
): SMSAccountConfig | undefined {
|
||||||
const plivoConfig = getPlivoConfig(cfg);
|
const smsConfig = getSMSConfig(cfg);
|
||||||
if (!plivoConfig) return undefined;
|
if (!smsConfig) return undefined;
|
||||||
|
|
||||||
const id = accountId || "default";
|
const id = accountId || "default";
|
||||||
|
|
||||||
// Multi-account lookup
|
// Multi-account lookup
|
||||||
if (plivoConfig.accounts?.[id]) {
|
if (smsConfig.accounts?.[id]) {
|
||||||
return { ...plivoConfig, ...plivoConfig.accounts[id] };
|
return { ...smsConfig, ...smsConfig.accounts[id] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single account (only for "default")
|
// Single account (only for "default")
|
||||||
if (id === "default") {
|
if (id === "default") {
|
||||||
return plivoConfig;
|
return smsConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -81,23 +77,39 @@ function getAccountConfig(
|
|||||||
export function resolveAccount(
|
export function resolveAccount(
|
||||||
cfg: { channels?: Record<string, unknown> },
|
cfg: { channels?: Record<string, unknown> },
|
||||||
accountId?: string
|
accountId?: string
|
||||||
): PlivoResolvedAccount | undefined {
|
): SMSResolvedAccount | undefined {
|
||||||
const accountConfig = getAccountConfig(cfg, accountId);
|
const accountConfig = getAccountConfig(cfg, accountId);
|
||||||
if (!accountConfig) return undefined;
|
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;
|
const phoneNumber = accountConfig.phoneNumber;
|
||||||
|
if (!phoneNumber) return undefined;
|
||||||
|
|
||||||
// Require essential credentials
|
// Determine provider
|
||||||
if (!authId || !authToken || !phoneNumber) {
|
const provider = accountConfig.provider || "plivo";
|
||||||
return undefined;
|
|
||||||
|
// 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 {
|
return {
|
||||||
authId,
|
provider,
|
||||||
authToken,
|
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
webhookUrl: accountConfig.webhookUrl,
|
webhookUrl: accountConfig.webhookUrl,
|
||||||
webhookPath: accountConfig.webhookPath || DEFAULT_WEBHOOK_PATH,
|
webhookPath: accountConfig.webhookPath || DEFAULT_WEBHOOK_PATH,
|
||||||
@ -106,6 +118,7 @@ export function resolveAccount(
|
|||||||
allowFrom: accountConfig.allowFrom || [],
|
allowFrom: accountConfig.allowFrom || [],
|
||||||
enableQuickCommands: accountConfig.enableQuickCommands ?? true,
|
enableQuickCommands: accountConfig.enableQuickCommands ?? true,
|
||||||
quickCommands: accountConfig.quickCommands || DEFAULT_QUICK_COMMANDS,
|
quickCommands: accountConfig.quickCommands || DEFAULT_QUICK_COMMANDS,
|
||||||
|
providerConfig,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,6 +163,7 @@ export function describeAccount(
|
|||||||
): {
|
): {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
provider?: string;
|
||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
dmPolicy?: string;
|
dmPolicy?: string;
|
||||||
} {
|
} {
|
||||||
@ -159,6 +173,7 @@ export function describeAccount(
|
|||||||
return {
|
return {
|
||||||
configured: resolved !== undefined,
|
configured: resolved !== undefined,
|
||||||
enabled: accountConfig?.enabled !== false,
|
enabled: accountConfig?.enabled !== false,
|
||||||
|
provider: resolved?.provider,
|
||||||
phoneNumber: resolved?.phoneNumber,
|
phoneNumber: resolved?.phoneNumber,
|
||||||
dmPolicy: resolved?.dmPolicy,
|
dmPolicy: resolved?.dmPolicy,
|
||||||
};
|
};
|
||||||
@ -1,17 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Plivo Gateway Adapter
|
* SMS Gateway Adapter
|
||||||
* Handles channel startup, shutdown, and webhook configuration
|
* Handles channel startup, shutdown, and webhook configuration
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as Plivo from "plivo";
|
|
||||||
import { setAccountState, removeAccountState } from "./runtime.js";
|
import { setAccountState, removeAccountState } from "./runtime.js";
|
||||||
import { startWebhookServer, autoConfigureWebhooks } from "./webhook.js";
|
import { startWebhookServer } from "./webhook.js";
|
||||||
import type { PlivoResolvedAccount, PlivoRuntimeState } from "./types.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 = {
|
export type GatewayContext = {
|
||||||
cfg: { channels?: Record<string, unknown> };
|
cfg: { channels?: Record<string, unknown> };
|
||||||
accountId: string;
|
accountId: string;
|
||||||
account: PlivoResolvedAccount;
|
account: SMSResolvedAccount;
|
||||||
runtime: unknown;
|
runtime: unknown;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
log: (message: string, data?: Record<string, unknown>) => void;
|
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> {
|
export async function startAccount(ctx: GatewayContext): Promise<void> {
|
||||||
const { account, accountId, log, setStatus } = ctx;
|
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
|
// Create and initialize provider
|
||||||
const client = new Plivo.Client(account.authId, account.authToken);
|
const provider = createProvider(account);
|
||||||
|
|
||||||
// Verify credentials
|
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
await provider.initialize();
|
||||||
await (client.accounts as any).get();
|
log("SMS provider initialized", { accountId, provider: account.provider });
|
||||||
log("Plivo credentials verified", { accountId });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown 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 });
|
setStatus({ running: false, lastError: errorMessage });
|
||||||
throw new Error(`Invalid Plivo credentials: ${errorMessage}`);
|
throw new Error(`Invalid SMS credentials: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine webhook URL
|
// Determine webhook URL
|
||||||
const webhookPort = parseInt(process.env.PLIVO_WEBHOOK_PORT || "8787", 10);
|
const webhookPort = parseInt(process.env.SMS_WEBHOOK_PORT || "8787", 10);
|
||||||
const webhookHost = process.env.PLIVO_WEBHOOK_HOST || "0.0.0.0";
|
const webhookHost = process.env.SMS_WEBHOOK_HOST || "0.0.0.0";
|
||||||
const publicUrl = account.webhookUrl || process.env.PUBLIC_URL || `http://localhost:${webhookPort}`;
|
const publicUrl = account.webhookUrl || process.env.PUBLIC_URL || `http://localhost:${webhookPort}`;
|
||||||
const fullWebhookUrl = `${publicUrl}${account.webhookPath}`;
|
const fullWebhookUrl = `${publicUrl}${account.webhookPath}`;
|
||||||
|
|
||||||
// Start webhook server
|
// Start webhook server
|
||||||
const { server, stop } = await startWebhookServer({
|
const { server, stop } = await startWebhookServer({
|
||||||
|
provider,
|
||||||
account,
|
account,
|
||||||
accountId,
|
accountId,
|
||||||
path: account.webhookPath,
|
path: account.webhookPath,
|
||||||
@ -74,7 +93,7 @@ export async function startAccount(ctx: GatewayContext): Promise<void> {
|
|||||||
from: message.from,
|
from: message.from,
|
||||||
text: message.text,
|
text: message.text,
|
||||||
accountId,
|
accountId,
|
||||||
channelId: "plivo",
|
channelId: "sms",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -86,13 +105,11 @@ export async function startAccount(ctx: GatewayContext): Promise<void> {
|
|||||||
log,
|
log,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-configure Plivo phone number to point to our webhook
|
// Auto-configure provider webhooks
|
||||||
const configResult = await autoConfigureWebhooks(
|
const configResult = await provider.configureWebhook({
|
||||||
client,
|
phoneNumber: account.phoneNumber,
|
||||||
account.phoneNumber,
|
webhookUrl: fullWebhookUrl,
|
||||||
fullWebhookUrl,
|
});
|
||||||
log
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!configResult.success) {
|
if (!configResult.success) {
|
||||||
log("Webhook auto-configuration failed, manual setup may be required", {
|
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
|
// Store runtime state
|
||||||
const state: PlivoRuntimeState = {
|
const state: SMSRuntimeState = {
|
||||||
client,
|
provider,
|
||||||
server,
|
server,
|
||||||
phoneNumber: account.phoneNumber,
|
phoneNumber: account.phoneNumber,
|
||||||
webhookConfigured: configResult.success,
|
webhookConfigured: configResult.success,
|
||||||
@ -118,8 +135,9 @@ export async function startAccount(ctx: GatewayContext): Promise<void> {
|
|||||||
webhookUrl: fullWebhookUrl,
|
webhookUrl: fullWebhookUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
log("Plivo account started successfully", {
|
log("SMS account started successfully", {
|
||||||
accountId,
|
accountId,
|
||||||
|
provider: account.provider,
|
||||||
phoneNumber: account.phoneNumber,
|
phoneNumber: account.phoneNumber,
|
||||||
webhookUrl: fullWebhookUrl,
|
webhookUrl: fullWebhookUrl,
|
||||||
webhookConfigured: configResult.success,
|
webhookConfigured: configResult.success,
|
||||||
@ -131,25 +149,25 @@ export async function startAccount(ctx: GatewayContext): Promise<void> {
|
|||||||
await stop();
|
await stop();
|
||||||
removeAccountState(accountId);
|
removeAccountState(accountId);
|
||||||
setStatus({ running: false, connected: false });
|
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> {
|
export async function stopAccount(ctx: GatewayContext): Promise<void> {
|
||||||
const { accountId, log, setStatus } = ctx;
|
const { accountId, log, setStatus } = ctx;
|
||||||
|
|
||||||
log("Stopping Plivo account", { accountId });
|
log("Stopping SMS account", { accountId });
|
||||||
|
|
||||||
// Clean up runtime state
|
// Clean up runtime state
|
||||||
removeAccountState(accountId);
|
removeAccountState(accountId);
|
||||||
|
|
||||||
setStatus({ running: false, connected: false });
|
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
|
* Handles sending SMS and MMS messages
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as Plivo from "plivo";
|
|
||||||
import { getAccountState } from "./runtime.js";
|
import { getAccountState } from "./runtime.js";
|
||||||
import type { PlivoResolvedAccount } from "./types.js";
|
import type { SMSResolvedAccount } from "./types.js";
|
||||||
|
|
||||||
export type OutboundContext = {
|
export type OutboundContext = {
|
||||||
to: string;
|
to: string;
|
||||||
@ -13,7 +12,7 @@ export type OutboundContext = {
|
|||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
mediaUrls?: string[];
|
mediaUrls?: string[];
|
||||||
accountId: string;
|
accountId: string;
|
||||||
account: PlivoResolvedAccount;
|
account: SMSResolvedAccount;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OutboundResult = {
|
export type OutboundResult = {
|
||||||
@ -22,14 +21,6 @@ export type OutboundResult = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Plivo client for account
|
|
||||||
*/
|
|
||||||
function getClient(accountId: string): Plivo.Client | undefined {
|
|
||||||
const state = getAccountState(accountId);
|
|
||||||
return state?.client as Plivo.Client | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize phone number to E.164 format
|
* Normalize phone number to E.164 format
|
||||||
*/
|
*/
|
||||||
@ -54,9 +45,9 @@ function normalizePhoneNumber(phoneNumber: string): string {
|
|||||||
* Send SMS text message
|
* Send SMS text message
|
||||||
*/
|
*/
|
||||||
export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
||||||
const client = getClient(ctx.accountId);
|
const state = getAccountState(ctx.accountId);
|
||||||
if (!client) {
|
if (!state?.provider) {
|
||||||
return { ok: false, error: "Plivo client not initialized" };
|
return { ok: false, error: "SMS provider not initialized" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ctx.text) {
|
if (!ctx.text) {
|
||||||
@ -66,26 +57,22 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
|||||||
const to = normalizePhoneNumber(ctx.to);
|
const to = normalizePhoneNumber(ctx.to);
|
||||||
const from = ctx.account.phoneNumber;
|
const from = ctx.account.phoneNumber;
|
||||||
|
|
||||||
try {
|
const result = await state.provider.sendText({ from, to, text: ctx.text });
|
||||||
const response = await client.messages.create(from, to, ctx.text);
|
|
||||||
const messageId = Array.isArray(response.messageUuid)
|
|
||||||
? response.messageUuid[0]
|
|
||||||
: response.messageUuid;
|
|
||||||
|
|
||||||
return { ok: true, externalId: messageId };
|
return {
|
||||||
} catch (error) {
|
ok: result.ok,
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
externalId: result.messageId,
|
||||||
return { ok: false, error: errorMessage };
|
error: result.error,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send MMS with media attachments
|
* Send MMS with media attachments
|
||||||
*/
|
*/
|
||||||
export async function sendMedia(ctx: OutboundContext): Promise<OutboundResult> {
|
export async function sendMedia(ctx: OutboundContext): Promise<OutboundResult> {
|
||||||
const client = getClient(ctx.accountId);
|
const state = getAccountState(ctx.accountId);
|
||||||
if (!client) {
|
if (!state?.provider) {
|
||||||
return { ok: false, error: "Plivo client not initialized" };
|
return { ok: false, error: "SMS provider not initialized" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaUrls = ctx.mediaUrls || (ctx.mediaUrl ? [ctx.mediaUrl] : []);
|
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 to = normalizePhoneNumber(ctx.to);
|
||||||
const from = ctx.account.phoneNumber;
|
const from = ctx.account.phoneNumber;
|
||||||
const text = ctx.text || "";
|
|
||||||
|
|
||||||
try {
|
const result = await state.provider.sendMedia({
|
||||||
const response = await client.messages.create(from, to, text, {
|
from,
|
||||||
type: "mms",
|
to,
|
||||||
media_urls: mediaUrls,
|
text: ctx.text,
|
||||||
});
|
mediaUrls,
|
||||||
|
});
|
||||||
|
|
||||||
const messageId = Array.isArray(response.messageUuid)
|
return {
|
||||||
? response.messageUuid[0]
|
ok: result.ok,
|
||||||
: response.messageUuid;
|
externalId: result.messageId,
|
||||||
|
error: result.error,
|
||||||
return { ok: true, externalId: messageId };
|
};
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
||||||
return { ok: false, error: errorMessage };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
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
|
import type { ProviderName } from "./providers/index.js";
|
||||||
export type PlivoAccountConfig = {
|
|
||||||
name?: string;
|
|
||||||
enabled?: boolean;
|
|
||||||
authId?: string;
|
|
||||||
authToken?: string;
|
|
||||||
authIdFile?: string;
|
|
||||||
authTokenFile?: string;
|
|
||||||
phoneNumber?: string;
|
|
||||||
webhookUrl?: string;
|
|
||||||
webhookPath?: string;
|
|
||||||
webhookSecret?: string;
|
|
||||||
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
|
||||||
allowFrom?: string[];
|
|
||||||
enableQuickCommands?: boolean;
|
|
||||||
quickCommands?: QuickCommand[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Multi-account configuration
|
|
||||||
export type PlivoConfig = {
|
|
||||||
accounts?: Record<string, PlivoAccountConfig>;
|
|
||||||
} & PlivoAccountConfig;
|
|
||||||
|
|
||||||
// Resolved account with required fields
|
|
||||||
export type PlivoResolvedAccount = {
|
|
||||||
authId: string;
|
|
||||||
authToken: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
webhookUrl?: string;
|
|
||||||
webhookPath: string;
|
|
||||||
webhookSecret?: string;
|
|
||||||
dmPolicy: "pairing" | "allowlist" | "open" | "disabled";
|
|
||||||
allowFrom: string[];
|
|
||||||
enableQuickCommands: boolean;
|
|
||||||
quickCommands: QuickCommand[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Quick command shortcut
|
// Quick command shortcut
|
||||||
export type QuickCommand = {
|
export type QuickCommand = {
|
||||||
@ -46,38 +11,6 @@ export type QuickCommand = {
|
|||||||
description: string;
|
description: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Inbound webhook payload from Plivo
|
|
||||||
export type PlivoInboundWebhook = {
|
|
||||||
From: string;
|
|
||||||
To: string;
|
|
||||||
Text: string;
|
|
||||||
MessageUUID: string;
|
|
||||||
Type: "sms" | "mms";
|
|
||||||
MediaUrl0?: string;
|
|
||||||
MediaUrl1?: string;
|
|
||||||
MediaUrl2?: string;
|
|
||||||
MediaContentType0?: string;
|
|
||||||
MediaContentType1?: string;
|
|
||||||
MediaContentType2?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Delivery report from Plivo
|
|
||||||
export type PlivoDeliveryReport = {
|
|
||||||
MessageUUID: string;
|
|
||||||
Status: string;
|
|
||||||
From: string;
|
|
||||||
To: string;
|
|
||||||
ErrorCode?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Runtime state for a Plivo account
|
|
||||||
export type PlivoRuntimeState = {
|
|
||||||
client: unknown; // Plivo.Client
|
|
||||||
server?: unknown; // HTTP server
|
|
||||||
phoneNumber: string;
|
|
||||||
webhookConfigured: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Default quick commands
|
// Default quick commands
|
||||||
export const DEFAULT_QUICK_COMMANDS: QuickCommand[] = [
|
export const DEFAULT_QUICK_COMMANDS: QuickCommand[] = [
|
||||||
{ trigger: "cal", fullCommand: "show my calendar for today", description: "View today's calendar" },
|
{ 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: "note", fullCommand: "save a note", description: "Save a quick note" },
|
||||||
{ trigger: "help", fullCommand: "show available commands", description: "List all quick commands" },
|
{ 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/open-prose: {}
|
||||||
|
|
||||||
extensions/plivo:
|
extensions/signal: {}
|
||||||
|
|
||||||
|
extensions/slack: {}
|
||||||
|
|
||||||
|
extensions/sms:
|
||||||
dependencies:
|
dependencies:
|
||||||
plivo:
|
plivo:
|
||||||
specifier: ^4.58.0
|
specifier: ^4.58.0
|
||||||
@ -419,10 +423,6 @@ importers:
|
|||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
extensions/signal: {}
|
|
||||||
|
|
||||||
extensions/slack: {}
|
|
||||||
|
|
||||||
extensions/telegram: {}
|
extensions/telegram: {}
|
||||||
|
|
||||||
extensions/tlon:
|
extensions/tlon:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user