Files
openclaw/extensions/sms/src/gateway.ts
Peter Steinberger 3513e8bfd9 feat: add Twilio SMS channel
Add a bundled SMS channel backed by Twilio inbound webhooks and outbound text delivery.

Includes signed webhook validation, pairing/allowlist access, Messaging Service sender support, chunked plain-text SMS delivery, default target support, docs, config metadata, labeler updates, and focused SMS coverage.

Verification:
- pnpm exec tsgo -p extensions/sms/tsconfig.json --noEmit
- OPENCLAW_VITEST_FS_MODULE_CACHE_PATH=/tmp/openclaw-vitest-sms-land-fix2 node scripts/run-vitest.mjs extensions/sms/src/phone.test.ts extensions/sms/src/accounts.test.ts extensions/sms/src/twilio.test.ts extensions/sms/src/inbound.test.ts extensions/sms/src/gateway.test.ts extensions/sms/src/channel.test.ts extensions/sms/src/send.test.ts extensions/sms/src/webhook.test.ts --reporter=verbose
- pnpm config:channels:check
- pnpm plugins:inventory:check
- git diff --check
- .agents/skills/autoreview/scripts/autoreview --mode local
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
2026-05-31 09:22:31 +01:00

113 lines
4.0 KiB
TypeScript

import { waitUntilAbort } from "openclaw/plugin-sdk/channel-outbound";
import { registerPluginHttpRoute } from "openclaw/plugin-sdk/webhook-ingress";
import type { ResolvedSmsAccount } from "./types.js";
import { createSmsWebhookHandler, type SmsWebhookHandlerParams } from "./webhook.js";
const CHANNEL_ID = "sms";
const activeRoutes = new Map<string, () => void>();
const activeRoutePaths = new Map<string, string>();
type SmsGatewayLog = {
info?: (message: string) => void;
warn?: (message: string) => void;
error?: (message: string) => void;
};
function routeKey(account: ResolvedSmsAccount): string {
return `${account.accountId}:${normalizeWebhookPath(account.webhookPath)}`;
}
function normalizeWebhookPath(path: string): string {
const trimmed = path.trim();
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}
export function collectSmsStartupWarnings(account: ResolvedSmsAccount): string[] {
const warnings: string[] = [];
if (
!account.accountSid ||
!account.authToken ||
(!account.fromNumber && !account.messagingServiceSid)
) {
warnings.push(
"- SMS: accountSid, authToken, and fromNumber or messagingServiceSid are required.",
);
}
if (!account.publicWebhookUrl && !account.dangerouslyDisableSignatureValidation) {
warnings.push(
"- SMS: publicWebhookUrl is required for Twilio signature validation. Set dangerouslyDisableSignatureValidation=true only for local testing.",
);
}
if (account.dmPolicy === "allowlist" && account.allowFrom.length === 0) {
warnings.push("- SMS: dmPolicy=allowlist with empty allowFrom rejects every sender.");
}
if (account.dmPolicy === "open" && !account.allowFrom.includes("*")) {
warnings.push('- SMS: dmPolicy=open should set allowFrom=["*"] or explicit sender numbers.');
}
return warnings;
}
export function registerSmsWebhookRoute(params: {
cfg: SmsWebhookHandlerParams["cfg"];
account: ResolvedSmsAccount;
channelRuntime: SmsWebhookHandlerParams["channelRuntime"];
log?: SmsGatewayLog;
}): () => void {
const key = routeKey(params.account);
const webhookPath = normalizeWebhookPath(params.account.webhookPath);
const currentPathOwner = activeRoutePaths.get(webhookPath);
if (currentPathOwner && currentPathOwner !== params.account.accountId) {
throw new Error(
`SMS webhook path ${webhookPath} is already registered by account ${currentPathOwner}; configure a distinct webhookPath for account ${params.account.accountId}.`,
);
}
activeRoutes.get(key)?.();
activeRoutePaths.delete(webhookPath);
const unregister = registerPluginHttpRoute({
path: webhookPath,
auth: "plugin",
pluginId: CHANNEL_ID,
accountId: params.account.accountId,
log: (msg) => params.log?.info?.(msg),
handler: createSmsWebhookHandler(params),
});
activeRoutes.set(key, unregister);
activeRoutePaths.set(webhookPath, params.account.accountId);
return () => {
unregister();
activeRoutes.delete(key);
if (activeRoutePaths.get(webhookPath) === params.account.accountId) {
activeRoutePaths.delete(webhookPath);
}
};
}
export async function startSmsGatewayAccount(params: {
cfg: SmsWebhookHandlerParams["cfg"];
account: ResolvedSmsAccount;
channelRuntime: SmsWebhookHandlerParams["channelRuntime"];
abortSignal: AbortSignal;
log?: SmsGatewayLog;
}) {
if (!params.account.enabled) {
params.log?.info?.(`SMS account ${params.account.accountId} is disabled`);
return waitUntilAbort(params.abortSignal);
}
const warnings = collectSmsStartupWarnings(params.account);
if (warnings.some((warning) => warning.includes("required"))) {
for (const warning of warnings) {
params.log?.warn?.(warning);
}
return waitUntilAbort(params.abortSignal);
}
for (const warning of warnings) {
params.log?.warn?.(warning);
}
const unregister = registerSmsWebhookRoute(params);
params.log?.info?.(
`Registered SMS webhook route ${params.account.webhookPath} for account ${params.account.accountId}`,
);
return waitUntilAbort(params.abortSignal, unregister);
}