mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-08 21:12:55 +00:00
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
113 lines
4.0 KiB
TypeScript
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);
|
|
}
|