Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,263 @@
// Sms tests cover accounts plugin behavior.
import { afterEach, describe, expect, it } from "vitest";
import { listSmsAccountIds, resolveSmsAccount } from "./accounts.js";
import { SmsConfigSchema } from "./config-schema.js";
const ENV_KEYS = [
"TWILIO_ACCOUNT_SID",
"TWILIO_AUTH_TOKEN",
"TWILIO_PHONE_NUMBER",
"TWILIO_SMS_FROM",
"TWILIO_MESSAGING_SERVICE_SID",
"SMS_PUBLIC_WEBHOOK_URL",
"SMS_WEBHOOK_PATH",
"SMS_ALLOWED_USERS",
"SMS_DANGEROUSLY_DISABLE_SIGNATURE_VALIDATION",
"SMS_TEXT_CHUNK_LIMIT",
] as const;
afterEach(() => {
for (const key of ENV_KEYS) {
delete process.env[key];
}
});
describe("SMS account config", () => {
it("resolves default account config and pairing policy", () => {
const account = resolveSmsAccount({
channels: {
sms: {
accountSid: " AC123 ",
authToken: " token ",
fromNumber: "(555) 123-4567",
defaultTo: "sms:+1 (555) 000-1111",
publicWebhookUrl: " https://example.com/webhooks/sms ",
},
},
});
expect(account).toMatchObject({
accountId: "default",
accountSid: "AC123",
authToken: "token",
fromNumber: "+5551234567",
messagingServiceSid: "",
defaultTo: "+15550001111",
webhookPath: "/webhooks/sms",
publicWebhookUrl: "https://example.com/webhooks/sms",
dmPolicy: "pairing",
allowFrom: [],
textChunkLimit: 1500,
});
});
it("merges named accounts over the top-level defaults", () => {
const cfg = {
channels: {
sms: {
accountSid: "AC-parent",
authToken: "parent-token",
fromNumber: "+15550000000",
accounts: {
support: {
fromNumber: "+15551112222",
webhookPath: "/webhooks/sms/support",
dmPolicy: "allowlist",
allowFrom: ["sms:+15553334444"],
},
},
},
},
};
expect(listSmsAccountIds(cfg)).toEqual(["default", "support"]);
expect(resolveSmsAccount(cfg, "support")).toMatchObject({
accountId: "support",
accountSid: "AC-parent",
authToken: "parent-token",
fromNumber: "+15551112222",
webhookPath: "/webhooks/sms/support",
dmPolicy: "allowlist",
allowFrom: ["+15553334444"],
});
});
it("normalizes numeric allowFrom entries accepted by config schema", () => {
const cfg = {
channels: {
sms: {
accountSid: "AC-parent",
authToken: "parent-token",
fromNumber: "+15550000000",
allowFrom: [1_555_333_4444],
},
},
};
expect(SmsConfigSchema.parse(cfg.channels.sms).allowFrom).toEqual([1_555_333_4444]);
expect(resolveSmsAccount(cfg)).toMatchObject({
allowFrom: ["+15553334444"],
});
});
it("uses the configured default account when accountId is omitted", () => {
const cfg = {
channels: {
sms: {
defaultAccount: "support",
accounts: {
support: {
accountSid: "AC-support",
authToken: "support-token",
fromNumber: "+15551112222",
textChunkLimit: 700,
},
},
},
},
};
expect(resolveSmsAccount(cfg)).toMatchObject({
accountId: "support",
accountSid: "AC-support",
authToken: "support-token",
fromNumber: "+15551112222",
textChunkLimit: 700,
});
});
it("treats top-level enabled false as a channel kill switch", () => {
const cfg = {
channels: {
sms: {
enabled: false,
accounts: {
support: {
enabled: true,
accountSid: "AC-support",
authToken: "support-token",
fromNumber: "+15551112222",
},
},
},
},
};
expect(resolveSmsAccount(cfg, "support")).toMatchObject({
accountId: "support",
enabled: false,
});
});
it("uses env fallbacks for the default account only", () => {
process.env.TWILIO_ACCOUNT_SID = "AC-env";
process.env.TWILIO_AUTH_TOKEN = "env-token";
process.env.TWILIO_PHONE_NUMBER = "+15550001111";
process.env.TWILIO_MESSAGING_SERVICE_SID = "MG-env";
process.env.SMS_WEBHOOK_PATH = "/webhooks/sms/env";
process.env.SMS_PUBLIC_WEBHOOK_URL = "https://sms.example.com/webhook";
process.env.SMS_ALLOWED_USERS = "sms:+15552223333,+15554445555";
process.env.SMS_DANGEROUSLY_DISABLE_SIGNATURE_VALIDATION = "true";
process.env.SMS_TEXT_CHUNK_LIMIT = "800";
const cfg = { channels: { sms: { accounts: { support: { enabled: true } } } } };
expect(listSmsAccountIds(cfg)).toEqual(["default", "support"]);
expect(resolveSmsAccount(cfg)).toMatchObject({
accountSid: "AC-env",
authToken: "env-token",
fromNumber: "+15550001111",
messagingServiceSid: "MG-env",
webhookPath: "/webhooks/sms/env",
publicWebhookUrl: "https://sms.example.com/webhook",
dangerouslyDisableSignatureValidation: true,
allowFrom: ["+15552223333", "+15554445555"],
textChunkLimit: 800,
});
expect(resolveSmsAccount(cfg, "support")).toMatchObject({
accountId: "support",
accountSid: "",
authToken: "",
fromNumber: "",
messagingServiceSid: "",
webhookPath: "/webhooks/sms",
publicWebhookUrl: "",
dangerouslyDisableSignatureValidation: false,
allowFrom: [],
textChunkLimit: 1500,
});
});
it("coerces numeric allowFrom entries accepted by the config schema", () => {
const parsed = SmsConfigSchema.parse({
accountSid: "AC123",
authToken: "token",
fromNumber: "+15550001111",
allowFrom: [15551234567],
});
expect(resolveSmsAccount({ channels: { sms: parsed } })).toMatchObject({
allowFrom: ["+15551234567"],
});
});
it("discovers env-only SMS credentials as the implicit default account", () => {
process.env.TWILIO_ACCOUNT_SID = "AC-env";
process.env.TWILIO_AUTH_TOKEN = "env-token";
process.env.TWILIO_SMS_FROM = "+15550001111";
expect(listSmsAccountIds({})).toEqual(["default"]);
expect(resolveSmsAccount({})).toMatchObject({
accountId: "default",
accountSid: "AC-env",
authToken: "env-token",
fromNumber: "+15550001111",
});
});
it("uses TWILIO_SMS_FROM when the legacy from-number env var is blank", () => {
process.env.TWILIO_ACCOUNT_SID = "AC-env";
process.env.TWILIO_AUTH_TOKEN = "env-token";
process.env.TWILIO_PHONE_NUMBER = " ";
process.env.TWILIO_SMS_FROM = "+15550001111";
expect(resolveSmsAccount({})).toMatchObject({
fromNumber: "+15550001111",
});
});
it("accepts a Twilio Messaging Service SID instead of a from number", () => {
process.env.TWILIO_ACCOUNT_SID = "AC-env";
process.env.TWILIO_AUTH_TOKEN = "env-token";
process.env.TWILIO_MESSAGING_SERVICE_SID = "MG-env";
expect(listSmsAccountIds({})).toEqual(["default"]);
expect(resolveSmsAccount({})).toMatchObject({
accountSid: "AC-env",
authToken: "env-token",
fromNumber: "",
messagingServiceSid: "MG-env",
});
});
it("accepts secret references for Twilio auth tokens", () => {
expect(() =>
SmsConfigSchema.parse({
accountSid: "AC123",
authToken: { source: "env", provider: "default", id: "TWILIO_AUTH_TOKEN" },
fromNumber: "+15550001111",
}),
).not.toThrow();
expect(() =>
resolveSmsAccount({
channels: {
sms: {
accountSid: "AC123",
authToken: { source: "env", provider: "default", id: "TWILIO_AUTH_TOKEN" },
fromNumber: "+15550001111",
},
},
}),
).toThrow('channels.sms.authToken: unresolved SecretRef "env:default:TWILIO_AUTH_TOKEN"');
});
});

View File

@@ -0,0 +1,174 @@
// Sms plugin module implements accounts behavior.
import { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id";
import {
DEFAULT_ACCOUNT_ID,
listCombinedAccountIds,
resolveAccountEntry,
resolveListedDefaultAccountId,
resolveMergedAccountConfig,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-resolution";
import { parseStrictInteger } from "openclaw/plugin-sdk/number-runtime";
import {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
} from "openclaw/plugin-sdk/secret-input";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import { normalizeSmsAllowFrom, normalizeSmsPhoneNumber } from "./phone.js";
import type { ResolvedSmsAccount, SmsChannelConfig } from "./types.js";
const CHANNEL_ID = "sms";
const DEFAULT_WEBHOOK_PATH = "/webhooks/sms";
const DEFAULT_TEXT_CHUNK_LIMIT = 1500;
function getChannelConfig(cfg: OpenClawConfig): SmsChannelConfig | undefined {
return cfg?.channels?.[CHANNEL_ID] as SmsChannelConfig | undefined;
}
function parseList(raw: unknown): string[] {
if (!raw) {
return [];
}
const entries = Array.isArray(raw)
? raw
: typeof raw === "string"
? normalizeStringEntries(raw.split(","))
: [raw];
return entries.map((entry) => normalizeSmsAllowFrom(String(entry))).filter(Boolean);
}
function parseTextChunkLimit(raw: unknown): number {
if (typeof raw === "number" && Number.isSafeInteger(raw) && raw > 0) {
return raw;
}
if (typeof raw === "string" && /^\d+$/.test(raw.trim())) {
return parseStrictInteger(raw.trim()) ?? DEFAULT_TEXT_CHUNK_LIMIT;
}
return DEFAULT_TEXT_CHUNK_LIMIT;
}
function firstNonBlankEnv(...values: Array<string | undefined>): string | undefined {
return values.find((value) => value?.trim());
}
function hasBaseAccount(channelCfg: SmsChannelConfig | undefined): boolean {
return Boolean(
channelCfg?.accountSid ||
hasConfiguredSecretInput(channelCfg?.authToken) ||
channelCfg?.fromNumber ||
channelCfg?.messagingServiceSid ||
process.env.TWILIO_ACCOUNT_SID ||
process.env.TWILIO_AUTH_TOKEN ||
process.env.TWILIO_PHONE_NUMBER ||
process.env.TWILIO_SMS_FROM ||
process.env.TWILIO_MESSAGING_SERVICE_SID,
);
}
export function listSmsAccountIds(cfg: OpenClawConfig): string[] {
const channelCfg = getChannelConfig(cfg);
return listCombinedAccountIds({
configuredAccountIds: Object.keys(channelCfg?.accounts ?? {}),
implicitAccountId: hasBaseAccount(channelCfg) ? DEFAULT_ACCOUNT_ID : undefined,
});
}
export function resolveDefaultSmsAccountId(cfg: OpenClawConfig): string {
const channelCfg = getChannelConfig(cfg);
return resolveListedDefaultAccountId({
accountIds: listSmsAccountIds(cfg),
configuredDefaultAccountId: normalizeOptionalAccountId(channelCfg?.defaultAccount),
});
}
export function resolveSmsAccount(
cfg: OpenClawConfig,
accountId?: string | null,
): ResolvedSmsAccount {
const channelCfg = getChannelConfig(cfg) ?? {};
const id = normalizeOptionalAccountId(accountId) ?? resolveDefaultSmsAccountId(cfg);
const accountConfig = resolveAccountEntry(channelCfg.accounts, id);
const channelConfig: Record<string, unknown> & SmsChannelConfig = { ...channelCfg };
const accountEntries:
| Record<string, Partial<Record<string, unknown> & SmsChannelConfig>>
| undefined = channelCfg.accounts
? Object.fromEntries(
Object.entries(channelCfg.accounts).map(([accountKey, account]) => [
accountKey,
{ ...account },
]),
)
: undefined;
const merged = resolveMergedAccountConfig<Record<string, unknown> & SmsChannelConfig>({
channelConfig,
accounts: accountEntries,
accountId: id,
omitKeys: ["defaultAccount"],
});
const useEnvFallbacks = id === DEFAULT_ACCOUNT_ID;
const envAccountSid = useEnvFallbacks ? process.env.TWILIO_ACCOUNT_SID : undefined;
const envAuthToken = useEnvFallbacks ? process.env.TWILIO_AUTH_TOKEN : undefined;
const envFromNumber = useEnvFallbacks
? firstNonBlankEnv(process.env.TWILIO_PHONE_NUMBER, process.env.TWILIO_SMS_FROM)
: undefined;
const envMessagingServiceSid = useEnvFallbacks
? process.env.TWILIO_MESSAGING_SERVICE_SID
: undefined;
const envWebhookPath = useEnvFallbacks ? process.env.SMS_WEBHOOK_PATH : undefined;
const envPublicWebhookUrl = useEnvFallbacks ? process.env.SMS_PUBLIC_WEBHOOK_URL : undefined;
const envAllowFrom = useEnvFallbacks ? process.env.SMS_ALLOWED_USERS : undefined;
const envTextChunkLimit = useEnvFallbacks ? process.env.SMS_TEXT_CHUNK_LIMIT : undefined;
const envDisableSignatureValidation = useEnvFallbacks
? process.env.SMS_DANGEROUSLY_DISABLE_SIGNATURE_VALIDATION
: undefined;
const webhookPath = (merged.webhookPath ?? envWebhookPath ?? DEFAULT_WEBHOOK_PATH).trim();
const publicWebhookUrl = (merged.publicWebhookUrl ?? envPublicWebhookUrl ?? "").trim();
const authToken =
normalizeResolvedSecretInputString({
value: merged.authToken ?? envAuthToken,
path:
id === DEFAULT_ACCOUNT_ID
? "channels.sms.authToken"
: `channels.sms.accounts.${id}.authToken`,
}) ?? "";
return {
accountId: id,
enabled: channelCfg.enabled !== false && accountConfig?.enabled !== false,
accountSid: (merged.accountSid ?? envAccountSid ?? "").trim(),
authToken,
fromNumber: normalizeSmsPhoneNumber(merged.fromNumber ?? envFromNumber ?? ""),
messagingServiceSid: (merged.messagingServiceSid ?? envMessagingServiceSid ?? "").trim(),
defaultTo: normalizeSmsPhoneNumber(merged.defaultTo ?? ""),
webhookPath: webhookPath || DEFAULT_WEBHOOK_PATH,
publicWebhookUrl,
dangerouslyDisableSignatureValidation:
merged.dangerouslyDisableSignatureValidation === true ||
envDisableSignatureValidation === "true",
dmPolicy: merged.dmPolicy ?? "pairing",
allowFrom: parseList(merged.allowFrom ?? envAllowFrom),
textChunkLimit: parseTextChunkLimit(merged.textChunkLimit ?? envTextChunkLimit),
};
}
export function inspectSmsAccount(cfg: OpenClawConfig, accountId?: string | null) {
const account = resolveSmsAccount(cfg, accountId);
const configured = isSmsAccountConfigured(account);
return {
enabled: account.enabled,
configured,
tokenStatus: account.authToken ? "available" : "missing",
webhookPath: account.webhookPath,
signatureValidation:
account.dangerouslyDisableSignatureValidation || account.publicWebhookUrl
? "configured"
: "missing-public-url",
};
}
export function isSmsAccountConfigured(account: ResolvedSmsAccount): boolean {
return Boolean(
account.accountSid && account.authToken && (account.fromNumber || account.messagingServiceSid),
);
}

View File

@@ -0,0 +1,156 @@
// Sms tests cover channel plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type ChannelModule = typeof import("./channel.js");
let resolveSmsTextChunkLimit: ChannelModule["resolveSmsTextChunkLimit"];
let smsPlugin: ChannelModule["smsPlugin"];
const sendSmsViaTwilio = vi.hoisted(() =>
vi.fn(async ({ to }) => ({
sid: "SM-default",
to,
from: "+15557654321",
status: "queued",
})),
);
beforeEach(async () => {
vi.resetModules();
sendSmsViaTwilio.mockClear();
vi.doMock("./twilio.js", () => ({
sendSmsViaTwilio,
}));
({ resolveSmsTextChunkLimit, smsPlugin } = await import("./channel.js"));
});
afterEach(() => {
vi.doUnmock("./twilio.js");
});
describe("smsPlugin status", () => {
it("builds a status snapshot for configured SMS accounts", async () => {
const snapshot = await smsPlugin.status?.buildAccountSnapshot?.({
cfg: {},
account: {
accountId: "support",
enabled: true,
accountSid: "AC123",
authToken: "secret",
fromNumber: "+15557654321",
messagingServiceSid: "",
defaultTo: "",
webhookPath: "/webhooks/sms",
publicWebhookUrl: "",
dangerouslyDisableSignatureValidation: false,
dmPolicy: "pairing",
allowFrom: [],
textChunkLimit: 1500,
},
});
expect(snapshot).toEqual({
accountId: "support",
name: "+15557654321",
enabled: true,
configured: true,
statusState: "configured",
});
});
});
describe("smsPlugin outbound", () => {
it("declares an active text chunker and account-aware chunk limit", () => {
expect(smsPlugin.configSchema).toBeDefined();
expect(smsPlugin.status?.probeAccount).toBeDefined();
expect(smsPlugin.status?.formatCapabilitiesProbe).toBeDefined();
expect(smsPlugin.secrets?.secretTargetRegistryEntries?.map((entry) => entry.id)).toEqual([
"channels.sms.accounts.*.authToken",
"channels.sms.authToken",
]);
expect(smsPlugin.messaging?.targetPrefixes).toEqual(["twilio-sms"]);
expect(smsPlugin.outbound?.chunker?.("alpha beta", 6)).toEqual(["alpha", "beta"]);
expect(
resolveSmsTextChunkLimit({
cfg: {
channels: {
sms: {
accountSid: "AC123",
authToken: "secret",
fromNumber: "+15557654321",
textChunkLimit: 42,
},
},
},
}),
).toBe(42);
expect(
resolveSmsTextChunkLimit({
cfg: {
channels: {
sms: {
defaultAccount: "support",
accounts: {
support: {
accountSid: "AC-support",
authToken: "support-token",
fromNumber: "+15551112222",
textChunkLimit: 700,
},
},
},
},
},
}),
).toBe(700);
});
it("uses defaultTo for targetless sends and preserves Twilio receipt metadata", async () => {
const result = await smsPlugin.outbound?.sendText?.({
cfg: {
channels: {
sms: {
accountSid: "AC123",
authToken: "secret",
fromNumber: "+15557654321",
defaultTo: "+15551234567",
},
},
},
to: "",
text: "hello",
});
expect(sendSmsViaTwilio).toHaveBeenCalledWith(
expect.objectContaining({ to: "+15551234567", text: "hello" }),
);
expect(result?.messageId).toBe("SM-default");
expect(result?.receipt?.raw?.[0]).toMatchObject({
messageId: "SM-default",
chatId: "+15551234567",
toJid: "+15551234567",
meta: {
from: "+15557654321",
status: "queued",
},
});
});
it("resolves the configured default SMS target for outbound delivery", () => {
expect(
smsPlugin.outbound?.resolveTarget?.({
cfg: {
channels: {
sms: {
accountSid: "AC123",
authToken: "secret",
fromNumber: "+15557654321",
defaultTo: "+15551234567",
},
},
},
to: "",
}),
).toEqual({ ok: true, to: "+15551234567" });
});
});

View File

@@ -0,0 +1,330 @@
// Sms plugin module implements channel behavior.
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/account-resolution";
import {
createHybridChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import {
createMessageReceiptFromOutboundResults,
defineChannelMessageAdapter,
} from "openclaw/plugin-sdk/channel-outbound";
import { createConditionalWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
import {
inspectSmsAccount,
isSmsAccountConfigured,
listSmsAccountIds,
resolveDefaultSmsAccountId,
resolveSmsAccount,
} from "./accounts.js";
import { SmsChannelConfigSchema } from "./config-schema.js";
import { collectSmsStartupWarnings, startSmsGatewayAccount } from "./gateway.js";
import type { SmsChannelRuntime } from "./inbound.js";
import {
looksLikeSmsPhoneNumber,
normalizeSmsAllowFrom,
normalizeSmsPhoneNumber,
} from "./phone.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
import { sendSmsTextChunks, toSmsPlainText } from "./send.js";
import { formatSmsProbeLines, probeSmsAccount, type SmsProbe } from "./status.js";
import type { ResolvedSmsAccount } from "./types.js";
const CHANNEL_ID = "sms";
const smsConfigAdapter = createHybridChannelConfigAdapter<ResolvedSmsAccount>({
sectionKey: CHANNEL_ID,
listAccountIds: listSmsAccountIds,
resolveAccount: resolveSmsAccount,
defaultAccountId: resolveDefaultSmsAccountId,
clearBaseFields: [
"accountSid",
"authToken",
"fromNumber",
"messagingServiceSid",
"defaultTo",
"webhookPath",
"publicWebhookUrl",
"dangerouslyDisableSignatureValidation",
"dmPolicy",
"allowFrom",
"textChunkLimit",
],
resolveAllowFrom: (account) => account.allowFrom,
formatAllowFrom: (allowFrom) =>
normalizeStringEntries(allowFrom.map((entry) => normalizeSmsAllowFrom(String(entry)))),
resolveDefaultTo: (account) => account.defaultTo,
});
const resolveSmsDmPolicy = createScopedDmSecurityResolver<ResolvedSmsAccount>({
channelKey: CHANNEL_ID,
resolvePolicy: (account) => account.dmPolicy,
resolveAllowFrom: (account) => account.allowFrom,
policyPathSuffix: "dmPolicy",
defaultPolicy: "pairing",
approveHint: "openclaw pairing approve sms <code>",
normalizeEntry: normalizeSmsAllowFrom,
});
const collectSmsSecurityWarnings = createConditionalWarningCollector<ResolvedSmsAccount>(
(account) =>
account.dangerouslyDisableSignatureValidation &&
"- SMS: Twilio signature validation is disabled. Only use this for local testing.",
(account) =>
account.dmPolicy === "open" &&
account.allowFrom.includes("*") &&
'- SMS: dmPolicy="open" allows any phone number to message the bot.',
);
function smsSetupPatch(input: Record<string, unknown>): Record<string, unknown> {
const patch: Record<string, unknown> = {};
for (const key of [
"accountSid",
"authToken",
"fromNumber",
"messagingServiceSid",
"defaultTo",
"webhookPath",
"publicWebhookUrl",
"dmPolicy",
"allowFrom",
]) {
if (input[key] !== undefined) {
patch[key] = input[key];
}
}
return patch;
}
function applySmsAccountConfig(params: {
cfg: OpenClawConfig;
accountId: string;
input: Record<string, unknown>;
}): OpenClawConfig {
const patch = smsSetupPatch(params.input);
const channels = { ...params.cfg.channels };
const current = { ...(channels[CHANNEL_ID] as Record<string, unknown> | undefined) };
if (params.accountId === DEFAULT_ACCOUNT_ID) {
channels[CHANNEL_ID] = { ...current, ...patch };
return { ...params.cfg, channels };
}
const accounts = { ...(current.accounts as Record<string, unknown> | undefined) };
accounts[params.accountId] = {
...(accounts[params.accountId] as Record<string, unknown> | undefined),
...patch,
};
channels[CHANNEL_ID] = { ...current, accounts };
return { ...params.cfg, channels };
}
function createSmsReceipt(params: {
results: Array<{ sid: string; to: string; from?: string; status?: string }>;
kind: "text";
}) {
const first = params.results[0];
if (!first) {
throw new Error("SMS send did not return a Twilio Message SID.");
}
return {
channel: CHANNEL_ID,
messageId: first.sid,
chatId: first.to,
receipt: createMessageReceiptFromOutboundResults({
results: params.results.map((result) => ({
channel: CHANNEL_ID,
messageId: result.sid,
chatId: result.to,
toJid: result.to,
conversationId: result.to,
meta: {
...(result.from ? { from: result.from } : {}),
...(result.status ? { status: result.status } : {}),
},
})),
threadId: first.to,
kind: params.kind,
}),
};
}
export function resolveSmsTextChunkLimit(params: {
cfg: OpenClawConfig;
accountId?: string | null;
fallbackLimit?: number;
}): number {
return (
resolveSmsAccount(params.cfg, params.accountId).textChunkLimit || params.fallbackLimit || 1500
);
}
async function sendSmsText(ctx: {
cfg: OpenClawConfig;
accountId?: string | null;
to: string;
text: string;
}) {
const account = resolveSmsAccount(ctx.cfg, ctx.accountId);
const to = normalizeSmsPhoneNumber(ctx.to) || account.defaultTo;
if (!looksLikeSmsPhoneNumber(to)) {
throw new Error(`Invalid SMS target: ${ctx.to}`);
}
const results = await sendSmsTextChunks({ account, to, text: ctx.text });
return createSmsReceipt({ results, kind: "text" });
}
const smsMessageAdapter = defineChannelMessageAdapter({
id: CHANNEL_ID,
durableFinal: {
capabilities: {
text: true,
media: false,
messageSendingHooks: true,
},
},
send: {
text: async (ctx) => await sendSmsText(ctx),
},
});
export const smsPlugin: ChannelPlugin<ResolvedSmsAccount, SmsProbe> = createChatChannelPlugin({
base: {
id: CHANNEL_ID,
meta: {
id: CHANNEL_ID,
label: "SMS",
selectionLabel: "SMS (Twilio)",
detailLabel: "Twilio SMS",
docsPath: "/channels/sms",
docsLabel: "sms",
blurb: "Twilio-backed SMS with inbound webhooks and outbound replies.",
order: 88,
},
capabilities: {
chatTypes: ["direct"],
media: false,
threads: false,
reactions: false,
edit: false,
unsend: false,
reply: false,
effects: false,
blockStreaming: false,
},
reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
configSchema: SmsChannelConfigSchema,
setup: {
applyAccountConfig: applySmsAccountConfig,
},
config: {
...smsConfigAdapter,
inspectAccount: inspectSmsAccount,
isConfigured: isSmsAccountConfigured,
unconfiguredReason: () =>
"SMS requires accountSid, authToken, and fromNumber or messagingServiceSid.",
describeAccount: (account) => ({
accountId: account.accountId,
name: account.fromNumber || account.messagingServiceSid || "SMS",
configured: isSmsAccountConfigured(account),
enabled: account.enabled,
}),
},
messaging: {
targetPrefixes: ["twilio-sms"],
normalizeTarget: (target) => normalizeSmsPhoneNumber(target),
targetResolver: {
looksLikeId: looksLikeSmsPhoneNumber,
hint: "<+15551234567>",
},
},
directory: createEmptyChannelDirectoryAdapter(),
gateway: {
startAccount: async (ctx) => {
if (!ctx.channelRuntime) {
ctx.log?.warn?.("SMS channel runtime is not available; webhook route not started");
return;
}
return await startSmsGatewayAccount({
cfg: ctx.cfg,
account: ctx.account,
channelRuntime: ctx.channelRuntime as unknown as SmsChannelRuntime,
abortSignal: ctx.abortSignal,
log: ctx.log,
});
},
},
status: {
buildAccountSnapshot: ({ account }) => {
const configured = isSmsAccountConfigured(account);
return {
accountId: account.accountId,
name: account.fromNumber || account.messagingServiceSid || "SMS",
enabled: account.enabled,
configured,
statusState: !account.enabled ? "disabled" : configured ? "configured" : "unconfigured",
};
},
probeAccount: async ({ account, timeoutMs }) => await probeSmsAccount({ account, timeoutMs }),
formatCapabilitiesProbe: ({ probe }) => formatSmsProbeLines(probe),
buildCapabilitiesDiagnostics: async ({ account }) => ({
lines: collectSmsStartupWarnings(account).map((text) => ({ text, tone: "warn" })),
}),
},
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
},
agentPrompt: {
messageToolHints: () => [
"",
"### SMS Formatting",
"SMS is plain text only. Keep replies brief, avoid markdown tables, and split long details into short messages.",
],
},
message: smsMessageAdapter,
},
pairing: {
text: {
idLabel: "phoneNumber",
message: "OpenClaw: your SMS access has been approved.",
normalizeAllowEntry: normalizeSmsAllowFrom,
notify: async ({ cfg, id, message, accountId }) => {
const account = resolveSmsAccount(cfg, accountId);
await sendSmsTextChunks({
account,
to: normalizeSmsPhoneNumber(id),
text: message,
});
},
},
},
security: {
resolveDmPolicy: resolveSmsDmPolicy,
collectWarnings: ({ account }) => collectSmsSecurityWarnings(account),
},
outbound: {
deliveryMode: "gateway",
chunker: chunkTextForOutbound,
chunkerMode: "text",
textChunkLimit: 1500,
resolveEffectiveTextChunkLimit: resolveSmsTextChunkLimit,
resolveTarget: ({ cfg, to, accountId }) => {
const explicit = normalizeSmsPhoneNumber(to ?? "");
if (explicit) {
return { ok: true, to: explicit };
}
if (cfg) {
const account = resolveSmsAccount(cfg, accountId);
if (account.defaultTo) {
return { ok: true, to: account.defaultTo };
}
}
return { ok: false, error: new Error("SMS target must be an E.164 phone number.") };
},
sanitizeText: ({ text }) => toSmsPlainText(text),
sendText: sendSmsText,
},
});

View File

@@ -0,0 +1,93 @@
// Sms helper module supports config schema behavior.
import {
AllowFromListSchema,
buildChannelConfigSchema,
DmPolicySchema,
requireOpenAllowFrom,
} from "openclaw/plugin-sdk/channel-config-primitives";
import { requireChannelOpenAllowFrom } from "openclaw/plugin-sdk/extension-shared";
import { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input";
import { z } from "zod";
const SecretInputSchema = buildSecretInputSchema();
const SmsAccountConfigSchema = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
accountSid: z.string().optional(),
authToken: SecretInputSchema.optional(),
fromNumber: z.string().optional(),
messagingServiceSid: z.string().optional(),
defaultTo: z.string().optional(),
webhookPath: z.string().optional(),
publicWebhookUrl: z.string().optional(),
dangerouslyDisableSignatureValidation: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: AllowFromListSchema,
textChunkLimit: z.number().int().positive().optional(),
})
.strict()
.superRefine((value, ctx) => {
requireChannelOpenAllowFrom({
channel: "sms",
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
requireOpenAllowFrom,
});
});
export const SmsConfigSchema = SmsAccountConfigSchema.extend({
accounts: z.record(z.string(), SmsAccountConfigSchema.optional()).optional(),
defaultAccount: z.string().optional(),
});
export const SmsChannelConfigSchema = buildChannelConfigSchema(SmsConfigSchema, {
uiHints: {
"": {
label: "SMS",
help: "Twilio SMS channel configuration for inbound webhooks and outbound text replies.",
},
accountSid: {
label: "Twilio Account SID",
help: "Twilio Account SID used for SMS outbound API calls.",
},
authToken: {
label: "Twilio Auth Token",
help: "Twilio Auth Token used to sign webhook validation and SMS outbound API calls.",
},
fromNumber: {
label: "SMS From Number",
help: "Twilio SMS-capable phone number in E.164 format, for example +15551234567.",
},
messagingServiceSid: {
label: "Twilio Messaging Service SID",
help: "Twilio Messaging Service SID to use instead of a dedicated fromNumber.",
},
defaultTo: {
label: "SMS Default To Number",
help: "Optional default outbound phone number used when a send flow omits an explicit SMS target.",
},
publicWebhookUrl: {
label: "SMS Public Webhook URL",
help: "Public URL configured in Twilio for incoming messages. Must match Twilio's signed URL exactly.",
},
webhookPath: {
label: "SMS Webhook Path",
help: "Gateway HTTP path that receives Twilio incoming-message webhooks. Use a distinct path per account.",
},
dmPolicy: {
label: "SMS DM Policy",
help: 'Direct SMS access control ("pairing" recommended). "open" requires channels.sms.allowFrom=["*"].',
},
allowFrom: {
label: "SMS Allow From",
help: "Allowed sender phone numbers in E.164 format, or * when dmPolicy is open.",
},
textChunkLimit: {
label: "SMS Text Chunk Limit",
help: "Maximum characters per outbound SMS chunk before OpenClaw splits long replies.",
},
},
});

View File

@@ -0,0 +1,107 @@
// Sms tests cover gateway plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerSmsWebhookRoute } from "./gateway.js";
import type { SmsChannelRuntime } from "./inbound.js";
import type { ResolvedSmsAccount } from "./types.js";
const registerPluginHttpRoute = vi.hoisted(() => vi.fn(() => vi.fn()));
vi.mock("openclaw/plugin-sdk/webhook-ingress", () => ({
createFixedWindowRateLimiter: () => ({
clear: vi.fn(),
isRateLimited: vi.fn(() => false),
size: vi.fn(() => 0),
}),
readRequestBodyWithLimit: vi.fn(async () => ""),
registerPluginHttpRoute,
}));
const registeredRoutes: Array<() => void> = [];
function createAccount(accountId: string, webhookPath = "/webhooks/sms"): ResolvedSmsAccount {
return {
accountId,
enabled: true,
accountSid: `AC-${accountId}`,
authToken: "secret",
fromNumber: "+15557654321",
messagingServiceSid: "",
defaultTo: "",
webhookPath,
publicWebhookUrl: `https://gateway.example.com${webhookPath}`,
dangerouslyDisableSignatureValidation: false,
dmPolicy: "pairing",
allowFrom: [],
textChunkLimit: 1500,
};
}
describe("registerSmsWebhookRoute", () => {
beforeEach(() => {
registerPluginHttpRoute.mockClear();
});
afterEach(() => {
for (const unregister of registeredRoutes.toReversed()) {
unregister();
}
registeredRoutes.length = 0;
});
function registerRoute(params: Parameters<typeof registerSmsWebhookRoute>[0]) {
const unregister = registerSmsWebhookRoute(params);
registeredRoutes.push(unregister);
return unregister;
}
it("rejects duplicate webhook paths across SMS accounts", () => {
const channelRuntime = {} as SmsChannelRuntime;
registerRoute({
cfg: {},
account: createAccount("default"),
channelRuntime,
});
expect(() =>
registerRoute({
cfg: {},
account: createAccount("support"),
channelRuntime,
}),
).toThrow(/already registered by account default/u);
});
it("rejects duplicate webhook paths after route normalization", () => {
const channelRuntime = {} as SmsChannelRuntime;
registerRoute({
cfg: {},
account: createAccount("default", "/webhooks/sms"),
channelRuntime,
});
expect(() =>
registerRoute({
cfg: {},
account: createAccount("support", "webhooks/sms"),
channelRuntime,
}),
).toThrow(/already registered by account default/u);
expect(registerPluginHttpRoute).toHaveBeenCalledTimes(1);
});
it("allows distinct webhook paths across SMS accounts", () => {
const channelRuntime = {} as SmsChannelRuntime;
registerRoute({
cfg: {},
account: createAccount("default"),
channelRuntime,
});
registerRoute({
cfg: {},
account: createAccount("support", "/webhooks/sms/support"),
channelRuntime,
});
expect(registerPluginHttpRoute).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,113 @@
// Sms plugin module implements gateway behavior.
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);
}

View File

@@ -0,0 +1,167 @@
// Sms tests cover inbound plugin behavior.
import { describe, expect, it, vi } from "vitest";
import { dispatchSmsInboundEvent, type SmsChannelRuntime } from "./inbound.js";
import type { sendSmsViaTwilio as sendSmsViaTwilioType } from "./twilio.js";
import type { ResolvedSmsAccount } from "./types.js";
const sendSmsViaTwilio = vi.hoisted(() =>
vi.fn<typeof sendSmsViaTwilioType>(async () => ({ sid: "SM-pair", to: "+15551234567" })),
);
vi.mock("./twilio.js", () => ({
sendSmsViaTwilio,
}));
function createAccount(overrides: Partial<ResolvedSmsAccount> = {}): ResolvedSmsAccount {
return {
accountId: "default",
enabled: true,
accountSid: "AC123",
authToken: "secret",
fromNumber: "+15557654321",
messagingServiceSid: "",
defaultTo: "",
webhookPath: "/webhooks/sms",
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
dangerouslyDisableSignatureValidation: false,
dmPolicy: "pairing",
allowFrom: [],
textChunkLimit: 1500,
...overrides,
};
}
function createRuntime() {
const readAllowFromStore = vi.fn(async () => [] as string[]);
const upsertPairingRequest = vi.fn(async () => ({ code: "PAIR123", created: true }));
const resolveAgentRoute = vi.fn();
const run = vi.fn<
(params: {
adapter: {
ingest: (msg: {
from: string;
to: string;
body: string;
messageSid: string;
accountSid: string;
}) => unknown;
resolveTurn: (ingested: unknown) => Promise<{ routeSessionKey: string }>;
};
}) => void
>();
const buildContext = vi.fn();
const resolveStorePath = vi.fn();
const runtime = {
pairing: {
readAllowFromStore,
upsertPairingRequest,
},
routing: {
resolveAgentRoute,
},
inbound: {
run,
buildContext,
},
session: {
resolveStorePath,
recordInboundSession: vi.fn(),
},
reply: {
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
},
} as unknown as SmsChannelRuntime;
return {
runtime,
readAllowFromStore,
upsertPairingRequest,
resolveAgentRoute,
run,
buildContext,
resolveStorePath,
};
}
describe("dispatchSmsInboundEvent", () => {
it("creates and sends a pairing challenge for first-time SMS senders", async () => {
const { runtime, readAllowFromStore, upsertPairingRequest } = createRuntime();
await dispatchSmsInboundEvent({
cfg: {},
account: createAccount(),
channelRuntime: runtime,
msg: {
from: "+15551234567",
to: "+15557654321",
body: "hello",
messageSid: "SM-inbound",
accountSid: "AC123",
},
});
expect(readAllowFromStore).toHaveBeenCalledWith({
channel: "sms",
accountId: "default",
});
expect(upsertPairingRequest).toHaveBeenCalledWith({
channel: "sms",
accountId: "default",
id: "+15551234567",
meta: undefined,
});
expect(sendSmsViaTwilio).toHaveBeenCalledOnce();
expect(sendSmsViaTwilio).toHaveBeenCalledWith(
expect.objectContaining({
to: "+15551234567",
text: expect.stringContaining("PAIR123"),
}),
);
});
it("uses the canonical routed session key for authorized SMS turns", async () => {
const { runtime, resolveAgentRoute, run, buildContext, resolveStorePath } = createRuntime();
resolveAgentRoute.mockReturnValue({
agentId: "main",
accountId: "default",
sessionKey: "agent:main:sms:direct:+15551234567",
});
buildContext.mockReturnValue({ SessionKey: "agent:main:sms:direct:+15551234567" });
resolveStorePath.mockReturnValue("/tmp/openclaw-sessions");
await dispatchSmsInboundEvent({
cfg: {},
account: createAccount({
dmPolicy: "allowlist",
allowFrom: ["+15551234567"],
}),
channelRuntime: runtime,
msg: {
from: "+15551234567",
to: "+15557654321",
body: "hello",
messageSid: "SM-inbound",
accountSid: "AC123",
},
});
const runParams = run.mock.calls[0]?.[0];
const ingested = runParams.adapter.ingest({
from: "+15551234567",
to: "+15557654321",
body: "hello",
messageSid: "SM-inbound",
accountSid: "AC123",
});
const turn = await runParams.adapter.resolveTurn(ingested);
expect(buildContext).toHaveBeenCalledWith(
expect.objectContaining({
route: expect.objectContaining({
routeSessionKey: "agent:main:sms:direct:+15551234567",
dispatchSessionKey: "agent:main:sms:direct:+15551234567",
}),
}),
);
expect(turn.routeSessionKey).toBe("agent:main:sms:direct:+15551234567");
});
});

View File

@@ -0,0 +1,215 @@
// Sms plugin module implements inbound behavior.
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import { normalizeSmsPhoneNumber } from "./phone.js";
import { sendSmsTextChunks } from "./send.js";
import type { ResolvedSmsAccount, SmsInboundMessage } from "./types.js";
const CHANNEL_ID = "sms";
type SmsLog = {
info?: (message: string) => void;
warn?: (message: string) => void;
};
export type SmsChannelRuntime = Pick<
PluginRuntime["channel"],
"inbound" | "pairing" | "reply" | "routing" | "session"
>;
async function authorizeSmsSender(params: {
cfg: OpenClawConfig;
account: ResolvedSmsAccount;
channelRuntime: SmsChannelRuntime;
from: string;
}) {
return await resolveStableChannelMessageIngress({
channelId: CHANNEL_ID,
accountId: params.account.accountId,
cfg: params.cfg,
identity: {
key: "phone",
entryIdPrefix: "sms-entry",
},
readStoreAllowFrom: async () =>
await params.channelRuntime.pairing.readAllowFromStore({
channel: CHANNEL_ID,
accountId: params.account.accountId,
}),
subject: { stableId: params.from },
conversation: {
kind: "direct",
id: "direct",
},
event: { mayPair: true },
dmPolicy: params.account.dmPolicy,
allowFrom: params.account.allowFrom,
});
}
async function issueSmsPairingChallenge(params: {
account: ResolvedSmsAccount;
channelRuntime: SmsChannelRuntime;
from: string;
log?: SmsLog;
}) {
const issueChallenge = createChannelPairingChallengeIssuer({
channel: CHANNEL_ID,
upsertPairingRequest: async (input) =>
await params.channelRuntime.pairing.upsertPairingRequest({
channel: CHANNEL_ID,
accountId: params.account.accountId,
...input,
}),
});
await issueChallenge({
senderId: params.from,
senderIdLine: `Your SMS phone number: ${params.from}`,
sendPairingReply: async (text) => {
await sendSmsTextChunks({
account: params.account,
to: params.from,
text,
});
},
onCreated: () => {
params.log?.info?.(`SMS pairing request created for ${params.from}`);
},
onReplyError: (err) => {
params.log?.warn?.(`SMS pairing reply failed for ${params.from}: ${String(err)}`);
},
});
}
export async function dispatchSmsInboundEvent(params: {
cfg: OpenClawConfig;
account: ResolvedSmsAccount;
msg: SmsInboundMessage;
channelRuntime: SmsChannelRuntime;
log?: SmsLog;
}): Promise<void> {
const from = normalizeSmsPhoneNumber(params.msg.from);
const auth = await authorizeSmsSender({
cfg: params.cfg,
account: params.account,
channelRuntime: params.channelRuntime,
from,
});
if (!auth.senderAccess.allowed) {
if (auth.senderAccess.decision === "pairing") {
await issueSmsPairingChallenge({
account: params.account,
channelRuntime: params.channelRuntime,
from,
log: params.log,
});
return;
}
params.log?.warn?.(`SMS sender ${from} is not authorized`);
return;
}
const route = params.channelRuntime.routing.resolveAgentRoute({
cfg: params.cfg,
channel: CHANNEL_ID,
accountId: params.account.accountId,
peer: {
kind: "direct",
id: from,
},
});
const sessionKey = route.sessionKey;
await params.channelRuntime.inbound.run({
channel: CHANNEL_ID,
accountId: params.account.accountId,
raw: params.msg,
adapter: {
ingest: (msg) => ({
id: msg.messageSid,
timestamp: Date.now(),
rawText: msg.body,
textForAgent: msg.body,
textForCommands: msg.body,
raw: msg,
}),
resolveTurn: async (input) => {
const ctxPayload = params.channelRuntime.inbound.buildContext({
channel: CHANNEL_ID,
accountId: params.account.accountId,
timestamp: input.timestamp,
from: `sms:${from}`,
sender: {
id: from,
name: from,
},
conversation: {
kind: "direct",
id: from,
label: from,
},
route: {
agentId: route.agentId,
accountId: params.account.accountId,
routeSessionKey: sessionKey,
dispatchSessionKey: sessionKey,
},
reply: {
to: `sms:${from}`,
},
message: {
rawBody: input.rawText,
commandBody: input.textForCommands,
bodyForAgent: input.textForAgent,
},
extra: {
MessageSid: params.msg.messageSid,
To: params.msg.to,
},
});
const storePath = params.channelRuntime.session.resolveStorePath(
params.cfg.session?.store,
{
agentId: route.agentId,
},
);
return {
cfg: params.cfg,
channel: CHANNEL_ID,
accountId: params.account.accountId,
agentId: route.agentId,
routeSessionKey: sessionKey,
storePath,
ctxPayload,
recordInboundSession: params.channelRuntime.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
params.channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
durable: () => ({
to: from,
}),
deliver: async (payload) => {
const text = payload.text;
if (!text) {
return { visibleReplySent: false };
}
await sendSmsTextChunks({
account: params.account,
to: from,
text,
});
return { visibleReplySent: true };
},
},
dispatcherOptions: {
onReplyStart: () => {
params.log?.info?.(`SMS reply started for ${from}`);
},
},
};
},
},
});
}

View File

@@ -0,0 +1,23 @@
// Sms tests cover phone plugin behavior.
import { describe, expect, it } from "vitest";
import {
looksLikeSmsPhoneNumber,
normalizeSmsAllowFrom,
normalizeSmsPhoneNumber,
} from "./phone.js";
describe("SMS phone normalization", () => {
it("normalizes sms-prefixed E.164 phone numbers", () => {
expect(normalizeSmsPhoneNumber("sms:+1 (555) 123-4567")).toBe("+15551234567");
expect(normalizeSmsPhoneNumber("twilio-sms:+1 (555) 123-4567")).toBe("+15551234567");
expect(normalizeSmsAllowFrom("SMS:+44 20 7946 0958")).toBe("+442079460958");
expect(normalizeSmsAllowFrom("*")).toBe("*");
});
it("validates E.164-ish SMS targets", () => {
expect(looksLikeSmsPhoneNumber("+15551234567")).toBe(true);
expect(looksLikeSmsPhoneNumber("15551234567")).toBe(true);
expect(looksLikeSmsPhoneNumber("+01234567")).toBe(false);
expect(looksLikeSmsPhoneNumber("+1555")).toBe(false);
});
});

View File

@@ -0,0 +1,21 @@
// Sms plugin module implements phone behavior.
export function normalizeSmsPhoneNumber(raw: string): string {
const trimmed = raw.trim().replace(/^(?:sms|twilio-sms):/i, "");
if (!trimmed) {
return "";
}
const withPlus = trimmed.startsWith("+") ? trimmed : `+${trimmed}`;
return withPlus.replace(/[^\d+]/g, "");
}
export function looksLikeSmsPhoneNumber(raw: string): boolean {
const normalized = normalizeSmsPhoneNumber(raw);
return /^\+[1-9]\d{6,14}$/.test(normalized);
}
export function normalizeSmsAllowFrom(raw: string): string {
if (raw.trim() === "*") {
return "*";
}
return normalizeSmsPhoneNumber(raw).toLowerCase();
}

View File

@@ -0,0 +1,10 @@
// Sms plugin module implements runtime behavior.
import { createPluginRuntimeStore, type PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
const { setRuntime: setSmsRuntime, getRuntime: getSmsRuntime } =
createPluginRuntimeStore<PluginRuntime>({
pluginId: "sms",
errorMessage: "SMS runtime not initialized - plugin not registered",
});
export { getSmsRuntime, setSmsRuntime };

View File

@@ -0,0 +1,163 @@
// Sms tests cover secret contract plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
applyResolvedAssignments,
createResolverContext,
resolveSecretRefValues,
} from "openclaw/plugin-sdk/secret-ref-runtime";
import { describe, expect, it } from "vitest";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
async function resolveSmsSecretAssignments(
sourceConfig: OpenClawConfig,
env: NodeJS.ProcessEnv,
): Promise<{
config: OpenClawConfig;
warnings: ReturnType<typeof createResolverContext>["warnings"];
}> {
const resolvedConfig: OpenClawConfig = structuredClone(sourceConfig);
const context = createResolverContext({ sourceConfig, env });
collectRuntimeConfigAssignments({
config: resolvedConfig,
defaults: sourceConfig.secrets?.defaults,
context,
});
const resolved = await resolveSecretRefValues(
context.assignments.map((assignment) => assignment.ref),
{
config: sourceConfig,
env: context.env,
cache: context.cache,
},
);
applyResolvedAssignments({ assignments: context.assignments, resolved });
return { config: resolvedConfig, warnings: context.warnings };
}
describe("sms secret contract", () => {
it("publishes SMS auth token targets", () => {
expect(secretTargetRegistryEntries.map((entry) => entry.id)).toEqual([
"channels.sms.accounts.*.authToken",
"channels.sms.authToken",
]);
});
it("resolves top-level authToken SecretRefs for SMS accounts", async () => {
const resolved = await resolveSmsSecretAssignments(
{
channels: {
sms: {
enabled: true,
accountSid: "AC123",
authToken: { source: "env", provider: "default", id: "TWILIO_AUTH_TOKEN" },
fromNumber: "+15557654321",
},
},
} as OpenClawConfig,
{ TWILIO_AUTH_TOKEN: "resolved-token" },
);
expect(resolved.config.channels?.sms?.authToken).toBe("resolved-token");
expect(resolved.warnings).toStrictEqual([]);
});
it("keeps top-level authToken active for an implicit default sender plus named accounts", async () => {
const resolved = await resolveSmsSecretAssignments(
{
channels: {
sms: {
enabled: true,
accountSid: "AC123",
authToken: { source: "env", provider: "default", id: "TWILIO_DEFAULT_TOKEN" },
fromNumber: "+15557654321",
accounts: {
support: {
enabled: true,
accountSid: "AC456",
authToken: { source: "env", provider: "default", id: "TWILIO_SUPPORT_TOKEN" },
fromNumber: "+15558675309",
},
},
},
},
} as OpenClawConfig,
{
TWILIO_DEFAULT_TOKEN: "resolved-default-token",
TWILIO_SUPPORT_TOKEN: "resolved-support-token",
},
);
expect(resolved.config.channels?.sms?.authToken).toBe("resolved-default-token");
expect(resolved.config.channels?.sms?.accounts?.support?.authToken).toBe(
"resolved-support-token",
);
expect(resolved.warnings).toStrictEqual([]);
});
it("keeps top-level authToken active for env-backed default senders plus named accounts", async () => {
const resolved = await resolveSmsSecretAssignments(
{
channels: {
sms: {
enabled: true,
authToken: { source: "env", provider: "default", id: "TWILIO_DEFAULT_TOKEN" },
accounts: {
support: {
enabled: true,
accountSid: "AC456",
authToken: { source: "env", provider: "default", id: "TWILIO_SUPPORT_TOKEN" },
fromNumber: "+15558675309",
},
},
},
},
} as OpenClawConfig,
{
TWILIO_ACCOUNT_SID: "AC-env",
TWILIO_PHONE_NUMBER: "+15550001111",
TWILIO_DEFAULT_TOKEN: "resolved-default-token",
TWILIO_SUPPORT_TOKEN: "resolved-support-token",
},
);
expect(resolved.config.channels?.sms?.authToken).toBe("resolved-default-token");
expect(resolved.config.channels?.sms?.accounts?.support?.authToken).toBe(
"resolved-support-token",
);
expect(resolved.warnings).toStrictEqual([]);
});
it("treats top-level authToken refs as inactive when all enabled accounts override them", async () => {
const resolved = await resolveSmsSecretAssignments(
{
channels: {
sms: {
authToken: { source: "env", provider: "default", id: "UNUSED_TWILIO_TOKEN" },
accounts: {
support: {
enabled: true,
accountSid: "AC456",
authToken: { source: "env", provider: "default", id: "TWILIO_SUPPORT_TOKEN" },
fromNumber: "+15558675309",
},
},
},
},
} as OpenClawConfig,
{ TWILIO_SUPPORT_TOKEN: "resolved-support-token" },
);
expect(resolved.config.channels?.sms?.authToken).toEqual({
source: "env",
provider: "default",
id: "UNUSED_TWILIO_TOKEN",
});
expect(resolved.config.channels?.sms?.accounts?.support?.authToken).toBe(
"resolved-support-token",
);
expect(resolved.warnings.map((warning) => warning.path)).toContain("channels.sms.authToken");
});
});

View File

@@ -0,0 +1,97 @@
// Sms plugin module implements secret contract behavior.
import {
collectConditionalChannelFieldAssignments,
getChannelSurface,
hasOwnProperty,
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/channel-secret-basic-runtime";
const DEFAULT_ACCOUNT_ID = "default";
export const secretTargetRegistryEntries = [
{
id: "channels.sms.accounts.*.authToken",
targetType: "channels.sms.accounts.*.authToken",
configFile: "openclaw.json",
pathPattern: "channels.sms.accounts.*.authToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.sms.authToken",
targetType: "channels.sms.authToken",
configFile: "openclaw.json",
pathPattern: "channels.sms.authToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
] satisfies SecretTargetRegistryEntry[];
function hasTopLevelSmsAccount(channel: Record<string, unknown>): boolean {
for (const field of ["accountSid", "fromNumber", "messagingServiceSid", "defaultTo"]) {
if (typeof channel[field] === "string" && channel[field].trim().length > 0) {
return true;
}
}
return false;
}
function hasEnvBackedDefaultSmsAccount(env: NodeJS.ProcessEnv): boolean {
for (const name of [
"TWILIO_ACCOUNT_SID",
"TWILIO_AUTH_TOKEN",
"TWILIO_PHONE_NUMBER",
"TWILIO_SMS_FROM",
"TWILIO_MESSAGING_SERVICE_SID",
]) {
if (typeof env[name] === "string" && env[name].trim().length > 0) {
return true;
}
}
return false;
}
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "sms");
if (!resolved) {
return;
}
const { channel: sms, surface } = resolved;
const hasExplicitDefaultAccount = surface.accounts.some(
({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
);
const topLevelSmsAccountActive =
(hasTopLevelSmsAccount(sms) || hasEnvBackedDefaultSmsAccount(params.context.env)) &&
!hasExplicitDefaultAccount;
collectConditionalChannelFieldAssignments({
channelKey: "sms",
field: "authToken",
channel: sms,
surface,
defaults: params.defaults,
context: params.context,
topLevelActiveWithoutAccounts: true,
topLevelInheritedAccountActive: ({ account, enabled }) =>
topLevelSmsAccountActive || (enabled && !hasOwnProperty(account, "authToken")),
accountActive: ({ enabled }) => enabled,
topInactiveReason: "no enabled SMS surface inherits this top-level authToken.",
accountInactiveReason: "SMS account is disabled.",
});
}
export const channelSecrets = {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
};

View File

@@ -0,0 +1,71 @@
// Sms tests cover send plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ResolvedSmsAccount } from "./types.js";
type SendModule = typeof import("./send.js");
let sendSmsTextChunks: SendModule["sendSmsTextChunks"];
let toSmsPlainText: SendModule["toSmsPlainText"];
const sendSmsViaTwilio = vi.hoisted(() => vi.fn(async ({ to }) => ({ sid: `SM-${to}`, to })));
beforeEach(async () => {
vi.resetModules();
sendSmsViaTwilio.mockClear();
vi.doMock("./twilio.js", () => ({
sendSmsViaTwilio,
}));
({ sendSmsTextChunks, toSmsPlainText } = await import("./send.js"));
});
afterEach(() => {
vi.doUnmock("./twilio.js");
});
function createAccount(textChunkLimit: number): ResolvedSmsAccount {
return {
accountId: "default",
enabled: true,
accountSid: "AC123",
authToken: "secret",
fromNumber: "+15557654321",
messagingServiceSid: "",
defaultTo: "",
webhookPath: "/webhooks/sms",
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
dangerouslyDisableSignatureValidation: false,
dmPolicy: "pairing",
allowFrom: [],
textChunkLimit,
};
}
describe("sendSmsTextChunks", () => {
it("splits long SMS text before sending to Twilio", async () => {
await sendSmsTextChunks({
account: createAccount(5),
to: "+15551234567",
text: "alpha beta",
});
expect(sendSmsViaTwilio).toHaveBeenCalledTimes(2);
expect(sendSmsViaTwilio.mock.calls.map(([call]) => call.text)).toEqual(["alpha", "beta"]);
});
it("flattens markdown before sending SMS chunks", async () => {
expect(
toSmsPlainText("**Hi** [docs](https://example.com)\n\n```bash\napprove 123\n```\nthere"),
).toBe("Hi docs (https://example.com)\n\napprove 123\nthere");
});
it("strips internal tool-trace banners before sending SMS chunks", async () => {
await sendSmsTextChunks({
account: createAccount(1500),
to: "+15551234567",
text: "**Done.**\n⚠ 🛠️ `search repos (agent)` failed",
});
expect(sendSmsViaTwilio).toHaveBeenCalledOnce();
expect(sendSmsViaTwilio.mock.calls[0]?.[0].text).toBe("Done.");
});
});

View File

@@ -0,0 +1,52 @@
// Sms plugin module implements send behavior.
import {
chunkTextForOutbound,
sanitizeAssistantVisibleText,
stripMarkdown,
} from "openclaw/plugin-sdk/text-chunking";
import { sendSmsViaTwilio } from "./twilio.js";
import type { ResolvedSmsAccount, SmsSendResult } from "./types.js";
export function toSmsPlainText(text: string): string {
const visibleText = sanitizeAssistantVisibleText(text);
const withoutFencedCodeMarkers = visibleText.replace(
/```[^\n]*\n?([\s\S]*?)```/g,
(_match, body: string) => body.trim(),
);
const withReadableLinks = withoutFencedCodeMarkers.replace(
/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g,
(_match, label: string, url: string) => {
const cleanLabel = label.trim();
const cleanUrl = url.trim();
return cleanLabel && cleanLabel !== cleanUrl ? `${cleanLabel} (${cleanUrl})` : cleanUrl;
},
);
return stripMarkdown(withReadableLinks)
.replace(/\r\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
export async function sendSmsTextChunks(params: {
account: ResolvedSmsAccount;
to: string;
text: string;
}): Promise<SmsSendResult[]> {
const text = toSmsPlainText(params.text);
if (!text) {
throw new Error("SMS send requires non-empty text.");
}
const chunks = chunkTextForOutbound(text, params.account.textChunkLimit).filter(Boolean);
const sendChunks = chunks.length ? chunks : [text];
const results: SmsSendResult[] = [];
for (const textLocal of sendChunks) {
results.push(
await sendSmsViaTwilio({
account: params.account,
to: params.to,
text: textLocal,
}),
);
}
return results;
}

View File

@@ -0,0 +1,267 @@
// Sms tests cover status plugin behavior.
import { describe, expect, it, vi } from "vitest";
import { formatSmsProbeLines, probeSmsAccount } from "./status.js";
import type { ResolvedSmsAccount } from "./types.js";
function createAccount(overrides: Partial<ResolvedSmsAccount> = {}): ResolvedSmsAccount {
return {
accountId: "default",
enabled: true,
accountSid: "AC123",
authToken: "secret",
fromNumber: "+15557654321",
messagingServiceSid: "",
defaultTo: "",
webhookPath: "/webhooks/sms",
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
dangerouslyDisableSignatureValidation: false,
dmPolicy: "pairing",
allowFrom: [],
textChunkLimit: 1500,
...overrides,
};
}
function createFetch(responses: Array<unknown>): typeof fetch {
return vi.fn(async () => {
const payload = responses.shift();
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as unknown as typeof fetch;
}
describe("SMS status probe", () => {
it("reports a healthy Twilio SMS webhook", async () => {
const fetchImpl = createFetch([
{
incoming_phone_numbers: [
{
phone_number: "+15557654321",
sms_url: "https://gateway.example.com/webhooks/sms",
sms_method: "POST",
voice_url: "https://gateway.example.com/voice/webhook",
},
],
},
{
messages: [
{
sid: "SM123",
direction: "inbound",
status: "received",
to: "+15557654321",
from: "+15551234567",
},
],
},
]);
await expect(
probeSmsAccount({
account: createAccount(),
timeoutMs: 1000,
options: { fetchImpl },
}),
).resolves.toMatchObject({
ok: true,
webhook: {
status: "matches",
configuredUrl: "https://gateway.example.com/webhooks/sms",
voiceUrl: "https://gateway.example.com/voice/webhook",
},
recentInbound: {
sid: "SM123",
status: "received",
},
});
const result = await probeSmsAccount({
account: createAccount(),
timeoutMs: 1000,
options: {
fetchImpl: createFetch([
{
incoming_phone_numbers: [
{
phone_number: "+15557654321",
sms_url: "https://gateway.example.com/webhooks/sms",
sms_method: "POST",
},
],
},
{
messages: [
{
sid: "SM456",
direction: "inbound",
status: "received",
to: "+15557654321",
from: "+15551234567",
},
],
},
]),
},
});
expect(result.recentInbound).not.toHaveProperty("from");
expect(result.recentInbound).not.toHaveProperty("to");
});
it("detects a Twilio SMS webhook URL mismatch", async () => {
const fetchImpl = createFetch([
{
incoming_phone_numbers: [
{
phone_number: "+15557654321",
sms_url: "https://old.example.com/webhooks/sms",
sms_method: "POST",
},
],
},
{ messages: [] },
]);
await expect(
probeSmsAccount({
account: createAccount(),
timeoutMs: 1000,
options: { fetchImpl },
}),
).resolves.toMatchObject({
ok: false,
error:
"Twilio number +15557654321 points SMS webhooks at https://old.example.com/webhooks/sms; expected https://gateway.example.com/webhooks/sms.",
webhook: {
status: "url-mismatch",
},
});
});
it("surfaces Twilio 11200 recent inbound failures and Funnel hints", async () => {
const fetchImpl = createFetch([
{
incoming_phone_numbers: [
{
phone_number: "+15557654321",
sms_url: "https://mac-studio.example.ts.net/webhooks/sms",
sms_method: "POST",
},
],
},
{
messages: [
{
sid: "SM11200",
direction: "inbound",
status: "received",
to: "+15557654321",
from: "+15551234567",
error_code: 11200,
},
],
},
]);
const result = await probeSmsAccount({
account: createAccount({
publicWebhookUrl: "https://mac-studio.example.ts.net/webhooks/sms",
}),
timeoutMs: 1000,
options: { fetchImpl },
});
expect(result).toMatchObject({
ok: false,
error: "Recent inbound SMS SM11200 has Twilio error 11200.",
recentInbound: {
sid: "SM11200",
errorCode: "11200",
},
});
expect(result.hints).toEqual(
expect.arrayContaining([
expect.stringContaining("Tailscale Funnel must expose the exact SMS path"),
expect.stringContaining("Twilio error 11200 means Twilio could not reach"),
]),
);
});
it("validates Twilio Messaging Service webhook settings", async () => {
const fetchImpl = createFetch([
{
sid: "MG123",
inbound_request_url: "https://gateway.example.com/webhooks/sms",
inbound_method: "POST",
use_inbound_webhook_on_number: false,
},
]);
await expect(
probeSmsAccount({
account: createAccount({
fromNumber: "",
messagingServiceSid: "MG123",
}),
timeoutMs: 1000,
options: { fetchImpl },
}),
).resolves.toMatchObject({
ok: true,
webhook: {
status: "messaging-service-matches",
serviceSid: "MG123",
configuredUrl: "https://gateway.example.com/webhooks/sms",
},
});
});
it("does not report Messaging Service defer-to-number probes as healthy", async () => {
const fetchImpl = createFetch([
{
sid: "MG123",
inbound_request_url: "https://gateway.example.com/webhooks/sms",
inbound_method: "POST",
use_inbound_webhook_on_number: true,
},
]);
await expect(
probeSmsAccount({
account: createAccount({
fromNumber: "",
messagingServiceSid: "MG123",
}),
timeoutMs: 1000,
options: { fetchImpl },
}),
).resolves.toMatchObject({
ok: false,
error:
"Twilio Messaging Service defers inbound webhooks to sender phone numbers; configure fromNumber or disable defer-to-sender before probing.",
webhook: {
status: "unavailable",
},
});
});
it("formats probe details for channel capability output", () => {
expect(
formatSmsProbeLines({
ok: false,
error: "Recent inbound SMS SM11200 has Twilio error 11200.",
webhook: { status: "matches", configuredUrl: "https://gateway.example.com/webhooks/sms" },
recentInbound: { sid: "SM11200", status: "received", errorCode: "11200" },
hints: ["Check the public route."],
}),
).toEqual([
{
text: "Probe: failed (Recent inbound SMS SM11200 has Twilio error 11200.)",
tone: "error",
},
{ text: "Twilio SMS webhook: https://gateway.example.com/webhooks/sms" },
{ text: "Recent inbound: received error=11200", tone: "warn" },
{ text: "Check the public route.", tone: "warn" },
]);
});
});

View File

@@ -0,0 +1,356 @@
// Sms plugin module implements status behavior.
import {
listTwilioIncomingPhoneNumbers,
listTwilioMessages,
retrieveTwilioMessagingService,
type TwilioIncomingPhoneNumber,
type TwilioMessagingService,
type TwilioMessageLogEntry,
} from "./twilio.js";
import type { ResolvedSmsAccount } from "./types.js";
const TWILIO_ERROR_WEBHOOK_REACHABILITY = "11200";
type ChannelCapabilitiesDisplayLine = {
text: string;
tone?: "default" | "muted" | "success" | "warn" | "error";
};
export type SmsTwilioWebhookProbe =
| {
status: "skipped";
reason: string;
}
| {
status: "unavailable";
reason: string;
}
| {
status: "number-not-found";
expectedNumber: string;
}
| {
status: "missing";
phoneNumber: string;
expectedUrl: string;
configuredMethod: string;
}
| {
status: "method-mismatch";
phoneNumber: string;
expectedUrl: string;
configuredUrl: string;
configuredMethod: string;
}
| {
status: "url-mismatch";
phoneNumber: string;
expectedUrl: string;
configuredUrl: string;
configuredMethod: string;
}
| {
status: "matches";
phoneNumber: string;
expectedUrl: string;
configuredUrl: string;
configuredMethod: string;
voiceUrl: string;
}
| {
status: "messaging-service-missing";
serviceSid: string;
expectedUrl: string;
configuredMethod: string;
}
| {
status: "messaging-service-method-mismatch";
serviceSid: string;
expectedUrl: string;
configuredUrl: string;
configuredMethod: string;
}
| {
status: "messaging-service-url-mismatch";
serviceSid: string;
expectedUrl: string;
configuredUrl: string;
configuredMethod: string;
}
| {
status: "messaging-service-matches";
serviceSid: string;
expectedUrl: string;
configuredUrl: string;
configuredMethod: string;
};
export type SmsProbe = {
ok: boolean;
error?: string;
webhook: SmsTwilioWebhookProbe;
recentInbound?: Pick<
TwilioMessageLogEntry,
"sid" | "direction" | "status" | "errorCode" | "dateCreated" | "dateSent"
>;
hints: string[];
};
type ProbeOptions = {
fetchImpl?: typeof fetch;
};
function addTailscaleHint(account: ResolvedSmsAccount, hints: string[]): void {
let host;
try {
host = new URL(account.publicWebhookUrl).hostname;
} catch {
return;
}
if (!host.endsWith(".ts.net")) {
return;
}
hints.push(
`Tailscale Funnel must expose the exact SMS path: tailscale funnel --bg --set-path ${account.webhookPath} http://127.0.0.1:<gateway-port>${account.webhookPath}`,
);
}
function compareTwilioWebhook(
account: ResolvedSmsAccount,
phoneNumber: TwilioIncomingPhoneNumber | undefined,
): SmsTwilioWebhookProbe {
if (!account.fromNumber) {
return {
status: "skipped",
reason: "Messaging Service senders do not have one phone-number SMS webhook to inspect.",
};
}
if (!phoneNumber) {
return { status: "number-not-found", expectedNumber: account.fromNumber };
}
const configuredMethod = phoneNumber.smsMethod.toUpperCase();
if (!phoneNumber.smsUrl) {
return {
status: "missing",
phoneNumber: phoneNumber.phoneNumber || account.fromNumber,
expectedUrl: account.publicWebhookUrl,
configuredMethod,
};
}
if (configuredMethod && configuredMethod !== "POST") {
return {
status: "method-mismatch",
phoneNumber: phoneNumber.phoneNumber || account.fromNumber,
expectedUrl: account.publicWebhookUrl,
configuredUrl: phoneNumber.smsUrl,
configuredMethod,
};
}
if (phoneNumber.smsUrl !== account.publicWebhookUrl) {
return {
status: "url-mismatch",
phoneNumber: phoneNumber.phoneNumber || account.fromNumber,
expectedUrl: account.publicWebhookUrl,
configuredUrl: phoneNumber.smsUrl,
configuredMethod,
};
}
return {
status: "matches",
phoneNumber: phoneNumber.phoneNumber || account.fromNumber,
expectedUrl: account.publicWebhookUrl,
configuredUrl: phoneNumber.smsUrl,
configuredMethod,
voiceUrl: phoneNumber.voiceUrl,
};
}
function compareTwilioMessagingService(
account: ResolvedSmsAccount,
service: TwilioMessagingService,
): SmsTwilioWebhookProbe {
if (service.useInboundWebhookOnNumber) {
return {
status: "unavailable",
reason:
"Twilio Messaging Service defers inbound webhooks to sender phone numbers; configure fromNumber or disable defer-to-sender before probing.",
};
}
const configuredMethod = service.inboundMethod.toUpperCase();
if (!service.inboundRequestUrl) {
return {
status: "messaging-service-missing",
serviceSid: service.sid || account.messagingServiceSid,
expectedUrl: account.publicWebhookUrl,
configuredMethod,
};
}
if (configuredMethod && configuredMethod !== "POST") {
return {
status: "messaging-service-method-mismatch",
serviceSid: service.sid || account.messagingServiceSid,
expectedUrl: account.publicWebhookUrl,
configuredUrl: service.inboundRequestUrl,
configuredMethod,
};
}
if (service.inboundRequestUrl !== account.publicWebhookUrl) {
return {
status: "messaging-service-url-mismatch",
serviceSid: service.sid || account.messagingServiceSid,
expectedUrl: account.publicWebhookUrl,
configuredUrl: service.inboundRequestUrl,
configuredMethod,
};
}
return {
status: "messaging-service-matches",
serviceSid: service.sid || account.messagingServiceSid,
expectedUrl: account.publicWebhookUrl,
configuredUrl: service.inboundRequestUrl,
configuredMethod,
};
}
function recentInboundSummary(
messages: TwilioMessageLogEntry[],
): SmsProbe["recentInbound"] | undefined {
const message = messages[0];
if (!message) {
return undefined;
}
return {
sid: message.sid,
direction: message.direction,
status: message.status,
errorCode: message.errorCode,
dateCreated: message.dateCreated,
dateSent: message.dateSent,
};
}
function webhookError(probe: SmsTwilioWebhookProbe): string | undefined {
switch (probe.status) {
case "matches":
case "skipped":
return undefined;
case "unavailable":
return probe.reason;
case "number-not-found":
return `Twilio account does not list ${probe.expectedNumber} as an incoming phone number.`;
case "missing":
return `Twilio number ${probe.phoneNumber} has no SMS webhook URL configured.`;
case "method-mismatch":
return `Twilio number ${probe.phoneNumber} uses ${probe.configuredMethod || "an unknown method"} for SMS webhooks; use POST.`;
case "url-mismatch":
return `Twilio number ${probe.phoneNumber} points SMS webhooks at ${probe.configuredUrl}; expected ${probe.expectedUrl}.`;
case "messaging-service-missing":
return `Twilio Messaging Service ${probe.serviceSid} has no inbound request URL configured.`;
case "messaging-service-method-mismatch":
return `Twilio Messaging Service ${probe.serviceSid} uses ${probe.configuredMethod || "an unknown method"} for inbound webhooks; use POST.`;
case "messaging-service-url-mismatch":
return `Twilio Messaging Service ${probe.serviceSid} points inbound webhooks at ${probe.configuredUrl}; expected ${probe.expectedUrl}.`;
case "messaging-service-matches":
return undefined;
}
return undefined;
}
export async function probeSmsAccount(params: {
account: ResolvedSmsAccount;
timeoutMs: number;
options?: ProbeOptions;
}): Promise<SmsProbe> {
const hints: string[] = [];
addTailscaleHint(params.account, hints);
const webhook: SmsTwilioWebhookProbe = params.account.fromNumber
? compareTwilioWebhook(
params.account,
(
await listTwilioIncomingPhoneNumbers({
account: params.account,
phoneNumber: params.account.fromNumber,
fetchImpl: params.options?.fetchImpl,
timeoutMs: params.timeoutMs,
})
)[0],
)
: params.account.messagingServiceSid
? compareTwilioMessagingService(
params.account,
await retrieveTwilioMessagingService({
account: params.account,
serviceSid: params.account.messagingServiceSid,
fetchImpl: params.options?.fetchImpl,
timeoutMs: params.timeoutMs,
}),
)
: {
status: "unavailable",
reason: "Twilio SMS probe requires fromNumber or messagingServiceSid.",
};
const messages = params.account.fromNumber
? await listTwilioMessages({
account: params.account,
to: params.account.fromNumber,
pageSize: 3,
fetchImpl: params.options?.fetchImpl,
timeoutMs: params.timeoutMs,
})
: [];
const recentInbound = recentInboundSummary(messages);
if (recentInbound?.errorCode === TWILIO_ERROR_WEBHOOK_REACHABILITY) {
hints.push(
"Twilio error 11200 means Twilio could not reach the SMS webhook. Check the public URL, tunnel/Funnel route, and Twilio Messaging webhook method.",
);
}
const error =
webhookError(webhook) ??
(recentInbound?.errorCode === TWILIO_ERROR_WEBHOOK_REACHABILITY
? `Recent inbound SMS ${recentInbound.sid} has Twilio error 11200.`
: undefined);
return {
ok: !error,
...(error ? { error } : {}),
webhook,
...(recentInbound ? { recentInbound } : {}),
hints,
};
}
export function formatSmsProbeLines(probe: unknown): ChannelCapabilitiesDisplayLine[] {
if (!probe || typeof probe !== "object") {
return [];
}
const smsProbe = probe as Partial<SmsProbe>;
const lines: ChannelCapabilitiesDisplayLine[] = [];
if (smsProbe.ok === true) {
lines.push({ text: "Probe: ok", tone: "success" });
} else if (smsProbe.ok === false) {
lines.push({
text: `Probe: failed${smsProbe.error ? ` (${smsProbe.error})` : ""}`,
tone: "error",
});
}
if (
smsProbe.webhook?.status === "matches" ||
smsProbe.webhook?.status === "messaging-service-matches"
) {
lines.push({ text: `Twilio SMS webhook: ${smsProbe.webhook.configuredUrl}` });
} else if (smsProbe.webhook?.status && smsProbe.webhook.status !== "skipped") {
lines.push({ text: `Twilio SMS webhook: ${smsProbe.webhook.status}`, tone: "warn" });
}
if (smsProbe.recentInbound?.sid) {
const error = smsProbe.recentInbound.errorCode
? ` error=${smsProbe.recentInbound.errorCode}`
: "";
lines.push({
text: `Recent inbound: ${smsProbe.recentInbound.status || "unknown"}${error}`,
tone: smsProbe.recentInbound.errorCode ? "warn" : "muted",
});
}
for (const hint of smsProbe.hints ?? []) {
lines.push({ text: hint, tone: "warn" });
}
return lines;
}

View File

@@ -0,0 +1,659 @@
// Sms tests cover twilio plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildTwilioInboundMessage,
computeTwilioSignature,
listTwilioIncomingPhoneNumbers,
listTwilioMessages,
parseTwilioFormBody,
resolveTwilioWebhookSignatureUrl,
retrieveTwilioMessagingService,
sendSmsViaTwilio,
TwilioSmsApiError,
verifyTwilioSignature,
} from "./twilio.js";
import type { ResolvedSmsAccount } from "./types.js";
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
return {
...actual,
fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args),
};
});
function createAccount(overrides: Partial<ResolvedSmsAccount> = {}): ResolvedSmsAccount {
return {
accountId: "default",
enabled: true,
accountSid: "AC123",
authToken: "secret",
fromNumber: "+15557654321",
messagingServiceSid: "",
defaultTo: "",
webhookPath: "/webhooks/sms",
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
dangerouslyDisableSignatureValidation: false,
dmPolicy: "pairing",
allowFrom: [],
textChunkLimit: 1500,
...overrides,
};
}
function readUrlEncodedRequestBody(init: RequestInit | undefined): URLSearchParams {
if (typeof init?.body === "string") {
return new URLSearchParams(init.body);
}
if (init?.body instanceof URLSearchParams) {
return init.body;
}
throw new Error("Expected Twilio request body to be URL-encoded.");
}
function cancelTrackedTextResponse(
text: string,
init?: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
describe("Twilio SMS helpers", () => {
afterEach(() => {
fetchWithSsrFGuardMock.mockReset();
});
it("parses Twilio form bodies and inbound messages", () => {
const form = parseTwilioFormBody(
"From=%2B15551234567&To=%2B15557654321&Body=hello+there&MessageSid=SM123",
);
expect(form).toEqual({
From: "+15551234567",
To: "+15557654321",
Body: "hello there",
MessageSid: "SM123",
});
expect(buildTwilioInboundMessage(form)).toEqual({
from: "+15551234567",
to: "+15557654321",
body: "hello there",
messageSid: "SM123",
accountSid: "",
});
});
it("verifies Twilio signatures over sorted form fields", () => {
const form = {
Body: "hello",
From: "+15551234567",
MessageSid: "SM123",
To: "+15557654321",
};
const signature = computeTwilioSignature({
url: "https://gateway.example.com/webhooks/sms",
authToken: "secret",
form,
});
expect(
verifyTwilioSignature({
signature,
url: "https://gateway.example.com/webhooks/sms",
authToken: "secret",
form,
}),
).toBe(true);
expect(
verifyTwilioSignature({
signature,
url: "https://gateway.example.com/webhooks/sms/other",
authToken: "secret",
form,
}),
).toBe(false);
});
it("preserves signed form values before signature verification", () => {
const form = parseTwilioFormBody(
"From=%2B15551234567&To=%2B15557654321&Body=+hello+&MessageSid=SM123&WaId=",
);
const signature = computeTwilioSignature({
url: "https://gateway.example.com/webhooks/sms",
authToken: "secret",
form,
});
expect(form.Body).toBe(" hello ");
expect(form.WaId).toBe("");
expect(
verifyTwilioSignature({
signature,
url: "https://gateway.example.com/webhooks/sms",
authToken: "secret",
form,
}),
).toBe(true);
expect(buildTwilioInboundMessage(form)?.body).toBe(" hello ");
});
it("sends SMS through Twilio's Messages API", async () => {
const fetchImpl = vi.fn<typeof fetch>(
async () =>
new Response(
JSON.stringify({
sid: "SM456",
to: "+15551234567",
from: "+15557654321",
status: "queued",
}),
{
status: 201,
headers: { "content-type": "application/json" },
},
),
);
await expect(
sendSmsViaTwilio({
account: {
accountId: "default",
enabled: true,
accountSid: "AC123",
authToken: "secret",
fromNumber: "+15557654321",
messagingServiceSid: "",
defaultTo: "",
webhookPath: "/webhooks/sms",
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
dangerouslyDisableSignatureValidation: false,
dmPolicy: "pairing",
allowFrom: [],
textChunkLimit: 1500,
},
to: "+15551234567",
text: "hello",
fetchImpl,
}),
).resolves.toEqual({
sid: "SM456",
to: "+15551234567",
from: "+15557654321",
status: "queued",
});
const [url, init] = fetchImpl.mock.calls[0] ?? [];
expect(url).toBe("https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json");
expect(init?.method).toBe("POST");
expect(init?.headers).toMatchObject({
authorization: `Basic ${Buffer.from("AC123:secret").toString("base64")}`,
"content-type": "application/x-www-form-urlencoded",
});
const body = readUrlEncodedRequestBody(init);
expect(body.get("From")).toBe("+15557654321");
expect(body.get("To")).toBe("+15551234567");
expect(body.get("Body")).toBe("hello");
});
it("lists Twilio phone-number webhook settings", async () => {
const fetchImpl = vi.fn<typeof fetch>(
async () =>
new Response(
JSON.stringify({
incoming_phone_numbers: [
{
sid: "PN123",
phone_number: "+15557654321",
sms_url: "https://gateway.example.com/webhooks/sms",
sms_method: "POST",
voice_url: "https://gateway.example.com/voice/webhook",
},
],
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
);
await expect(
listTwilioIncomingPhoneNumbers({
account: createAccount(),
phoneNumber: "+15557654321",
fetchImpl,
}),
).resolves.toEqual([
{
sid: "PN123",
phoneNumber: "+15557654321",
smsUrl: "https://gateway.example.com/webhooks/sms",
smsMethod: "POST",
voiceUrl: "https://gateway.example.com/voice/webhook",
},
]);
const [url, init] = fetchImpl.mock.calls[0] ?? [];
expect(url).toBe(
"https://api.twilio.com/2010-04-01/Accounts/AC123/IncomingPhoneNumbers.json?PhoneNumber=%2B15557654321",
);
expect(init?.headers).toMatchObject({
authorization: `Basic ${Buffer.from("AC123:secret").toString("base64")}`,
});
});
it("lists recent Twilio messages for diagnostics", async () => {
const fetchImpl = vi.fn<typeof fetch>(
async () =>
new Response(
JSON.stringify({
messages: [
{
sid: "SM123",
direction: "inbound",
status: "received",
to: "+15557654321",
from: "+15551234567",
error_code: 11200,
body: "hello",
date_created: "Sun, 31 May 2026 10:00:00 +0000",
date_sent: null,
},
],
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
);
await expect(
listTwilioMessages({
account: createAccount(),
to: "+15557654321",
pageSize: 3,
fetchImpl,
}),
).resolves.toEqual([
{
sid: "SM123",
direction: "inbound",
status: "received",
to: "+15557654321",
from: "+15551234567",
errorCode: "11200",
body: "hello",
dateCreated: "Sun, 31 May 2026 10:00:00 +0000",
dateSent: "",
},
]);
const [url] = fetchImpl.mock.calls[0] ?? [];
expect(url).toBe(
"https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json?To=%2B15557654321&PageSize=3",
);
});
it("retrieves Twilio Messaging Service webhook settings", async () => {
const fetchImpl = vi.fn<typeof fetch>(
async () =>
new Response(
JSON.stringify({
sid: "MG123",
inbound_request_url: "https://gateway.example.com/webhooks/sms",
inbound_method: "POST",
use_inbound_webhook_on_number: false,
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
);
await expect(
retrieveTwilioMessagingService({
account: createAccount({ messagingServiceSid: "MG123", fromNumber: "" }),
serviceSid: "MG123",
fetchImpl,
}),
).resolves.toEqual({
sid: "MG123",
inboundRequestUrl: "https://gateway.example.com/webhooks/sms",
inboundMethod: "POST",
useInboundWebhookOnNumber: false,
});
const [url, init] = fetchImpl.mock.calls[0] ?? [];
expect(url).toBe("https://messaging.twilio.com/v1/Services/MG123");
expect(init?.headers).toMatchObject({
authorization: `Basic ${Buffer.from("AC123:secret").toString("base64")}`,
});
});
it("can send through a Twilio Messaging Service SID", async () => {
const fetchImpl = vi.fn<typeof fetch>(
async () =>
new Response(JSON.stringify({ sid: "SM789" }), {
status: 201,
headers: { "content-type": "application/json" },
}),
);
await sendSmsViaTwilio({
account: {
accountId: "default",
enabled: true,
accountSid: "AC123",
authToken: "secret",
fromNumber: "",
messagingServiceSid: "MG123",
defaultTo: "",
webhookPath: "/webhooks/sms",
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
dangerouslyDisableSignatureValidation: false,
dmPolicy: "pairing",
allowFrom: [],
textChunkLimit: 1500,
},
to: "+15551234567",
text: "hello",
fetchImpl,
});
const [, init] = fetchImpl.mock.calls[0] ?? [];
const body = readUrlEncodedRequestBody(init);
expect(body.get("MessagingServiceSid")).toBe("MG123");
expect(body.get("To")).toBe("+15551234567");
expect(body.get("Body")).toBe("hello");
});
it("prefers an explicit from number when both sender options are resolved", async () => {
const fetchImpl = vi.fn<typeof fetch>(
async () =>
new Response(JSON.stringify({ sid: "SM999" }), {
status: 201,
headers: { "content-type": "application/json" },
}),
);
await sendSmsViaTwilio({
account: {
accountId: "default",
enabled: true,
accountSid: "AC123",
authToken: "secret",
fromNumber: "+15557654321",
messagingServiceSid: "MG123",
defaultTo: "",
webhookPath: "/webhooks/sms",
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
dangerouslyDisableSignatureValidation: false,
dmPolicy: "pairing",
allowFrom: [],
textChunkLimit: 1500,
},
to: "+15551234567",
text: "hello",
fetchImpl,
});
const [, init] = fetchImpl.mock.calls[0] ?? [];
const body = readUrlEncodedRequestBody(init);
expect(body.get("From")).toBe("+15557654321");
expect(body.get("MessagingServiceSid")).toBeNull();
});
it("throws structured Twilio errors from JSON error bodies", async () => {
const fetchImpl = vi.fn<typeof fetch>(
async () =>
new Response(
JSON.stringify({
code: 21610,
message: "The message From/To pair violates a blacklist rule.",
}),
{ status: 400, headers: { "content-type": "application/json" } },
),
);
await expect(
sendSmsViaTwilio({
account: createAccount(),
to: "+15551234567",
text: "hello",
fetchImpl,
}),
).rejects.toMatchObject({
name: "TwilioSmsApiError",
httpStatus: 400,
twilioCode: 21610,
responseText: JSON.stringify({
code: 21610,
message: "The message From/To pair violates a blacklist rule.",
}),
});
});
it("includes non-JSON Twilio error text in send failures", async () => {
const fetchImpl = vi.fn<typeof fetch>(
async () => new Response("upstream unavailable", { status: 503 }),
);
await expect(
sendSmsViaTwilio({
account: createAccount(),
to: "+15551234567",
text: "hello",
fetchImpl,
}),
).rejects.toThrow("Twilio SMS send failed (503): upstream unavailable");
});
it("releases guarded Twilio egress on failed send responses", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response("upstream unavailable", { status: 503 }),
release,
});
await expect(
sendSmsViaTwilio({
account: createAccount(),
to: "+15551234567",
text: "hello",
}),
).rejects.toThrow("Twilio SMS send failed (503): upstream unavailable");
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
auditContext: "sms-twilio-api",
policy: { allowedHostnames: ["api.twilio.com"] },
requireHttps: true,
timeoutMs: 30_000,
url: "https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json",
}),
);
expect(release).toHaveBeenCalledTimes(1);
});
it("bounds and cancels oversized guarded Twilio error bodies", async () => {
const release = vi.fn(async () => {});
const tracked = cancelTrackedTextResponse(`${"upstream unavailable ".repeat(512)}tail`, {
status: 503,
});
fetchWithSsrFGuardMock.mockResolvedValue({
response: tracked.response,
release,
});
let caught: Error | undefined;
try {
await sendSmsViaTwilio({
account: createAccount(),
to: "+15551234567",
text: "hello",
});
} catch (error) {
caught = error as Error;
}
expect(caught?.message).toContain("Twilio SMS send failed (503): upstream unavailable");
expect(caught?.message).toContain("... [truncated]");
expect(caught?.message).not.toContain("tail");
expect(caught?.message.length).toBeLessThan(8_300);
expect(tracked.wasCanceled()).toBe(true);
expect(release).toHaveBeenCalledTimes(1);
});
it("rejects malformed JSON from successful Twilio sends", async () => {
const fetchImpl = vi.fn<typeof fetch>(async () => new Response("not json", { status: 201 }));
await expect(
sendSmsViaTwilio({
account: createAccount(),
to: "+15551234567",
text: "hello",
fetchImpl,
}),
).rejects.toThrow("Twilio SMS send returned malformed JSON.");
});
it("releases guarded Twilio egress on malformed successful send responses", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response("not json", { status: 201 }),
release,
});
await expect(
sendSmsViaTwilio({
account: createAccount(),
to: "+15551234567",
text: "hello",
}),
).rejects.toThrow("Twilio SMS send returned malformed JSON.");
expect(release).toHaveBeenCalledTimes(1);
});
it("rejects malformed JSON from Twilio Messaging Service lookup", async () => {
const fetchImpl = vi.fn<typeof fetch>(
async () => new Response("NOT JSON {{{", { status: 200, headers: { "content-type": "application/json" } }),
);
await expect(
retrieveTwilioMessagingService({
account: createAccount({ messagingServiceSid: "MG123", fromNumber: "" }),
serviceSid: "MG123",
fetchImpl,
}),
).rejects.toThrow("Twilio Messaging Service lookup returned malformed JSON.");
});
it("returns empty list on malformed JSON from Twilio incoming phone number list", async () => {
const fetchImpl = vi.fn<typeof fetch>(
async () => new Response("NOT JSON {{{", { status: 200, headers: { "content-type": "application/json" } }),
);
const result = await listTwilioIncomingPhoneNumbers({
account: createAccount(),
fetchImpl,
});
expect(result).toEqual([]);
});
it("bounds and cancels oversized guarded Twilio success bodies", async () => {
const release = vi.fn(async () => {});
const tracked = cancelTrackedTextResponse("x".repeat(1024 * 1024 + 1), { status: 201 });
fetchWithSsrFGuardMock.mockResolvedValue({
response: tracked.response,
release,
});
await expect(
sendSmsViaTwilio({
account: createAccount(),
to: "+15551234567",
text: "hello",
}),
).rejects.toThrow(
"Twilio SMS API response body too large: 1048577 bytes (limit: 1048576 bytes)",
);
expect(tracked.wasCanceled()).toBe(true);
expect(release).toHaveBeenCalledTimes(1);
});
it("exposes a typed Twilio SMS API error", () => {
const error = new TwilioSmsApiError(
429,
JSON.stringify({ code: 20429, message: "Too many requests" }),
);
expect(error).toBeInstanceOf(TwilioSmsApiError);
expect(error.message).toBe("Twilio SMS send failed (429): Too many requests");
expect(error.httpStatus).toBe(429);
expect(error.twilioCode).toBe(20429);
});
it("requires successful Twilio sends to include a Message SID", async () => {
const fetchImpl = vi.fn<typeof fetch>(
async () => new Response(JSON.stringify({ status: "queued" }), { status: 201 }),
);
await expect(
sendSmsViaTwilio({
account: createAccount(),
to: "+15551234567",
text: "hello",
fetchImpl,
}),
).rejects.toThrow("Twilio SMS send response did not include a Message SID.");
});
it("preserves the configured public webhook path when adding a request query", () => {
expect(
resolveTwilioWebhookSignatureUrl({
req: { url: "/webhooks/sms?foo=bar" } as never,
publicWebhookUrl: "https://gateway.example.com/base",
}),
).toBe("https://gateway.example.com/base?foo=bar");
});
it("keeps an explicit configured public webhook query", () => {
expect(
resolveTwilioWebhookSignatureUrl({
req: { url: "/webhooks/sms?foo=request" } as never,
publicWebhookUrl: "https://gateway.example.com/base?foo=configured",
}),
).toBe("https://gateway.example.com/base?foo=configured");
});
it("does not reserialize the configured public webhook URL", () => {
expect(
resolveTwilioWebhookSignatureUrl({
req: { url: "/webhooks/sms" } as never,
publicWebhookUrl: "https://gateway.example.com:443/webhooks/sms",
}),
).toBe("https://gateway.example.com:443/webhooks/sms");
});
});

View File

@@ -0,0 +1,577 @@
// Sms plugin module implements twilio behavior.
import { createHmac, timingSafeEqual } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import * as querystring from "node:querystring";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { readRequestBodyWithLimit } from "openclaw/plugin-sdk/webhook-ingress";
import type { ResolvedSmsAccount, SmsInboundMessage, SmsSendResult } from "./types.js";
const TWILIO_ACCOUNTS_URL = "https://api.twilio.com/2010-04-01/Accounts";
const TWILIO_MESSAGING_URL = "https://messaging.twilio.com/v1";
const TWILIO_API_HOSTNAME = "api.twilio.com";
const TWILIO_MESSAGING_HOSTNAME = "messaging.twilio.com";
const TWILIO_API_TIMEOUT_MS = 30_000;
const TWILIO_API_SUCCESS_BODY_LIMIT_BYTES = 1 * 1024 * 1024;
const TWILIO_API_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const TRUNCATED_RESPONSE_SUFFIX = "... [truncated]";
const WEBHOOK_BODY_LIMIT_BYTES = 32 * 1024;
const WEBHOOK_BODY_TIMEOUT_MS = 5_000;
type ParsedTwilioApiError = {
code?: number;
message?: string;
};
type TwilioApiResponse = {
ok: boolean;
status: number;
text: string;
};
type TwilioMessagePayload = {
sid?: string;
to?: string;
from?: string;
status?: string;
};
export type TwilioIncomingPhoneNumber = {
sid: string;
phoneNumber: string;
smsUrl: string;
smsMethod: string;
voiceUrl: string;
};
export type TwilioMessageLogEntry = {
sid: string;
direction: string;
status: string;
to: string;
from: string;
errorCode: string;
body: string;
dateCreated: string;
dateSent: string;
};
export type TwilioMessagingService = {
sid: string;
inboundRequestUrl: string;
inboundMethod: string;
useInboundWebhookOnNumber: boolean;
};
function firstString(value: unknown): string {
if (Array.isArray(value)) {
return firstString(value[0]);
}
return typeof value === "string" ? value : "";
}
function firstTrimmedString(value: unknown): string {
return firstString(value).trim();
}
function firstStringish(value: unknown): string {
const first = Array.isArray(value) ? value[0] : value;
if (typeof first === "string") {
return first;
}
return typeof first === "number" ? String(first) : "";
}
function parseTwilioApiError(text: string): ParsedTwilioApiError {
try {
const parsed: unknown = JSON.parse(text);
if (!parsed || typeof parsed !== "object") {
return {};
}
const record = parsed as Record<string, unknown>;
return {
code: typeof record.code === "number" ? record.code : undefined,
message: typeof record.message === "string" ? record.message : undefined,
};
} catch {
return {};
}
}
function parseTwilioSuccessPayload(text: string): TwilioMessagePayload {
if (!text.trim()) {
return {};
}
try {
const parsed: unknown = JSON.parse(text);
if (!parsed || typeof parsed !== "object") {
throw new Error("Twilio SMS send returned malformed JSON.");
}
const record = parsed as Record<string, unknown>;
return {
sid: typeof record.sid === "string" ? record.sid : undefined,
to: typeof record.to === "string" ? record.to : undefined,
from: typeof record.from === "string" ? record.from : undefined,
status: typeof record.status === "string" ? record.status : undefined,
};
} catch (cause) {
if (cause instanceof Error && cause.message === "Twilio SMS send returned malformed JSON.") {
throw cause;
}
throw new Error("Twilio SMS send returned malformed JSON.", { cause });
}
}
function requestSearch(req: IncomingMessage): string {
try {
return new URL(req.url ?? "/", "http://localhost").search;
} catch {
return "";
}
}
function configuredUrlHasQuery(url: string): boolean {
const hashIndex = url.indexOf("#");
const beforeHash = hashIndex === -1 ? url : url.slice(0, hashIndex);
return beforeHash.includes("?");
}
export function resolveTwilioWebhookSignatureUrl(params: {
req: IncomingMessage;
publicWebhookUrl: string;
}): string {
if (configuredUrlHasQuery(params.publicWebhookUrl)) {
return params.publicWebhookUrl;
}
const search = requestSearch(params.req);
if (!search) {
return params.publicWebhookUrl;
}
const hashIndex = params.publicWebhookUrl.indexOf("#");
if (hashIndex === -1) {
return `${params.publicWebhookUrl}${search}`;
}
return `${params.publicWebhookUrl.slice(0, hashIndex)}${search}${params.publicWebhookUrl.slice(hashIndex)}`;
}
export class TwilioSmsApiError extends Error {
readonly httpStatus: number;
readonly responseText: string;
readonly twilioCode?: number;
constructor(httpStatus: number, responseText: string, operation = "send") {
const parsed = parseTwilioApiError(responseText);
const detail = parsed.message ?? (responseText || "unknown");
super(`Twilio SMS ${operation} failed (${httpStatus}): ${detail}`);
this.name = "TwilioSmsApiError";
this.httpStatus = httpStatus;
this.responseText = responseText;
this.twilioCode = parsed.code;
}
}
export function parseTwilioFormBody(body: string): Record<string, string> {
const parsed = querystring.parse(body);
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed)) {
out[key] = firstString(value);
}
return out;
}
export function computeTwilioSignature(params: {
url: string;
authToken: string;
form: Record<string, string>;
}): string {
const data =
params.url +
Object.keys(params.form)
.toSorted()
.map((key) => `${key}${params.form[key] ?? ""}`)
.join("");
return createHmac("sha1", params.authToken).update(data).digest("base64");
}
function safeEqual(a: string, b: string): boolean {
const left = Buffer.from(a);
const right = Buffer.from(b);
return left.length === right.length && timingSafeEqual(left, right);
}
export function verifyTwilioSignature(params: {
signature: string | undefined;
url: string;
authToken: string;
form: Record<string, string>;
}): boolean {
if (!params.signature || !params.url || !params.authToken) {
return false;
}
return safeEqual(
params.signature,
computeTwilioSignature({
url: params.url,
authToken: params.authToken,
form: params.form,
}),
);
}
export function buildTwilioInboundMessage(form: Record<string, string>): SmsInboundMessage | null {
const from = firstTrimmedString(form.From);
const to = firstTrimmedString(form.To);
const body = firstString(form.Body);
const accountSid = firstTrimmedString(form.AccountSid);
const messageSid =
firstTrimmedString(form.MessageSid) ||
firstTrimmedString(form.SmsSid) ||
firstTrimmedString(form.SmsMessageSid);
if (!from || !to || !body || !messageSid) {
return null;
}
return { accountSid, from, to, body, messageSid };
}
export async function readTwilioWebhookForm(req: IncomingMessage): Promise<Record<string, string>> {
const body = await readRequestBodyWithLimit(req, {
maxBytes: WEBHOOK_BODY_LIMIT_BYTES,
timeoutMs: WEBHOOK_BODY_TIMEOUT_MS,
});
return parseTwilioFormBody(body);
}
export function respondTwiml(res: ServerResponse, statusCode: number, body = ""): void {
res.statusCode = statusCode;
res.setHeader("content-type", "text/xml; charset=utf-8");
res.end(body || "<Response></Response>");
}
function twilioApiUrl(accountSid: string, path: string, query?: URLSearchParams): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const url = new URL(`${TWILIO_ACCOUNTS_URL}/${encodeURIComponent(accountSid)}${normalizedPath}`);
if (query) {
url.search = query.toString();
}
return url.toString();
}
function twilioMessagingUrl(path: string, query?: URLSearchParams): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const url = new URL(`${TWILIO_MESSAGING_URL}${normalizedPath}`);
if (query) {
url.search = query.toString();
}
return url.toString();
}
function basicAuthHeader(account: ResolvedSmsAccount): string {
return `Basic ${Buffer.from(`${account.accountSid}:${account.authToken}`).toString("base64")}`;
}
function appendTruncatedResponseSuffix(text: string): string {
return `${text.trimEnd()}${TRUNCATED_RESPONSE_SUFFIX}`;
}
async function readTwilioApiResponseText(response: Response): Promise<string> {
if (!response.body) {
return "";
}
const maxBytes = response.ok
? TWILIO_API_SUCCESS_BODY_LIMIT_BYTES
: TWILIO_API_ERROR_BODY_LIMIT_BYTES;
const truncateOnLimit = !response.ok;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let totalBytes = 0;
let text = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
return text + decoder.decode();
}
if (!value?.byteLength) {
continue;
}
const remainingBytes = maxBytes - totalBytes;
if (value.byteLength > remainingBytes) {
const clipped = remainingBytes > 0 ? value.slice(0, remainingBytes) : undefined;
if (truncateOnLimit) {
if (clipped) {
text += decoder.decode(clipped, { stream: true });
}
await reader.cancel().catch(() => undefined);
return appendTruncatedResponseSuffix(text + decoder.decode());
}
await reader.cancel().catch(() => undefined);
throw new Error(
`Twilio SMS API response body too large: ${totalBytes + value.byteLength} bytes ` +
`(limit: ${maxBytes} bytes)`,
);
}
text += decoder.decode(value, { stream: true });
totalBytes += value.byteLength;
}
} finally {
try {
reader.releaseLock();
} catch {}
}
}
function normalizeRequestHeaders(headers: HeadersInit | undefined): Record<string, string> {
if (!headers) {
return {};
}
if (headers instanceof Headers) {
return Object.fromEntries(headers.entries());
}
if (Array.isArray(headers)) {
return Object.fromEntries(headers.map(([key, value]) => [key, value]));
}
return Object.fromEntries(Object.entries(headers));
}
async function requestTwilioApi(params: {
url: string;
account: ResolvedSmsAccount;
allowedHostname: string;
init?: RequestInit;
fetchImpl?: typeof fetch;
timeoutMs?: number;
}): Promise<TwilioApiResponse> {
const init = {
...params.init,
headers: {
...normalizeRequestHeaders(params.init?.headers),
authorization: basicAuthHeader(params.account),
},
} satisfies RequestInit;
if (params.fetchImpl) {
const response = await params.fetchImpl(params.url, init);
return {
ok: response.ok,
status: response.status,
text: await readTwilioApiResponseText(response),
};
}
const guarded = await fetchWithSsrFGuard({
url: params.url,
init,
auditContext: "sms-twilio-api",
policy: { allowedHostnames: [params.allowedHostname] },
requireHttps: true,
timeoutMs: params.timeoutMs ?? TWILIO_API_TIMEOUT_MS,
});
try {
return {
ok: guarded.response.ok,
status: guarded.response.status,
text: await readTwilioApiResponseText(guarded.response),
};
} finally {
await guarded.release();
}
}
function parseTwilioIncomingPhoneNumber(
record: Record<string, unknown>,
): TwilioIncomingPhoneNumber {
return {
sid: firstTrimmedString(record.sid),
phoneNumber: firstTrimmedString(record.phone_number ?? record.phoneNumber),
smsUrl: firstTrimmedString(record.sms_url ?? record.smsUrl),
smsMethod: firstTrimmedString(record.sms_method ?? record.smsMethod),
voiceUrl: firstTrimmedString(record.voice_url ?? record.voiceUrl),
};
}
function parseTwilioMessageLogEntry(record: Record<string, unknown>): TwilioMessageLogEntry {
return {
sid: firstTrimmedString(record.sid),
direction: firstTrimmedString(record.direction),
status: firstTrimmedString(record.status),
to: firstTrimmedString(record.to),
from: firstTrimmedString(record.from),
errorCode: firstStringish(record.error_code ?? record.errorCode).trim(),
body: firstString(record.body),
dateCreated: firstTrimmedString(record.date_created ?? record.dateCreated),
dateSent: firstTrimmedString(record.date_sent ?? record.dateSent),
};
}
function parseTwilioMessagingService(record: Record<string, unknown>): TwilioMessagingService {
return {
sid: firstTrimmedString(record.sid),
inboundRequestUrl: firstTrimmedString(record.inbound_request_url ?? record.inboundRequestUrl),
inboundMethod: firstTrimmedString(record.inbound_method ?? record.inboundMethod),
useInboundWebhookOnNumber: Boolean(
record.use_inbound_webhook_on_number ?? record.useInboundWebhookOnNumber,
),
};
}
function parseTwilioListPayload<T>(
text: string,
key: string,
parseEntry: (record: Record<string, unknown>) => T,
): T[] {
if (!text.trim()) {
return [];
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return [];
}
if (!parsed || typeof parsed !== "object") {
return [];
}
const items = (parsed as Record<string, unknown>)[key];
if (!Array.isArray(items)) {
return [];
}
return items
.filter((item): item is Record<string, unknown> =>
Boolean(item && typeof item === "object" && !Array.isArray(item)),
)
.map(parseEntry);
}
export async function listTwilioIncomingPhoneNumbers(params: {
account: ResolvedSmsAccount;
phoneNumber?: string;
fetchImpl?: typeof fetch;
timeoutMs?: number;
}): Promise<TwilioIncomingPhoneNumber[]> {
const query = new URLSearchParams();
if (params.phoneNumber) {
query.set("PhoneNumber", params.phoneNumber);
}
const response = await requestTwilioApi({
account: params.account,
url: twilioApiUrl(params.account.accountSid, "/IncomingPhoneNumbers.json", query),
allowedHostname: TWILIO_API_HOSTNAME,
fetchImpl: params.fetchImpl,
timeoutMs: params.timeoutMs,
});
if (!response.ok) {
throw new TwilioSmsApiError(response.status, response.text, "phone-number lookup");
}
return parseTwilioListPayload(
response.text,
"incoming_phone_numbers",
parseTwilioIncomingPhoneNumber,
);
}
export async function retrieveTwilioMessagingService(params: {
account: ResolvedSmsAccount;
serviceSid: string;
fetchImpl?: typeof fetch;
timeoutMs?: number;
}): Promise<TwilioMessagingService> {
const response = await requestTwilioApi({
account: params.account,
url: twilioMessagingUrl(`/Services/${encodeURIComponent(params.serviceSid)}`),
allowedHostname: TWILIO_MESSAGING_HOSTNAME,
fetchImpl: params.fetchImpl,
timeoutMs: params.timeoutMs,
});
if (!response.ok) {
throw new TwilioSmsApiError(response.status, response.text, "messaging-service lookup");
}
let parsed: unknown;
try {
parsed = JSON.parse(response.text);
} catch {
throw new Error("Twilio Messaging Service lookup returned malformed JSON.");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Twilio Messaging Service lookup returned malformed JSON.");
}
return parseTwilioMessagingService(parsed as Record<string, unknown>);
}
export async function listTwilioMessages(params: {
account: ResolvedSmsAccount;
to?: string;
from?: string;
pageSize?: number;
fetchImpl?: typeof fetch;
timeoutMs?: number;
}): Promise<TwilioMessageLogEntry[]> {
const query = new URLSearchParams();
if (params.to) {
query.set("To", params.to);
}
if (params.from) {
query.set("From", params.from);
}
query.set("PageSize", String(params.pageSize ?? 5));
const response = await requestTwilioApi({
account: params.account,
url: twilioApiUrl(params.account.accountSid, "/Messages.json", query),
allowedHostname: TWILIO_API_HOSTNAME,
fetchImpl: params.fetchImpl,
timeoutMs: params.timeoutMs,
});
if (!response.ok) {
throw new TwilioSmsApiError(response.status, response.text, "message lookup");
}
return parseTwilioListPayload(response.text, "messages", parseTwilioMessageLogEntry);
}
export async function sendSmsViaTwilio(params: {
account: ResolvedSmsAccount;
to: string;
text: string;
fetchImpl?: typeof fetch;
}): Promise<SmsSendResult> {
if (!params.account.fromNumber && !params.account.messagingServiceSid) {
throw new Error("Twilio SMS send requires fromNumber or messagingServiceSid.");
}
const body = new URLSearchParams({
To: params.to,
Body: params.text,
});
if (params.account.fromNumber) {
body.set("From", params.account.fromNumber);
} else {
body.set("MessagingServiceSid", params.account.messagingServiceSid);
}
const init = {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
},
body,
} satisfies RequestInit;
const response = await requestTwilioApi({
account: params.account,
url: twilioApiUrl(params.account.accountSid, "/Messages.json"),
allowedHostname: TWILIO_API_HOSTNAME,
init,
fetchImpl: params.fetchImpl,
});
if (!response.ok) {
throw new TwilioSmsApiError(response.status, response.text);
}
const payload = parseTwilioSuccessPayload(response.text);
const sid = payload.sid?.trim();
if (!sid) {
throw new Error("Twilio SMS send response did not include a Message SID.");
}
return {
sid,
to: payload.to?.trim() || params.to,
...(payload.from?.trim() ? { from: payload.from.trim() } : {}),
...(payload.status?.trim() ? { status: payload.status.trim() } : {}),
};
}

View File

@@ -0,0 +1,55 @@
// Sms type declarations define plugin contracts.
import type { SecretInput } from "openclaw/plugin-sdk/secret-input";
export type SmsChannelConfigFields = {
enabled?: boolean;
accountSid?: string;
authToken?: SecretInput;
fromNumber?: string;
messagingServiceSid?: string;
defaultTo?: string;
webhookPath?: string;
publicWebhookUrl?: string;
dangerouslyDisableSignatureValidation?: boolean;
dmPolicy?: "pairing" | "open" | "allowlist" | "disabled";
allowFrom?: string | Array<string | number>;
textChunkLimit?: number;
};
export interface SmsChannelConfig extends SmsChannelConfigFields {
accounts?: Record<string, SmsAccountRaw>;
defaultAccount?: string;
}
export interface SmsAccountRaw extends SmsChannelConfigFields {}
export interface ResolvedSmsAccount {
accountId: string;
enabled: boolean;
accountSid: string;
authToken: string;
fromNumber: string;
messagingServiceSid: string;
defaultTo: string;
webhookPath: string;
publicWebhookUrl: string;
dangerouslyDisableSignatureValidation: boolean;
dmPolicy: "pairing" | "open" | "allowlist" | "disabled";
allowFrom: string[];
textChunkLimit: number;
}
export interface SmsInboundMessage {
messageSid: string;
accountSid: string;
from: string;
to: string;
body: string;
}
export type SmsSendResult = {
sid: string;
to: string;
from?: string;
status?: string;
};

View File

@@ -0,0 +1,105 @@
// Sms tests cover webhook plugin behavior.
import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SmsChannelRuntime } from "./inbound.js";
import { computeTwilioSignature, parseTwilioFormBody } from "./twilio.js";
import type { ResolvedSmsAccount } from "./types.js";
import { createSmsWebhookHandler, resetSmsWebhookReplayCacheForTest } from "./webhook.js";
const dispatchSmsInboundEvent = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("./inbound.js", () => ({
dispatchSmsInboundEvent,
}));
function createAccount(): ResolvedSmsAccount {
return {
accountId: "default",
enabled: true,
accountSid: "AC123",
authToken: "secret",
fromNumber: "+15557654321",
messagingServiceSid: "",
defaultTo: "",
webhookPath: "/webhooks/sms",
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
dangerouslyDisableSignatureValidation: false,
dmPolicy: "pairing",
allowFrom: [],
textChunkLimit: 1500,
};
}
function createRequest(body: string, signature: string): IncomingMessage {
const req = Readable.from([body]) as IncomingMessage;
req.method = "POST";
req.headers = { "x-twilio-signature": signature };
Object.defineProperty(req, "socket", {
value: { remoteAddress: "127.0.0.1" },
});
return req;
}
function createResponse(): ServerResponse & { body?: string } {
return {
statusCode: 200,
setHeader: vi.fn(),
end: vi.fn(function (this: ServerResponse & { body?: string }, body?: string) {
this.body = body;
return this;
}),
} as unknown as ServerResponse & { body?: string };
}
describe("createSmsWebhookHandler", () => {
beforeEach(() => {
dispatchSmsInboundEvent.mockClear();
resetSmsWebhookReplayCacheForTest();
});
it("dedupes replayed signed Twilio webhooks by message SID", async () => {
const body =
"AccountSid=AC123&From=%2B15551234567&To=%2B15557654321&Body=hello&MessageSid=SM123";
const signature = computeTwilioSignature({
url: "https://gateway.example.com/webhooks/sms",
authToken: "secret",
form: parseTwilioFormBody(body),
});
const handler = createSmsWebhookHandler({
cfg: {},
account: createAccount(),
channelRuntime: {} as SmsChannelRuntime,
});
const firstRes = createResponse();
await handler(createRequest(body, signature), firstRes);
const replayRes = createResponse();
await handler(createRequest(body, signature), replayRes);
expect(firstRes.statusCode).toBe(200);
expect(replayRes.statusCode).toBe(200);
expect(dispatchSmsInboundEvent).toHaveBeenCalledTimes(1);
});
it("rejects signed webhooks for a different Twilio account", async () => {
const body =
"AccountSid=AC-other&From=%2B15551234567&To=%2B15557654321&Body=hello&SmsMessageSid=SM123";
const signature = computeTwilioSignature({
url: "https://gateway.example.com/webhooks/sms",
authToken: "secret",
form: parseTwilioFormBody(body),
});
const handler = createSmsWebhookHandler({
cfg: {},
account: createAccount(),
channelRuntime: {} as SmsChannelRuntime,
});
const res = createResponse();
await handler(createRequest(body, signature), res);
expect(res.statusCode).toBe(403);
expect(dispatchSmsInboundEvent).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,147 @@
// Sms plugin module implements webhook behavior.
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createFixedWindowRateLimiter } from "openclaw/plugin-sdk/webhook-ingress";
import { dispatchSmsInboundEvent, type SmsChannelRuntime } from "./inbound.js";
import {
buildTwilioInboundMessage,
readTwilioWebhookForm,
respondTwiml,
resolveTwilioWebhookSignatureUrl,
verifyTwilioSignature,
} from "./twilio.js";
import type { ResolvedSmsAccount } from "./types.js";
const rateLimiter = createFixedWindowRateLimiter({
maxRequests: 30,
windowMs: 60_000,
maxTrackedKeys: 5_000,
});
const REPLAY_CACHE_TTL_MS = 10 * 60_000;
const REPLAY_CACHE_MAX_KEYS = 10_000;
const replayCache = new Map<string, number>();
type SmsWebhookLog = {
info?: (message: string) => void;
warn?: (message: string) => void;
error?: (message: string) => void;
};
export type SmsWebhookHandlerParams = {
cfg: OpenClawConfig;
account: ResolvedSmsAccount;
channelRuntime: SmsChannelRuntime;
log?: SmsWebhookLog;
};
function headerValue(value: string | string[] | undefined): string | undefined {
if (Array.isArray(value)) {
return value[0];
}
return value;
}
function rateLimitKey(req: IncomingMessage): string {
return req.socket?.remoteAddress ?? "unknown";
}
function rememberWebhookMessage(params: {
accountId: string;
messageSid: string;
now?: number;
}): boolean {
const now = params.now ?? Date.now();
for (const [key, expiresAt] of replayCache) {
if (expiresAt > now && replayCache.size <= REPLAY_CACHE_MAX_KEYS) {
break;
}
replayCache.delete(key);
}
const key = `${params.accountId}:${params.messageSid}`;
if ((replayCache.get(key) ?? 0) > now) {
return false;
}
replayCache.set(key, now + REPLAY_CACHE_TTL_MS);
return true;
}
export function resetSmsWebhookReplayCacheForTest(): void {
replayCache.clear();
}
export function createSmsWebhookHandler(params: SmsWebhookHandlerParams) {
return async (req: IncomingMessage, res: ServerResponse) => {
if (req.method !== "POST") {
respondTwiml(res, 405, "Method not allowed");
return true;
}
const key = rateLimitKey(req);
if (rateLimiter.isRateLimited(key)) {
params.log?.warn?.(`SMS webhook rate limit exceeded for ${key}`);
respondTwiml(res, 429, "Rate limit exceeded");
return true;
}
let form: Record<string, string>;
try {
form = await readTwilioWebhookForm(req);
} catch {
respondTwiml(res, 400, "Invalid request body");
return true;
}
if (!params.account.dangerouslyDisableSignatureValidation) {
const ok = verifyTwilioSignature({
signature: headerValue(req.headers["x-twilio-signature"]),
url: resolveTwilioWebhookSignatureUrl({
req,
publicWebhookUrl: params.account.publicWebhookUrl,
}),
authToken: params.account.authToken,
form,
});
if (!ok) {
params.log?.warn?.("SMS webhook rejected invalid Twilio signature");
respondTwiml(res, 403, "Invalid signature");
return true;
}
}
const msg = buildTwilioInboundMessage(form);
if (!msg) {
respondTwiml(res, 400, "Missing SMS payload");
return true;
}
if (msg.accountSid && msg.accountSid !== params.account.accountSid) {
params.log?.warn?.("SMS webhook rejected mismatched Twilio AccountSid");
respondTwiml(res, 403, "Invalid account");
return true;
}
if (
!rememberWebhookMessage({
accountId: params.account.accountId,
messageSid: msg.messageSid,
})
) {
params.log?.warn?.(`SMS webhook ignored replayed message ${msg.messageSid}`);
respondTwiml(res, 200);
return true;
}
void dispatchSmsInboundEvent({
cfg: params.cfg,
account: params.account,
msg,
channelRuntime: params.channelRuntime,
log: params.log,
}).catch((err: unknown) => {
params.log?.error?.(
`SMS webhook dispatch failed: ${err instanceof Error ? err.message : String(err)}`,
);
});
respondTwiml(res, 200);
return true;
};
}