diff --git a/extensions/sms/src/accounts.ts b/extensions/sms/src/accounts.ts index c6b00d1718cc..8bd84a23ce34 100644 --- a/extensions/sms/src/accounts.ts +++ b/extensions/sms/src/accounts.ts @@ -29,7 +29,7 @@ function parseList(raw: string | string[] | undefined): string[] { return []; } return (Array.isArray(raw) ? raw : normalizeStringEntries(raw.split(","))) - .map((entry) => normalizeSmsAllowFrom(String(entry))) + .map((entry) => normalizeSmsAllowFrom(entry)) .filter(Boolean); } @@ -83,17 +83,21 @@ export function resolveSmsAccount( ): ResolvedSmsAccount { const channelCfg = getChannelConfig(cfg) ?? {}; const id = normalizeOptionalAccountId(accountId) ?? resolveDefaultSmsAccountId(cfg); - const accountConfig = resolveAccountEntry( - channelCfg.accounts as - | Record & SmsChannelConfig>> - | undefined, - id, - ); + const accountConfig = resolveAccountEntry(channelCfg.accounts, id); + const channelConfig: Record & SmsChannelConfig = { ...channelCfg }; + const accountEntries: + | Record & SmsChannelConfig>> + | undefined = channelCfg.accounts + ? Object.fromEntries( + Object.entries(channelCfg.accounts).map(([accountKey, account]) => [ + accountKey, + { ...account }, + ]), + ) + : undefined; const merged = resolveMergedAccountConfig & SmsChannelConfig>({ - channelConfig: channelCfg as Record & SmsChannelConfig, - accounts: channelCfg.accounts as - | Record & SmsChannelConfig>> - | undefined, + channelConfig, + accounts: accountEntries, accountId: id, omitKeys: ["defaultAccount"], }); @@ -115,8 +119,8 @@ export function resolveSmsAccount( ? process.env.SMS_DANGEROUSLY_DISABLE_SIGNATURE_VALIDATION : undefined; - const webhookPath = String(merged.webhookPath ?? envWebhookPath ?? DEFAULT_WEBHOOK_PATH).trim(); - const publicWebhookUrl = String(merged.publicWebhookUrl ?? envPublicWebhookUrl ?? "").trim(); + const webhookPath = (merged.webhookPath ?? envWebhookPath ?? DEFAULT_WEBHOOK_PATH).trim(); + const publicWebhookUrl = (merged.publicWebhookUrl ?? envPublicWebhookUrl ?? "").trim(); const authToken = normalizeResolvedSecretInputString({ value: merged.authToken ?? envAuthToken, @@ -128,11 +132,11 @@ export function resolveSmsAccount( return { accountId: id, enabled: channelCfg.enabled !== false && accountConfig?.enabled !== false, - accountSid: String(merged.accountSid ?? envAccountSid ?? "").trim(), + accountSid: (merged.accountSid ?? envAccountSid ?? "").trim(), authToken, - fromNumber: normalizeSmsPhoneNumber(String(merged.fromNumber ?? envFromNumber ?? "")), - messagingServiceSid: String(merged.messagingServiceSid ?? envMessagingServiceSid ?? "").trim(), - defaultTo: normalizeSmsPhoneNumber(String(merged.defaultTo ?? "")), + fromNumber: normalizeSmsPhoneNumber(merged.fromNumber ?? envFromNumber ?? ""), + messagingServiceSid: (merged.messagingServiceSid ?? envMessagingServiceSid ?? "").trim(), + defaultTo: normalizeSmsPhoneNumber(merged.defaultTo ?? ""), webhookPath: webhookPath || DEFAULT_WEBHOOK_PATH, publicWebhookUrl, dangerouslyDisableSignatureValidation: diff --git a/extensions/sms/src/channel.ts b/extensions/sms/src/channel.ts index ecfd39745b81..ae699977e881 100644 --- a/extensions/sms/src/channel.ts +++ b/extensions/sms/src/channel.ts @@ -103,15 +103,15 @@ function applySmsAccountConfig(params: { input: Record; }): OpenClawConfig { const patch = smsSetupPatch(params.input); - const channels = { ...(params.cfg.channels ?? {}) }; - const current = { ...((channels[CHANNEL_ID] as Record | undefined) ?? {}) }; + const channels = { ...params.cfg.channels }; + const current = { ...(channels[CHANNEL_ID] as Record | undefined) }; if (params.accountId === DEFAULT_ACCOUNT_ID) { channels[CHANNEL_ID] = { ...current, ...patch }; return { ...params.cfg, channels }; } - const accounts = { ...((current.accounts as Record | undefined) ?? {}) }; + const accounts = { ...(current.accounts as Record | undefined) }; accounts[params.accountId] = { - ...((accounts[params.accountId] as Record | undefined) ?? {}), + ...(accounts[params.accountId] as Record | undefined), ...patch, }; channels[CHANNEL_ID] = { ...current, accounts }; @@ -254,6 +254,12 @@ export const smsPlugin: ChannelPlugin = createChatChannelPlu }, }, status: { + buildAccountSnapshot: ({ account }) => ({ + accountId: account.accountId, + name: account.fromNumber || account.messagingServiceSid || "SMS", + configured: isSmsAccountConfigured(account), + enabled: account.enabled, + }), buildCapabilitiesDiagnostics: async ({ account }) => ({ lines: collectSmsStartupWarnings(account).map((text) => ({ text, tone: "warn" })), }), diff --git a/extensions/sms/src/gateway.test.ts b/extensions/sms/src/gateway.test.ts index 4493e2a51b1e..7126ec46a000 100644 --- a/extensions/sms/src/gateway.test.ts +++ b/extensions/sms/src/gateway.test.ts @@ -6,7 +6,7 @@ import type { ResolvedSmsAccount } from "./types.js"; const registerPluginHttpRoute = vi.hoisted(() => vi.fn(() => vi.fn())); vi.mock("openclaw/plugin-sdk/webhook-ingress", async (importOriginal) => ({ - ...((await importOriginal()) as Record), + ...(await importOriginal()), registerPluginHttpRoute, })); diff --git a/extensions/sms/src/inbound.test.ts b/extensions/sms/src/inbound.test.ts index 77f5ab7dcfae..dd39318dec0b 100644 --- a/extensions/sms/src/inbound.test.ts +++ b/extensions/sms/src/inbound.test.ts @@ -1,9 +1,10 @@ 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(async () => ({ sid: "SM-pair", to: "+15551234567" })), + vi.fn(async () => ({ sid: "SM-pair", to: "+15551234567" })), ); vi.mock("./twilio.js", () => ({ diff --git a/extensions/sms/src/twilio.test.ts b/extensions/sms/src/twilio.test.ts index 0b8258c27fa5..661e8b5ac668 100644 --- a/extensions/sms/src/twilio.test.ts +++ b/extensions/sms/src/twilio.test.ts @@ -29,6 +29,16 @@ function createAccount(overrides: Partial = {}): ResolvedSms }; } +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."); +} + describe("Twilio SMS helpers", () => { it("parses Twilio form bodies and inbound messages", () => { const form = parseTwilioFormBody( @@ -105,7 +115,7 @@ describe("Twilio SMS helpers", () => { }); it("sends SMS through Twilio's Messages API", async () => { - const fetchImpl = vi.fn( + const fetchImpl = vi.fn( async () => new Response( JSON.stringify({ @@ -156,14 +166,14 @@ describe("Twilio SMS helpers", () => { authorization: `Basic ${Buffer.from("AC123:secret").toString("base64")}`, "content-type": "application/x-www-form-urlencoded", }); - const body = new URLSearchParams(String(init?.body)); + const body = readUrlEncodedRequestBody(init); expect(body.get("From")).toBe("+15557654321"); expect(body.get("To")).toBe("+15551234567"); expect(body.get("Body")).toBe("hello"); }); it("can send through a Twilio Messaging Service SID", async () => { - const fetchImpl = vi.fn( + const fetchImpl = vi.fn( async () => new Response(JSON.stringify({ sid: "SM789" }), { status: 201, @@ -193,14 +203,14 @@ describe("Twilio SMS helpers", () => { }); const [, init] = fetchImpl.mock.calls[0] ?? []; - const body = new URLSearchParams(String(init?.body)); + 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( + const fetchImpl = vi.fn( async () => new Response(JSON.stringify({ sid: "SM999" }), { status: 201, @@ -230,7 +240,7 @@ describe("Twilio SMS helpers", () => { }); const [, init] = fetchImpl.mock.calls[0] ?? []; - const body = new URLSearchParams(String(init?.body)); + const body = readUrlEncodedRequestBody(init); expect(body.get("From")).toBe("+15557654321"); expect(body.get("MessagingServiceSid")).toBeNull(); }); diff --git a/extensions/sms/src/twilio.ts b/extensions/sms/src/twilio.ts index bcb365d249d5..67efcdd9cde8 100644 --- a/extensions/sms/src/twilio.ts +++ b/extensions/sms/src/twilio.ts @@ -76,7 +76,7 @@ function parseTwilioSuccessPayload(text: string): TwilioMessagePayload { if (err instanceof Error && err.message === "Twilio SMS send returned malformed JSON.") { throw err; } - throw new Error("Twilio SMS send returned malformed JSON."); + throw new Error("Twilio SMS send returned malformed JSON.", { cause: err }); } } @@ -145,7 +145,7 @@ export function computeTwilioSignature(params: { const data = params.url + Object.keys(params.form) - .sort() + .toSorted() .map((key) => `${key}${params.form[key] ?? ""}`) .join(""); return createHmac("sha1", params.authToken).update(data).digest("base64"); diff --git a/extensions/sms/tsconfig.json b/extensions/sms/tsconfig.json index b1eac8b8a69e..b8a85a99ac3d 100644 --- a/extensions/sms/tsconfig.json +++ b/extensions/sms/tsconfig.json @@ -4,5 +4,13 @@ "rootDir": "." }, "include": ["./*.ts", "./src/**/*.ts"], - "exclude": ["./**/*.test.ts", "./dist/**", "./node_modules/**"] + "exclude": [ + "./**/*.test.ts", + "./dist/**", + "./node_modules/**", + "./src/test-support/**", + "./src/**/*test-helpers.ts", + "./src/**/*test-harness.ts", + "./src/**/*test-support.ts" + ] } diff --git a/src/gateway/server.agent.gateway-server-agent-b.test.ts b/src/gateway/server.agent.gateway-server-agent-b.test.ts index 032ac7541a4d..20b8b8adb488 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.test.ts @@ -357,7 +357,7 @@ describe("gateway server agent", () => { const res = await rpcReq(ws, "agent", { message: "hi", sessionKey: "main", - channel: "sms", + channel: "missing-channel", idempotencyKey: "idem-agent-bad-channel", }); expect(res.ok).toBe(false);