feat(channels): add Plivo SMS/MMS channel extension
Add a new channel extension for Plivo SMS/MMS messaging, enabling universal phone-based access to Clawdbot from any device. Features: - Two-way SMS messaging via Plivo API - MMS media support (images, videos, documents) - Quick command shortcuts (e.g., "cal" -> "show my calendar") - Auto-configuration of Plivo webhooks on startup - Multi-account support - DM policies (pairing, allowlist, open) Implements ideas 3.1, 3.3, 3.4, 3.7 from PLIVO_INTEGRATION_IDEAS.md: - 3.1 SMS Channel Adapter - 3.3 Quick Commands via SMS - 3.4 MMS Media Sharing - 3.7 Critical Alerts via SMS Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
552b2956d4
commit
40538b5a30
4
extensions/plivo/.gitignore
vendored
Normal file
4
extensions/plivo/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
45
extensions/plivo/index.ts
Normal file
45
extensions/plivo/index.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Plivo SMS Channel Extension for Clawdbot
|
||||||
|
*
|
||||||
|
* This extension provides SMS/MMS messaging capabilities via Plivo,
|
||||||
|
* enabling universal phone-based access to your AI assistant.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Two-way SMS messaging
|
||||||
|
* - MMS media support (images, videos, documents)
|
||||||
|
* - Quick command shortcuts (e.g., "cal" -> "show my calendar")
|
||||||
|
* - Auto-configuration of Plivo webhooks
|
||||||
|
* - Multi-account support
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { plivoPlugin } from "./src/channel.js";
|
||||||
|
import { setPlivoRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
|
// Plugin definition for Clawdbot
|
||||||
|
const plugin = {
|
||||||
|
id: "plivo",
|
||||||
|
name: "Plivo SMS",
|
||||||
|
description: "SMS/MMS channel via Plivo - Universal phone access to your AI assistant",
|
||||||
|
|
||||||
|
register(api: {
|
||||||
|
runtime: unknown;
|
||||||
|
registerChannel: (opts: { plugin: typeof plivoPlugin }) => void;
|
||||||
|
}) {
|
||||||
|
// Store runtime reference for access in adapters
|
||||||
|
setPlivoRuntime(api.runtime);
|
||||||
|
|
||||||
|
// Register the Plivo channel
|
||||||
|
api.registerChannel({ plugin: plivoPlugin });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
|
|
||||||
|
// Re-export types and utilities for external use
|
||||||
|
export { plivoPlugin } from "./src/channel.js";
|
||||||
|
export type {
|
||||||
|
PlivoConfig,
|
||||||
|
PlivoAccountConfig,
|
||||||
|
PlivoResolvedAccount,
|
||||||
|
QuickCommand,
|
||||||
|
} from "./src/types.js";
|
||||||
10435
extensions/plivo/package-lock.json
generated
Normal file
10435
extensions/plivo/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
56
extensions/plivo/package.json
Normal file
56
extensions/plivo/package.json
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "@clawdbot/plivo",
|
||||||
|
"version": "2026.1.26",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Clawdbot Plivo SMS/MMS channel plugin - Universal SMS access to your AI assistant",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsc --watch",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"clawdbot",
|
||||||
|
"plivo",
|
||||||
|
"sms",
|
||||||
|
"mms",
|
||||||
|
"channel",
|
||||||
|
"messaging"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"clawdbot": {
|
||||||
|
"extensions": ["./dist/index.js"],
|
||||||
|
"channel": {
|
||||||
|
"id": "plivo",
|
||||||
|
"label": "Plivo",
|
||||||
|
"selectionLabel": "Plivo SMS",
|
||||||
|
"docsPath": "/channels/plivo",
|
||||||
|
"docsLabel": "plivo",
|
||||||
|
"blurb": "SMS/MMS via Plivo; universal phone access to your AI assistant.",
|
||||||
|
"order": 45
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"npmSpec": "@clawdbot/plivo",
|
||||||
|
"localPath": "extensions/plivo",
|
||||||
|
"defaultChoice": "local"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"plivo": "^4.58.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"clawdbot": "*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"typescript": "^5.3.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
148
extensions/plivo/src/channel.ts
Normal file
148
extensions/plivo/src/channel.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* Plivo SMS Channel Plugin
|
||||||
|
* Main channel implementation for Clawdbot
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { configAdapter } from "./config.js";
|
||||||
|
import { gatewayAdapter } from "./gateway.js";
|
||||||
|
import { outboundAdapter } from "./outbound.js";
|
||||||
|
import type { PlivoResolvedAccount } from "./types.js";
|
||||||
|
|
||||||
|
const CHANNEL_ID = "plivo";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel metadata for UI/docs
|
||||||
|
*/
|
||||||
|
const meta = {
|
||||||
|
id: CHANNEL_ID,
|
||||||
|
label: "Plivo",
|
||||||
|
selectionLabel: "Plivo SMS",
|
||||||
|
docsPath: "/channels/plivo",
|
||||||
|
docsLabel: "plivo",
|
||||||
|
blurb: "SMS/MMS via Plivo; universal phone access to your AI assistant.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel capabilities
|
||||||
|
*/
|
||||||
|
const capabilities = {
|
||||||
|
supportsText: true,
|
||||||
|
supportsMedia: true,
|
||||||
|
supportsVoice: false,
|
||||||
|
supportsVideo: false,
|
||||||
|
supportsStickers: false,
|
||||||
|
supportsPolls: false,
|
||||||
|
supportsButtons: false,
|
||||||
|
supportsFormatting: false, // SMS is plain text
|
||||||
|
supportsThreading: false,
|
||||||
|
supportsReactions: false,
|
||||||
|
supportsEditing: false,
|
||||||
|
supportsDeleting: false,
|
||||||
|
maxTextLength: 1600, // SMS concatenation limit
|
||||||
|
maxMediaSize: 5 * 1024 * 1024, // 5MB MMS limit
|
||||||
|
maxMediaCount: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plivo channel plugin definition
|
||||||
|
*/
|
||||||
|
export const plivoPlugin = {
|
||||||
|
id: CHANNEL_ID,
|
||||||
|
meta,
|
||||||
|
capabilities,
|
||||||
|
|
||||||
|
// Configuration adapter
|
||||||
|
config: {
|
||||||
|
listAccountIds: (cfg: { channels?: Record<string, unknown> }) =>
|
||||||
|
configAdapter.listAccountIds(cfg),
|
||||||
|
|
||||||
|
resolveAccount: (cfg: { channels?: Record<string, unknown> }, accountId?: string) =>
|
||||||
|
configAdapter.resolveAccount(cfg, accountId),
|
||||||
|
|
||||||
|
isConfigured: (cfg: { channels?: Record<string, unknown> }, accountId?: string) =>
|
||||||
|
configAdapter.isConfigured(cfg, accountId),
|
||||||
|
|
||||||
|
resolveAllowFrom: (cfg: { channels?: Record<string, unknown> }, accountId?: string) =>
|
||||||
|
configAdapter.resolveAllowFrom(cfg, accountId),
|
||||||
|
|
||||||
|
describeAccount: (cfg: { channels?: Record<string, unknown> }, accountId?: string) =>
|
||||||
|
configAdapter.describeAccount(cfg, accountId),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Gateway adapter for starting/stopping
|
||||||
|
gateway: {
|
||||||
|
startAccount: gatewayAdapter.startAccount,
|
||||||
|
stopAccount: gatewayAdapter.stopAccount,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Outbound adapter for sending messages
|
||||||
|
outbound: {
|
||||||
|
deliveryMode: outboundAdapter.deliveryMode,
|
||||||
|
|
||||||
|
sendText: async (ctx: {
|
||||||
|
to: string;
|
||||||
|
text: string;
|
||||||
|
accountId: string;
|
||||||
|
account: PlivoResolvedAccount;
|
||||||
|
}) => {
|
||||||
|
const result = await outboundAdapter.sendText({
|
||||||
|
to: ctx.to,
|
||||||
|
text: ctx.text,
|
||||||
|
accountId: ctx.accountId,
|
||||||
|
account: ctx.account,
|
||||||
|
});
|
||||||
|
return { ok: result.ok, externalId: result.externalId, error: result.error };
|
||||||
|
},
|
||||||
|
|
||||||
|
sendMedia: async (ctx: {
|
||||||
|
to: string;
|
||||||
|
text?: string;
|
||||||
|
mediaUrl?: string;
|
||||||
|
mediaUrls?: string[];
|
||||||
|
accountId: string;
|
||||||
|
account: PlivoResolvedAccount;
|
||||||
|
}) => {
|
||||||
|
const result = await outboundAdapter.sendMedia({
|
||||||
|
to: ctx.to,
|
||||||
|
text: ctx.text,
|
||||||
|
mediaUrl: ctx.mediaUrl,
|
||||||
|
mediaUrls: ctx.mediaUrls,
|
||||||
|
accountId: ctx.accountId,
|
||||||
|
account: ctx.account,
|
||||||
|
});
|
||||||
|
return { ok: result.ok, externalId: result.externalId, error: result.error };
|
||||||
|
},
|
||||||
|
|
||||||
|
resolveTarget: (target: string) => outboundAdapter.resolveTarget(target),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Status adapter for health checks
|
||||||
|
status: {
|
||||||
|
probeAccount: async (ctx: { accountId: string }) => {
|
||||||
|
// Basic connectivity check
|
||||||
|
return { ok: true, message: "Plivo account is running" };
|
||||||
|
},
|
||||||
|
|
||||||
|
buildAccountSnapshot: (ctx: { accountId: string; account: PlivoResolvedAccount }) => {
|
||||||
|
return {
|
||||||
|
accountId: ctx.accountId,
|
||||||
|
phoneNumber: ctx.account.phoneNumber,
|
||||||
|
dmPolicy: ctx.account.dmPolicy,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Heartbeat adapter
|
||||||
|
heartbeat: {
|
||||||
|
checkReady: async (params: { accountId: string }) => {
|
||||||
|
return { ok: true, reason: "Plivo ready" };
|
||||||
|
},
|
||||||
|
|
||||||
|
resolveRecipients: (params: { accountId: string; allowFrom: string[] }) => {
|
||||||
|
return {
|
||||||
|
recipients: params.allowFrom,
|
||||||
|
source: "allowlist",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
176
extensions/plivo/src/config.ts
Normal file
176
extensions/plivo/src/config.ts
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Plivo Configuration Adapter
|
||||||
|
* Handles account configuration, credential resolution, and DM policies
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
|
import type {
|
||||||
|
PlivoConfig,
|
||||||
|
PlivoAccountConfig,
|
||||||
|
PlivoResolvedAccount,
|
||||||
|
} from "./types.js";
|
||||||
|
import { DEFAULT_QUICK_COMMANDS } from "./types.js";
|
||||||
|
|
||||||
|
const CHANNEL_ID = "plivo";
|
||||||
|
const DEFAULT_WEBHOOK_PATH = "/plivo";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Plivo config from Clawdbot config
|
||||||
|
*/
|
||||||
|
function getPlivoConfig(cfg: { channels?: Record<string, unknown> }): PlivoConfig | undefined {
|
||||||
|
return cfg?.channels?.[CHANNEL_ID] as PlivoConfig | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read credential from file if path provided
|
||||||
|
*/
|
||||||
|
function readCredentialFile(filePath: string | undefined): string | undefined {
|
||||||
|
if (!filePath) return undefined;
|
||||||
|
if (!existsSync(filePath)) return undefined;
|
||||||
|
return readFileSync(filePath, "utf-8").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all configured account IDs
|
||||||
|
*/
|
||||||
|
export function listAccountIds(cfg: { channels?: Record<string, unknown> }): string[] {
|
||||||
|
const plivoConfig = getPlivoConfig(cfg);
|
||||||
|
if (!plivoConfig) return [];
|
||||||
|
|
||||||
|
// Check for multi-account setup
|
||||||
|
if (plivoConfig.accounts) {
|
||||||
|
return Object.keys(plivoConfig.accounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single account (default)
|
||||||
|
if (plivoConfig.authId || plivoConfig.authIdFile || plivoConfig.phoneNumber) {
|
||||||
|
return ["default"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get account configuration by ID
|
||||||
|
*/
|
||||||
|
function getAccountConfig(
|
||||||
|
cfg: { channels?: Record<string, unknown> },
|
||||||
|
accountId?: string
|
||||||
|
): PlivoAccountConfig | undefined {
|
||||||
|
const plivoConfig = getPlivoConfig(cfg);
|
||||||
|
if (!plivoConfig) return undefined;
|
||||||
|
|
||||||
|
const id = accountId || "default";
|
||||||
|
|
||||||
|
// Multi-account lookup
|
||||||
|
if (plivoConfig.accounts?.[id]) {
|
||||||
|
return { ...plivoConfig, ...plivoConfig.accounts[id] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single account (only for "default")
|
||||||
|
if (id === "default") {
|
||||||
|
return plivoConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve account configuration with defaults and credential files
|
||||||
|
*/
|
||||||
|
export function resolveAccount(
|
||||||
|
cfg: { channels?: Record<string, unknown> },
|
||||||
|
accountId?: string
|
||||||
|
): PlivoResolvedAccount | undefined {
|
||||||
|
const accountConfig = getAccountConfig(cfg, accountId);
|
||||||
|
if (!accountConfig) return undefined;
|
||||||
|
|
||||||
|
// Resolve credentials (direct or from file)
|
||||||
|
const authId = accountConfig.authId || readCredentialFile(accountConfig.authIdFile);
|
||||||
|
const authToken = accountConfig.authToken || readCredentialFile(accountConfig.authTokenFile);
|
||||||
|
const phoneNumber = accountConfig.phoneNumber;
|
||||||
|
|
||||||
|
// Require essential credentials
|
||||||
|
if (!authId || !authToken || !phoneNumber) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authId,
|
||||||
|
authToken,
|
||||||
|
phoneNumber,
|
||||||
|
webhookUrl: accountConfig.webhookUrl,
|
||||||
|
webhookPath: accountConfig.webhookPath || DEFAULT_WEBHOOK_PATH,
|
||||||
|
webhookSecret: accountConfig.webhookSecret,
|
||||||
|
dmPolicy: accountConfig.dmPolicy || "pairing",
|
||||||
|
allowFrom: accountConfig.allowFrom || [],
|
||||||
|
enableQuickCommands: accountConfig.enableQuickCommands ?? true,
|
||||||
|
quickCommands: accountConfig.quickCommands || DEFAULT_QUICK_COMMANDS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if account is configured with required credentials
|
||||||
|
*/
|
||||||
|
export function isConfigured(
|
||||||
|
cfg: { channels?: Record<string, unknown> },
|
||||||
|
accountId?: string
|
||||||
|
): boolean {
|
||||||
|
return resolveAccount(cfg, accountId) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if account is enabled
|
||||||
|
*/
|
||||||
|
export function isEnabled(
|
||||||
|
cfg: { channels?: Record<string, unknown> },
|
||||||
|
accountId?: string
|
||||||
|
): boolean {
|
||||||
|
const accountConfig = getAccountConfig(cfg, accountId);
|
||||||
|
return accountConfig?.enabled !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get DM allowlist for account
|
||||||
|
*/
|
||||||
|
export function resolveAllowFrom(
|
||||||
|
cfg: { channels?: Record<string, unknown> },
|
||||||
|
accountId?: string
|
||||||
|
): string[] {
|
||||||
|
const account = resolveAccount(cfg, accountId);
|
||||||
|
return account?.allowFrom || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describe account for UI display
|
||||||
|
*/
|
||||||
|
export function describeAccount(
|
||||||
|
cfg: { channels?: Record<string, unknown> },
|
||||||
|
accountId?: string
|
||||||
|
): {
|
||||||
|
configured: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
phoneNumber?: string;
|
||||||
|
dmPolicy?: string;
|
||||||
|
} {
|
||||||
|
const accountConfig = getAccountConfig(cfg, accountId);
|
||||||
|
const resolved = resolveAccount(cfg, accountId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
configured: resolved !== undefined,
|
||||||
|
enabled: accountConfig?.enabled !== false,
|
||||||
|
phoneNumber: resolved?.phoneNumber,
|
||||||
|
dmPolicy: resolved?.dmPolicy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config adapter for Clawdbot channel plugin
|
||||||
|
*/
|
||||||
|
export const configAdapter = {
|
||||||
|
listAccountIds,
|
||||||
|
resolveAccount,
|
||||||
|
isConfigured,
|
||||||
|
resolveAllowFrom,
|
||||||
|
describeAccount,
|
||||||
|
};
|
||||||
161
extensions/plivo/src/gateway.ts
Normal file
161
extensions/plivo/src/gateway.ts
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Plivo Gateway Adapter
|
||||||
|
* Handles channel startup, shutdown, and webhook configuration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as Plivo from "plivo";
|
||||||
|
import { setAccountState, removeAccountState } from "./runtime.js";
|
||||||
|
import { startWebhookServer, autoConfigureWebhooks } from "./webhook.js";
|
||||||
|
import type { PlivoResolvedAccount, PlivoRuntimeState } from "./types.js";
|
||||||
|
|
||||||
|
export type GatewayContext = {
|
||||||
|
cfg: { channels?: Record<string, unknown> };
|
||||||
|
accountId: string;
|
||||||
|
account: PlivoResolvedAccount;
|
||||||
|
runtime: unknown;
|
||||||
|
abortSignal?: AbortSignal;
|
||||||
|
log: (message: string, data?: Record<string, unknown>) => void;
|
||||||
|
getStatus: () => unknown;
|
||||||
|
setStatus: (status: Partial<{
|
||||||
|
running: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
lastConnectedAt: number;
|
||||||
|
lastError: string;
|
||||||
|
webhookUrl: string;
|
||||||
|
}>) => void;
|
||||||
|
onInboundMessage?: (message: {
|
||||||
|
from: string;
|
||||||
|
text: string;
|
||||||
|
accountId: string;
|
||||||
|
channelId: string;
|
||||||
|
}) => Promise<string | void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start Plivo account - initialize client and webhook server
|
||||||
|
*/
|
||||||
|
export async function startAccount(ctx: GatewayContext): Promise<void> {
|
||||||
|
const { account, accountId, log, setStatus } = ctx;
|
||||||
|
|
||||||
|
log("Starting Plivo account", { accountId, phoneNumber: account.phoneNumber });
|
||||||
|
|
||||||
|
// Create Plivo client
|
||||||
|
const client = new Plivo.Client(account.authId, account.authToken);
|
||||||
|
|
||||||
|
// Verify credentials
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await (client.accounts as any).get();
|
||||||
|
log("Plivo credentials verified", { accountId });
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
log("Failed to verify Plivo credentials", { error: errorMessage });
|
||||||
|
setStatus({ running: false, lastError: errorMessage });
|
||||||
|
throw new Error(`Invalid Plivo credentials: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine webhook URL
|
||||||
|
const webhookPort = parseInt(process.env.PLIVO_WEBHOOK_PORT || "8787", 10);
|
||||||
|
const webhookHost = process.env.PLIVO_WEBHOOK_HOST || "0.0.0.0";
|
||||||
|
const publicUrl = account.webhookUrl || process.env.PUBLIC_URL || `http://localhost:${webhookPort}`;
|
||||||
|
const fullWebhookUrl = `${publicUrl}${account.webhookPath}`;
|
||||||
|
|
||||||
|
// Start webhook server
|
||||||
|
const { server, stop } = await startWebhookServer({
|
||||||
|
account,
|
||||||
|
accountId,
|
||||||
|
path: account.webhookPath,
|
||||||
|
port: webhookPort,
|
||||||
|
host: webhookHost,
|
||||||
|
onMessage: async (message) => {
|
||||||
|
// Route to Clawdbot's message handler
|
||||||
|
if (ctx.onInboundMessage) {
|
||||||
|
return ctx.onInboundMessage({
|
||||||
|
from: message.from,
|
||||||
|
text: message.text,
|
||||||
|
accountId,
|
||||||
|
channelId: "plivo",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
log("Webhook error", { error: error.message });
|
||||||
|
setStatus({ lastError: error.message });
|
||||||
|
},
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-configure Plivo phone number to point to our webhook
|
||||||
|
const configResult = await autoConfigureWebhooks(
|
||||||
|
client,
|
||||||
|
account.phoneNumber,
|
||||||
|
fullWebhookUrl,
|
||||||
|
log
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!configResult.success) {
|
||||||
|
log("Webhook auto-configuration failed, manual setup may be required", {
|
||||||
|
error: configResult.error,
|
||||||
|
manualUrl: fullWebhookUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store runtime state
|
||||||
|
const state: PlivoRuntimeState = {
|
||||||
|
client,
|
||||||
|
server,
|
||||||
|
phoneNumber: account.phoneNumber,
|
||||||
|
webhookConfigured: configResult.success,
|
||||||
|
};
|
||||||
|
setAccountState(accountId, state);
|
||||||
|
|
||||||
|
// Update channel status
|
||||||
|
setStatus({
|
||||||
|
running: true,
|
||||||
|
connected: true,
|
||||||
|
lastConnectedAt: Date.now(),
|
||||||
|
webhookUrl: fullWebhookUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
log("Plivo account started successfully", {
|
||||||
|
accountId,
|
||||||
|
phoneNumber: account.phoneNumber,
|
||||||
|
webhookUrl: fullWebhookUrl,
|
||||||
|
webhookConfigured: configResult.success,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle abort signal
|
||||||
|
if (ctx.abortSignal) {
|
||||||
|
ctx.abortSignal.addEventListener("abort", async () => {
|
||||||
|
await stop();
|
||||||
|
removeAccountState(accountId);
|
||||||
|
setStatus({ running: false, connected: false });
|
||||||
|
log("Plivo account stopped via abort signal", { accountId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop Plivo account
|
||||||
|
*/
|
||||||
|
export async function stopAccount(ctx: GatewayContext): Promise<void> {
|
||||||
|
const { accountId, log, setStatus } = ctx;
|
||||||
|
|
||||||
|
log("Stopping Plivo account", { accountId });
|
||||||
|
|
||||||
|
// Clean up runtime state
|
||||||
|
removeAccountState(accountId);
|
||||||
|
|
||||||
|
setStatus({ running: false, connected: false });
|
||||||
|
|
||||||
|
log("Plivo account stopped", { accountId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway adapter for Clawdbot channel plugin
|
||||||
|
*/
|
||||||
|
export const gatewayAdapter = {
|
||||||
|
startAccount,
|
||||||
|
stopAccount,
|
||||||
|
};
|
||||||
142
extensions/plivo/src/outbound.ts
Normal file
142
extensions/plivo/src/outbound.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Plivo Outbound Message Adapter
|
||||||
|
* Handles sending SMS and MMS messages
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as Plivo from "plivo";
|
||||||
|
import { getAccountState } from "./runtime.js";
|
||||||
|
import type { PlivoResolvedAccount } from "./types.js";
|
||||||
|
|
||||||
|
export type OutboundContext = {
|
||||||
|
to: string;
|
||||||
|
text?: string;
|
||||||
|
mediaUrl?: string;
|
||||||
|
mediaUrls?: string[];
|
||||||
|
accountId: string;
|
||||||
|
account: PlivoResolvedAccount;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OutboundResult = {
|
||||||
|
ok: boolean;
|
||||||
|
externalId?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Plivo client for account
|
||||||
|
*/
|
||||||
|
function getClient(accountId: string): Plivo.Client | undefined {
|
||||||
|
const state = getAccountState(accountId);
|
||||||
|
return state?.client as Plivo.Client | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize phone number to E.164 format
|
||||||
|
*/
|
||||||
|
function normalizePhoneNumber(phoneNumber: string): string {
|
||||||
|
// Remove any non-digit characters except leading +
|
||||||
|
let normalized = phoneNumber.replace(/[^\d+]/g, "");
|
||||||
|
|
||||||
|
// Ensure it starts with +
|
||||||
|
if (!normalized.startsWith("+")) {
|
||||||
|
// Assume US number if 10 digits
|
||||||
|
if (normalized.length === 10) {
|
||||||
|
normalized = "+1" + normalized;
|
||||||
|
} else {
|
||||||
|
normalized = "+" + normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send SMS text message
|
||||||
|
*/
|
||||||
|
export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
||||||
|
const client = getClient(ctx.accountId);
|
||||||
|
if (!client) {
|
||||||
|
return { ok: false, error: "Plivo client not initialized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx.text) {
|
||||||
|
return { ok: false, error: "No text content provided" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const to = normalizePhoneNumber(ctx.to);
|
||||||
|
const from = ctx.account.phoneNumber;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await client.messages.create(from, to, ctx.text);
|
||||||
|
const messageId = Array.isArray(response.messageUuid)
|
||||||
|
? response.messageUuid[0]
|
||||||
|
: response.messageUuid;
|
||||||
|
|
||||||
|
return { ok: true, externalId: messageId };
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return { ok: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send MMS with media attachments
|
||||||
|
*/
|
||||||
|
export async function sendMedia(ctx: OutboundContext): Promise<OutboundResult> {
|
||||||
|
const client = getClient(ctx.accountId);
|
||||||
|
if (!client) {
|
||||||
|
return { ok: false, error: "Plivo client not initialized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaUrls = ctx.mediaUrls || (ctx.mediaUrl ? [ctx.mediaUrl] : []);
|
||||||
|
if (mediaUrls.length === 0) {
|
||||||
|
return { ok: false, error: "No media URLs provided" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const to = normalizePhoneNumber(ctx.to);
|
||||||
|
const from = ctx.account.phoneNumber;
|
||||||
|
const text = ctx.text || "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await client.messages.create(from, to, text, {
|
||||||
|
type: "mms",
|
||||||
|
media_urls: mediaUrls,
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageId = Array.isArray(response.messageUuid)
|
||||||
|
? response.messageUuid[0]
|
||||||
|
: response.messageUuid;
|
||||||
|
|
||||||
|
return { ok: true, externalId: messageId };
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return { ok: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve target phone number
|
||||||
|
*/
|
||||||
|
export function resolveTarget(target: string): { ok: boolean; to?: string; error?: string } {
|
||||||
|
if (!target) {
|
||||||
|
return { ok: false, error: "No target provided" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate it looks like a phone number
|
||||||
|
const digitsOnly = target.replace(/\D/g, "");
|
||||||
|
if (digitsOnly.length < 10 || digitsOnly.length > 15) {
|
||||||
|
return { ok: false, error: "Invalid phone number format" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, to: normalizePhoneNumber(target) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound adapter for Clawdbot channel plugin
|
||||||
|
*/
|
||||||
|
export const outboundAdapter = {
|
||||||
|
deliveryMode: "gateway" as const,
|
||||||
|
sendText,
|
||||||
|
sendMedia,
|
||||||
|
resolveTarget,
|
||||||
|
};
|
||||||
35
extensions/plivo/src/runtime.ts
Normal file
35
extensions/plivo/src/runtime.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Plivo Runtime State Management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PlivoRuntimeState } from "./types.js";
|
||||||
|
|
||||||
|
// Runtime reference from Clawdbot plugin API
|
||||||
|
let plivoRuntime: unknown = null;
|
||||||
|
|
||||||
|
// Per-account runtime state
|
||||||
|
const accountStates = new Map<string, PlivoRuntimeState>();
|
||||||
|
|
||||||
|
export function setPlivoRuntime(runtime: unknown): void {
|
||||||
|
plivoRuntime = runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlivoRuntime(): unknown {
|
||||||
|
return plivoRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAccountState(accountId: string, state: PlivoRuntimeState): void {
|
||||||
|
accountStates.set(accountId, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccountState(accountId: string): PlivoRuntimeState | undefined {
|
||||||
|
return accountStates.get(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeAccountState(accountId: string): void {
|
||||||
|
accountStates.delete(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllAccountStates(): Map<string, PlivoRuntimeState> {
|
||||||
|
return accountStates;
|
||||||
|
}
|
||||||
89
extensions/plivo/src/types.ts
Normal file
89
extensions/plivo/src/types.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Plivo SMS Channel Types
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Plivo account configuration
|
||||||
|
export type PlivoAccountConfig = {
|
||||||
|
name?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
authId?: string;
|
||||||
|
authToken?: string;
|
||||||
|
authIdFile?: string;
|
||||||
|
authTokenFile?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
webhookUrl?: string;
|
||||||
|
webhookPath?: string;
|
||||||
|
webhookSecret?: string;
|
||||||
|
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||||
|
allowFrom?: string[];
|
||||||
|
enableQuickCommands?: boolean;
|
||||||
|
quickCommands?: QuickCommand[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Multi-account configuration
|
||||||
|
export type PlivoConfig = {
|
||||||
|
accounts?: Record<string, PlivoAccountConfig>;
|
||||||
|
} & PlivoAccountConfig;
|
||||||
|
|
||||||
|
// Resolved account with required fields
|
||||||
|
export type PlivoResolvedAccount = {
|
||||||
|
authId: string;
|
||||||
|
authToken: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
webhookUrl?: string;
|
||||||
|
webhookPath: string;
|
||||||
|
webhookSecret?: string;
|
||||||
|
dmPolicy: "pairing" | "allowlist" | "open" | "disabled";
|
||||||
|
allowFrom: string[];
|
||||||
|
enableQuickCommands: boolean;
|
||||||
|
quickCommands: QuickCommand[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Quick command shortcut
|
||||||
|
export type QuickCommand = {
|
||||||
|
trigger: string;
|
||||||
|
fullCommand: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inbound webhook payload from Plivo
|
||||||
|
export type PlivoInboundWebhook = {
|
||||||
|
From: string;
|
||||||
|
To: string;
|
||||||
|
Text: string;
|
||||||
|
MessageUUID: string;
|
||||||
|
Type: "sms" | "mms";
|
||||||
|
MediaUrl0?: string;
|
||||||
|
MediaUrl1?: string;
|
||||||
|
MediaUrl2?: string;
|
||||||
|
MediaContentType0?: string;
|
||||||
|
MediaContentType1?: string;
|
||||||
|
MediaContentType2?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delivery report from Plivo
|
||||||
|
export type PlivoDeliveryReport = {
|
||||||
|
MessageUUID: string;
|
||||||
|
Status: string;
|
||||||
|
From: string;
|
||||||
|
To: string;
|
||||||
|
ErrorCode?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Runtime state for a Plivo account
|
||||||
|
export type PlivoRuntimeState = {
|
||||||
|
client: unknown; // Plivo.Client
|
||||||
|
server?: unknown; // HTTP server
|
||||||
|
phoneNumber: string;
|
||||||
|
webhookConfigured: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default quick commands
|
||||||
|
export const DEFAULT_QUICK_COMMANDS: QuickCommand[] = [
|
||||||
|
{ trigger: "cal", fullCommand: "show my calendar for today", description: "View today's calendar" },
|
||||||
|
{ trigger: "todo", fullCommand: "show my todo list", description: "View todo list" },
|
||||||
|
{ trigger: "weather", fullCommand: "what's the weather today", description: "Get weather forecast" },
|
||||||
|
{ trigger: "remind", fullCommand: "set a reminder", description: "Create a reminder" },
|
||||||
|
{ trigger: "note", fullCommand: "save a note", description: "Save a quick note" },
|
||||||
|
{ trigger: "help", fullCommand: "show available commands", description: "List all quick commands" },
|
||||||
|
];
|
||||||
292
extensions/plivo/src/webhook.ts
Normal file
292
extensions/plivo/src/webhook.ts
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* Plivo Webhook Handler
|
||||||
|
* Handles inbound SMS/MMS messages and delivery reports
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createServer, IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import { parse as parseUrl } from "node:url";
|
||||||
|
import * as Plivo from "plivo";
|
||||||
|
import type { PlivoResolvedAccount, PlivoInboundWebhook, QuickCommand } from "./types.js";
|
||||||
|
|
||||||
|
export type WebhookMessageHandler = (message: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
text: string;
|
||||||
|
messageId: string;
|
||||||
|
isMedia: boolean;
|
||||||
|
mediaUrls: string[];
|
||||||
|
accountId: string;
|
||||||
|
}) => Promise<string | void>;
|
||||||
|
|
||||||
|
export type WebhookServerOptions = {
|
||||||
|
account: PlivoResolvedAccount;
|
||||||
|
accountId: string;
|
||||||
|
path: string;
|
||||||
|
port: number;
|
||||||
|
host?: string;
|
||||||
|
onMessage: WebhookMessageHandler;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
log?: (message: string, data?: Record<string, unknown>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse URL-encoded body from request
|
||||||
|
*/
|
||||||
|
async function parseBody(req: IncomingMessage): Promise<Record<string, string>> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let body = "";
|
||||||
|
req.on("data", (chunk) => (body += chunk));
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(body);
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const [key, value] of params) {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
resolve(result);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract media URLs from webhook payload
|
||||||
|
*/
|
||||||
|
function extractMediaUrls(body: Record<string, string>): string[] {
|
||||||
|
const urls: string[] = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const url = body[`MediaUrl${i}`];
|
||||||
|
if (url) urls.push(url);
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process quick commands - expand shortcuts to full commands
|
||||||
|
*/
|
||||||
|
function processQuickCommand(
|
||||||
|
text: string,
|
||||||
|
commands: QuickCommand[],
|
||||||
|
enabled: boolean
|
||||||
|
): string {
|
||||||
|
if (!enabled || !commands.length) return text;
|
||||||
|
|
||||||
|
const commandMap = new Map(commands.map((c) => [c.trigger.toLowerCase(), c]));
|
||||||
|
const trimmedText = text.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
const exactMatch = commandMap.get(trimmedText);
|
||||||
|
if (exactMatch) return exactMatch.fullCommand;
|
||||||
|
|
||||||
|
// Prefix match (e.g., "cal tomorrow" -> "show my calendar for tomorrow")
|
||||||
|
const words = trimmedText.split(/\s+/);
|
||||||
|
const prefixMatch = commandMap.get(words[0]);
|
||||||
|
if (prefixMatch && words.length > 1) {
|
||||||
|
return `${prefixMatch.fullCommand} ${words.slice(1).join(" ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate Plivo webhook signature
|
||||||
|
*/
|
||||||
|
function validateSignature(
|
||||||
|
req: IncomingMessage,
|
||||||
|
body: Record<string, string>,
|
||||||
|
secret: string | undefined
|
||||||
|
): boolean {
|
||||||
|
if (!secret) return true; // Skip validation if no secret configured
|
||||||
|
|
||||||
|
const signature = req.headers["x-plivo-signature-v3"] as string;
|
||||||
|
const nonce = req.headers["x-plivo-signature-v3-nonce"] as string;
|
||||||
|
|
||||||
|
if (!signature || !nonce) return false;
|
||||||
|
|
||||||
|
const fullUrl = `http://${req.headers.host}${req.url}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isValid = Plivo.validateV3Signature(
|
||||||
|
req.method || "POST",
|
||||||
|
fullUrl,
|
||||||
|
nonce,
|
||||||
|
secret,
|
||||||
|
signature,
|
||||||
|
body
|
||||||
|
);
|
||||||
|
return Boolean(isValid);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and start webhook server
|
||||||
|
*/
|
||||||
|
export async function startWebhookServer(
|
||||||
|
options: WebhookServerOptions
|
||||||
|
): Promise<{ server: ReturnType<typeof createServer>; stop: () => Promise<void> }> {
|
||||||
|
const {
|
||||||
|
account,
|
||||||
|
accountId,
|
||||||
|
path,
|
||||||
|
port,
|
||||||
|
host = "0.0.0.0",
|
||||||
|
onMessage,
|
||||||
|
onError,
|
||||||
|
log = console.log,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const inboundPath = path;
|
||||||
|
const statusPath = `${path}/status`;
|
||||||
|
|
||||||
|
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||||
|
const parsedUrl = parseUrl(req.url || "", true);
|
||||||
|
const urlPath = parsedUrl.pathname || "";
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
if (urlPath === `${path}/health` && req.method === "GET") {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ status: "healthy", channel: "plivo", accountId }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only handle POST requests
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.writeHead(405);
|
||||||
|
res.end("Method Not Allowed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req);
|
||||||
|
|
||||||
|
// Validate signature in production
|
||||||
|
if (account.webhookSecret && !validateSignature(req, body, account.webhookSecret)) {
|
||||||
|
log("Invalid webhook signature", { path: urlPath });
|
||||||
|
res.writeHead(401);
|
||||||
|
res.end("Unauthorized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inbound message
|
||||||
|
if (urlPath === inboundPath) {
|
||||||
|
const webhook: PlivoInboundWebhook = {
|
||||||
|
From: body.From,
|
||||||
|
To: body.To,
|
||||||
|
Text: body.Text || "",
|
||||||
|
MessageUUID: body.MessageUUID,
|
||||||
|
Type: (body.Type as "sms" | "mms") || "sms",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mediaUrls = extractMediaUrls(body);
|
||||||
|
const isMedia = webhook.Type === "mms" || mediaUrls.length > 0;
|
||||||
|
|
||||||
|
// Process quick commands
|
||||||
|
const processedText = processQuickCommand(
|
||||||
|
webhook.Text,
|
||||||
|
account.quickCommands,
|
||||||
|
account.enableQuickCommands
|
||||||
|
);
|
||||||
|
|
||||||
|
log("Received inbound message", {
|
||||||
|
from: webhook.From,
|
||||||
|
messageId: webhook.MessageUUID,
|
||||||
|
isMedia,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call message handler
|
||||||
|
const reply = await onMessage({
|
||||||
|
from: webhook.From,
|
||||||
|
to: webhook.To,
|
||||||
|
text: processedText,
|
||||||
|
messageId: webhook.MessageUUID,
|
||||||
|
isMedia,
|
||||||
|
mediaUrls,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If handler returns a string, send as immediate reply via XML
|
||||||
|
if (reply && typeof reply === "string") {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const response = (Plivo as any).Response();
|
||||||
|
response.addMessage(reply, {
|
||||||
|
src: webhook.To,
|
||||||
|
dst: webhook.From,
|
||||||
|
});
|
||||||
|
res.writeHead(200, { "Content-Type": "application/xml" });
|
||||||
|
res.end(response.toXML());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end("OK");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery status report
|
||||||
|
if (urlPath === statusPath) {
|
||||||
|
log("Received delivery report", {
|
||||||
|
messageId: body.MessageUUID,
|
||||||
|
status: body.Status,
|
||||||
|
});
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end("OK");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end("Not Found");
|
||||||
|
} catch (error) {
|
||||||
|
log("Webhook error", { error: String(error) });
|
||||||
|
onError?.(error as Error);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end("Internal Server Error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.on("error", reject);
|
||||||
|
server.listen(port, host, () => {
|
||||||
|
log(`Plivo webhook server started`, { port, path: inboundPath });
|
||||||
|
resolve({
|
||||||
|
server,
|
||||||
|
stop: () =>
|
||||||
|
new Promise<void>((res) => {
|
||||||
|
server.close(() => res());
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-configure Plivo phone number webhooks
|
||||||
|
*/
|
||||||
|
export async function autoConfigureWebhooks(
|
||||||
|
client: Plivo.Client,
|
||||||
|
phoneNumber: string,
|
||||||
|
webhookUrl: string,
|
||||||
|
log?: (message: string, data?: Record<string, unknown>) => void
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const normalizedNumber = phoneNumber.replace(/^\+/, "");
|
||||||
|
const logger = log || console.log;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await (client.numbers as any).update(normalizedNumber, {
|
||||||
|
message_url: webhookUrl,
|
||||||
|
message_method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
logger("Auto-configured Plivo webhooks", { phoneNumber, webhookUrl });
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
logger("Failed to auto-configure webhooks", { error: errorMessage });
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
}
|
||||||
209
extensions/plivo/test-integration.ts
Normal file
209
extensions/plivo/test-integration.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* Integration test for Plivo SMS extension
|
||||||
|
* Tests core functionality without requiring real Plivo credentials
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { plivoPlugin } from "./src/channel.js";
|
||||||
|
import { startWebhookServer } from "./src/webhook.js";
|
||||||
|
import type { PlivoResolvedAccount } from "./src/types.js";
|
||||||
|
|
||||||
|
const TEST_PORT = 19876;
|
||||||
|
|
||||||
|
// Mock account config
|
||||||
|
const mockAccount: PlivoResolvedAccount = {
|
||||||
|
authId: "test_auth_id",
|
||||||
|
authToken: "test_auth_token",
|
||||||
|
phoneNumber: "+15551234567",
|
||||||
|
webhookPath: "/plivo",
|
||||||
|
dmPolicy: "open",
|
||||||
|
allowFrom: [],
|
||||||
|
enableQuickCommands: true,
|
||||||
|
quickCommands: [
|
||||||
|
{ trigger: "cal", fullCommand: "show my calendar for today", description: "Calendar" },
|
||||||
|
{ trigger: "todo", fullCommand: "show my todo list", description: "Todos" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock Clawdbot config
|
||||||
|
const mockConfig = {
|
||||||
|
channels: {
|
||||||
|
plivo: {
|
||||||
|
authId: "test_auth_id",
|
||||||
|
authToken: "test_auth_token",
|
||||||
|
phoneNumber: "+15551234567",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function testConfigAdapter() {
|
||||||
|
console.log("\n📋 Testing Config Adapter...");
|
||||||
|
|
||||||
|
// Test listAccountIds
|
||||||
|
const accountIds = plivoPlugin.config.listAccountIds(mockConfig);
|
||||||
|
console.log(` ✓ listAccountIds: ${JSON.stringify(accountIds)}`);
|
||||||
|
if (accountIds.length !== 1 || accountIds[0] !== "default") {
|
||||||
|
throw new Error("listAccountIds failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test isConfigured
|
||||||
|
const isConfigured = plivoPlugin.config.isConfigured(mockConfig);
|
||||||
|
console.log(` ✓ isConfigured: ${isConfigured}`);
|
||||||
|
if (!isConfigured) {
|
||||||
|
throw new Error("isConfigured should be true");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test describeAccount
|
||||||
|
const description = plivoPlugin.config.describeAccount(mockConfig);
|
||||||
|
console.log(` ✓ describeAccount: ${JSON.stringify(description)}`);
|
||||||
|
|
||||||
|
console.log(" ✅ Config adapter tests passed!");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testOutboundAdapter() {
|
||||||
|
console.log("\n📤 Testing Outbound Adapter...");
|
||||||
|
|
||||||
|
// Test resolveTarget
|
||||||
|
const validTarget = plivoPlugin.outbound.resolveTarget("+15559876543");
|
||||||
|
console.log(` ✓ resolveTarget (valid): ${JSON.stringify(validTarget)}`);
|
||||||
|
if (!validTarget.ok) {
|
||||||
|
throw new Error("resolveTarget should succeed for valid number");
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidTarget = plivoPlugin.outbound.resolveTarget("abc");
|
||||||
|
console.log(` ✓ resolveTarget (invalid): ${JSON.stringify(invalidTarget)}`);
|
||||||
|
if (invalidTarget.ok) {
|
||||||
|
throw new Error("resolveTarget should fail for invalid number");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 10-digit US number normalization
|
||||||
|
const usNumber = plivoPlugin.outbound.resolveTarget("5559876543");
|
||||||
|
console.log(` ✓ resolveTarget (10-digit): ${JSON.stringify(usNumber)}`);
|
||||||
|
if (usNumber.to !== "+15559876543") {
|
||||||
|
throw new Error("10-digit number should be normalized to +1");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(" ✅ Outbound adapter tests passed!");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testWebhookServer() {
|
||||||
|
console.log("\n🌐 Testing Webhook Server...");
|
||||||
|
|
||||||
|
let receivedMessage: unknown = null;
|
||||||
|
|
||||||
|
const { server, stop } = await startWebhookServer({
|
||||||
|
account: mockAccount,
|
||||||
|
accountId: "test",
|
||||||
|
path: "/plivo",
|
||||||
|
port: TEST_PORT,
|
||||||
|
onMessage: async (message) => {
|
||||||
|
receivedMessage = message;
|
||||||
|
return "Test reply";
|
||||||
|
},
|
||||||
|
log: () => {}, // Silent logging for tests
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✓ Webhook server started on port ${TEST_PORT}`);
|
||||||
|
|
||||||
|
// Test health endpoint
|
||||||
|
const healthResponse = await fetch(`http://localhost:${TEST_PORT}/plivo/health`);
|
||||||
|
const healthData = await healthResponse.json();
|
||||||
|
console.log(` ✓ Health check: ${JSON.stringify(healthData)}`);
|
||||||
|
if (healthData.status !== "healthy") {
|
||||||
|
throw new Error("Health check failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test inbound SMS webhook
|
||||||
|
const inboundPayload = new URLSearchParams({
|
||||||
|
From: "+15559876543",
|
||||||
|
To: "+15551234567",
|
||||||
|
Text: "Hello world",
|
||||||
|
MessageUUID: "test-uuid-123",
|
||||||
|
Type: "sms",
|
||||||
|
});
|
||||||
|
|
||||||
|
const inboundResponse = await fetch(`http://localhost:${TEST_PORT}/plivo`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: inboundPayload.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✓ Inbound webhook status: ${inboundResponse.status}`);
|
||||||
|
if (inboundResponse.status !== 200) {
|
||||||
|
throw new Error("Inbound webhook failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify message was received
|
||||||
|
if (!receivedMessage) {
|
||||||
|
throw new Error("Message handler was not called");
|
||||||
|
}
|
||||||
|
console.log(` ✓ Received message: ${JSON.stringify(receivedMessage)}`);
|
||||||
|
|
||||||
|
// Test quick command expansion
|
||||||
|
receivedMessage = null;
|
||||||
|
const quickCommandPayload = new URLSearchParams({
|
||||||
|
From: "+15559876543",
|
||||||
|
To: "+15551234567",
|
||||||
|
Text: "cal",
|
||||||
|
MessageUUID: "test-uuid-456",
|
||||||
|
Type: "sms",
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetch(`http://localhost:${TEST_PORT}/plivo`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: quickCommandPayload.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const msg = receivedMessage as { text: string };
|
||||||
|
console.log(` ✓ Quick command expanded: "${msg?.text}"`);
|
||||||
|
if (msg?.text !== "show my calendar for today") {
|
||||||
|
throw new Error("Quick command not expanded correctly");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop server
|
||||||
|
await stop();
|
||||||
|
console.log(" ✓ Webhook server stopped");
|
||||||
|
|
||||||
|
console.log(" ✅ Webhook server tests passed!");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testCapabilities() {
|
||||||
|
console.log("\n🔧 Testing Channel Capabilities...");
|
||||||
|
|
||||||
|
console.log(` ✓ Channel ID: ${plivoPlugin.id}`);
|
||||||
|
console.log(` ✓ Supports text: ${plivoPlugin.capabilities.supportsText}`);
|
||||||
|
console.log(` ✓ Supports media: ${plivoPlugin.capabilities.supportsMedia}`);
|
||||||
|
console.log(` ✓ Max text length: ${plivoPlugin.capabilities.maxTextLength}`);
|
||||||
|
|
||||||
|
if (!plivoPlugin.capabilities.supportsText) {
|
||||||
|
throw new Error("Should support text");
|
||||||
|
}
|
||||||
|
if (!plivoPlugin.capabilities.supportsMedia) {
|
||||||
|
throw new Error("Should support media");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(" ✅ Capabilities tests passed!");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAllTests() {
|
||||||
|
console.log("🧪 Plivo SMS Extension Integration Tests\n");
|
||||||
|
console.log("=========================================");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await testCapabilities();
|
||||||
|
await testConfigAdapter();
|
||||||
|
await testOutboundAdapter();
|
||||||
|
await testWebhookServer();
|
||||||
|
|
||||||
|
console.log("\n=========================================");
|
||||||
|
console.log("✅ All integration tests passed!\n");
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("\n❌ Test failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runAllTests();
|
||||||
20
extensions/plivo/tsconfig.json
Normal file
20
extensions/plivo/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": ".",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["index.ts", "src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user