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
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:
13
extensions/sms/README.md
Normal file
13
extensions/sms/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# SMS OpenClaw channel
|
||||
|
||||
Official OpenClaw channel plugin for SMS.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
openclaw plugins install @openclaw/sms
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
See `docs/channels/sms.md` in the OpenClaw repository, or the published docs at `https://docs.openclaw.ai/channels/sms`.
|
||||
2
extensions/sms/api.ts
Normal file
2
extensions/sms/api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Sms API module exposes the plugin public contract.
|
||||
export { getSmsRuntime, setSmsRuntime } from "./src/runtime.js";
|
||||
2
extensions/sms/channel-plugin-api.ts
Normal file
2
extensions/sms/channel-plugin-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Sms API module exposes the plugin public contract.
|
||||
export { smsPlugin } from "./src/channel.js";
|
||||
17
extensions/sms/index.ts
Normal file
17
extensions/sms/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Sms plugin entrypoint registers its OpenClaw integration.
|
||||
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelEntry({
|
||||
id: "sms",
|
||||
name: "SMS",
|
||||
description: "Twilio SMS channel plugin for OpenClaw text messages.",
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./channel-plugin-api.js",
|
||||
exportName: "smsPlugin",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./api.js",
|
||||
exportName: "setSmsRuntime",
|
||||
},
|
||||
});
|
||||
24
extensions/sms/npm-shrinkwrap.json
generated
Normal file
24
extensions/sms/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@openclaw/sms",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/sms",
|
||||
"version": "2026.6.11",
|
||||
"dependencies": {
|
||||
"zod": "4.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
extensions/sms/openclaw.plugin.json
Normal file
26
extensions/sms/openclaw.plugin.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "sms",
|
||||
"name": "SMS",
|
||||
"description": "Twilio SMS channel plugin for OpenClaw text messages.",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"channels": ["sms"],
|
||||
"channelEnvVars": {
|
||||
"sms": [
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
50
extensions/sms/package.json
Normal file
50
extensions/sms/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@openclaw/sms",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw SMS channel plugin for Twilio text messages.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"channel": {
|
||||
"id": "sms",
|
||||
"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,
|
||||
"quickstartAllowFrom": true
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11",
|
||||
"bundledDist": false
|
||||
},
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/sms",
|
||||
"npmSpec": "@openclaw/sms",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.6.9",
|
||||
"allowInvalidConfigRecovery": true
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
6
extensions/sms/secret-contract-api.ts
Normal file
6
extensions/sms/secret-contract-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Sms API module exposes the plugin public contract.
|
||||
export {
|
||||
channelSecrets,
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
263
extensions/sms/src/accounts.test.ts
Normal file
263
extensions/sms/src/accounts.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
174
extensions/sms/src/accounts.ts
Normal file
174
extensions/sms/src/accounts.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
156
extensions/sms/src/channel.test.ts
Normal file
156
extensions/sms/src/channel.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
330
extensions/sms/src/channel.ts
Normal file
330
extensions/sms/src/channel.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
93
extensions/sms/src/config-schema.ts
Normal file
93
extensions/sms/src/config-schema.ts
Normal 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.",
|
||||
},
|
||||
},
|
||||
});
|
||||
107
extensions/sms/src/gateway.test.ts
Normal file
107
extensions/sms/src/gateway.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
113
extensions/sms/src/gateway.ts
Normal file
113
extensions/sms/src/gateway.ts
Normal 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);
|
||||
}
|
||||
167
extensions/sms/src/inbound.test.ts
Normal file
167
extensions/sms/src/inbound.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
215
extensions/sms/src/inbound.ts
Normal file
215
extensions/sms/src/inbound.ts
Normal 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}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
23
extensions/sms/src/phone.test.ts
Normal file
23
extensions/sms/src/phone.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
21
extensions/sms/src/phone.ts
Normal file
21
extensions/sms/src/phone.ts
Normal 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();
|
||||
}
|
||||
10
extensions/sms/src/runtime.ts
Normal file
10
extensions/sms/src/runtime.ts
Normal 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 };
|
||||
163
extensions/sms/src/secret-contract.test.ts
Normal file
163
extensions/sms/src/secret-contract.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
97
extensions/sms/src/secret-contract.ts
Normal file
97
extensions/sms/src/secret-contract.ts
Normal 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,
|
||||
};
|
||||
71
extensions/sms/src/send.test.ts
Normal file
71
extensions/sms/src/send.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
52
extensions/sms/src/send.ts
Normal file
52
extensions/sms/src/send.ts
Normal 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;
|
||||
}
|
||||
267
extensions/sms/src/status.test.ts
Normal file
267
extensions/sms/src/status.test.ts
Normal 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
356
extensions/sms/src/status.ts
Normal file
356
extensions/sms/src/status.ts
Normal 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;
|
||||
}
|
||||
659
extensions/sms/src/twilio.test.ts
Normal file
659
extensions/sms/src/twilio.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
577
extensions/sms/src/twilio.ts
Normal file
577
extensions/sms/src/twilio.ts
Normal 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() } : {}),
|
||||
};
|
||||
}
|
||||
55
extensions/sms/src/types.ts
Normal file
55
extensions/sms/src/types.ts
Normal 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;
|
||||
};
|
||||
105
extensions/sms/src/webhook.test.ts
Normal file
105
extensions/sms/src/webhook.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
147
extensions/sms/src/webhook.ts
Normal file
147
extensions/sms/src/webhook.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
16
extensions/sms/tsconfig.json
Normal file
16
extensions/sms/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user