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