mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(sms): add Twilio MMS support (#118664)
* feat(sms): add Twilio MMS support * fix(sms): harden hosted media bearer handling * fix(sms): discard staged media before dispatch * fix(sms): close staged media cleanup races * fix(sms): finalize MMS metadata and race proof * fix(media): bind local reads to authorized descriptors * fix(sms): close MMS review gaps * fix(media): make inbound root matching type-safe * fix(media): route bounded reads through fs-safe facade * test(media): satisfy async lint contracts
This commit is contained in:
@@ -38,7 +38,7 @@ install. Channels marked "official plugin" install with one command
|
||||
- [Reef](/channels/reef) - Reef channel setup: guarded, end-to-end-encrypted messaging between OpenClaw agents of different people (bundled plugin).
|
||||
- [Signal](/channels/signal) - Signal support via signal-cli (native daemon or bbernhard container), setup paths, and number model (official plugin).
|
||||
- [Slack](/channels/slack) - Slack setup and runtime behavior (Socket Mode, HTTP Request URLs, and relay mode) (official plugin).
|
||||
- [SMS](/channels/sms) - Twilio SMS channel setup, access controls, and webhook configuration (official plugin).
|
||||
- [SMS](/channels/sms) - Twilio SMS/MMS setup, access controls, and webhook configuration (official plugin).
|
||||
- [Synology Chat](/channels/synology-chat) - Synology Chat webhook setup and OpenClaw config (official plugin).
|
||||
- [Telegram](/channels/telegram) - Telegram bot support status, capabilities, and configuration (bundled plugin).
|
||||
- [Tlon](/channels/tlon) - Tlon/Urbit support status, capabilities, and configuration (official plugin).
|
||||
|
||||
+45
-25
@@ -1,14 +1,14 @@
|
||||
---
|
||||
summary: "Twilio SMS channel setup, access controls, and webhook configuration"
|
||||
summary: "Twilio SMS/MMS setup, access controls, and webhook configuration"
|
||||
read_when:
|
||||
- You want to connect OpenClaw to SMS through Twilio
|
||||
- You need SMS webhook or allowlist setup
|
||||
- You want to connect OpenClaw to SMS or MMS through Twilio
|
||||
- You need SMS/MMS webhook or allowlist setup
|
||||
title: "SMS"
|
||||
---
|
||||
|
||||
OpenClaw receives and sends SMS through a Twilio phone number or Messaging Service. The Gateway registers an inbound webhook route (default `/webhooks/sms`), validates Twilio request signatures by default, and sends replies back through Twilio's Messages API.
|
||||
OpenClaw receives and sends SMS/MMS through a Twilio phone number or Messaging Service. The Gateway registers an inbound webhook route (default `/webhooks/sms`), validates Twilio request signatures by default, and sends replies back through Twilio's Messages API.
|
||||
|
||||
Status: official plugin, installed separately. Text only: no MMS/media, direct messages only.
|
||||
Status: official plugin, installed separately. SMS text and MMS attachments, direct messages only.
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Pairing" icon="link" href="/channels/pairing">
|
||||
@@ -27,7 +27,7 @@ Status: official plugin, installed separately. Text only: no MMS/media, direct m
|
||||
You need:
|
||||
|
||||
- The official SMS plugin installed with `openclaw plugins install @openclaw/sms`.
|
||||
- A Twilio account with an SMS-capable phone number, or a Twilio Messaging Service.
|
||||
- A Twilio account with an SMS-capable phone number, or a Twilio Messaging Service. MMS requires an MMS-capable sender; native MMS delivery also depends on the destination country and carrier.
|
||||
- The Twilio Account SID and Auth Token.
|
||||
- A public HTTPS URL that reaches your OpenClaw Gateway.
|
||||
- A sender policy choice: `pairing` (default) for private use, `allowlist` for preapproved phone numbers, or `open` only for intentionally public SMS access.
|
||||
@@ -43,7 +43,7 @@ One Twilio number can serve both SMS and [Voice Call](/plugins/voice-call) if it
|
||||
```
|
||||
</Step>
|
||||
<Step title="Create or choose a Twilio sender">
|
||||
In Twilio, open **Phone Numbers > Manage > Active numbers** and choose an SMS-capable number. Save:
|
||||
In Twilio, open **Phone Numbers > Manage > Active numbers** and choose an SMS-capable number. To send attachments, choose one that is also MMS-capable. Save:
|
||||
|
||||
- Account SID, for example `ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`
|
||||
- Auth Token
|
||||
@@ -93,7 +93,7 @@ https://gateway.example.com/webhooks/sms
|
||||
</Step>
|
||||
|
||||
<Step title="Expose the exact SMS webhook path">
|
||||
Your public URL must route the SMS path to the Gateway process (default port `18789`). If you use Tailscale Funnel for local testing, expose `/webhooks/sms` explicitly:
|
||||
Your public URL must route the SMS path to the Gateway process (default port `18789`). The same path serves inbound Twilio webhooks and short-lived, tokenized attachments when OpenClaw sends MMS. If you use Tailscale Funnel for local testing, expose `/webhooks/sms` explicitly:
|
||||
|
||||
```bash
|
||||
tailscale funnel --bg --set-path /webhooks/sms http://127.0.0.1:<gateway-port>/webhooks/sms
|
||||
@@ -126,21 +126,21 @@ openclaw pairing approve sms <CODE>
|
||||
|
||||
All keys live under `channels.sms` (and per account under `channels.sms.accounts.<id>`):
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --------------------------------------- | --------------- | ------------------------------------------------------------------- |
|
||||
| `enabled` | `true` | Enable or disable the channel/account. |
|
||||
| `accountSid` | — | Twilio Account SID (`AC...`). |
|
||||
| `authToken` | — | Twilio Auth Token; plaintext string or SecretRef. |
|
||||
| `fromNumber` | — | E.164 sender number. |
|
||||
| `messagingServiceSid` | — | Messaging Service SID (`MG...`) used when no `fromNumber` resolves. |
|
||||
| `defaultTo` | — | Default destination when a send flow omits an explicit target. |
|
||||
| `webhookPath` | `/webhooks/sms` | Gateway HTTP path for inbound Twilio webhooks. |
|
||||
| `publicWebhookUrl` | — | Public URL configured in Twilio; required for signature validation. |
|
||||
| `dangerouslyDisableSignatureValidation` | `false` | Skip `X-Twilio-Signature` checks; local tunnel testing only. |
|
||||
| `dmPolicy` | `"pairing"` | `pairing`, `allowlist`, `open`, or `disabled`. |
|
||||
| `allowFrom` | `[]` | Allowed sender numbers in E.164, or `"*"` with `dmPolicy: "open"`. |
|
||||
| `textChunkLimit` | `1500` | Maximum characters per outbound SMS chunk. |
|
||||
| `accounts`, `defaultAccount` | — | Multi-account map and default account id. |
|
||||
| Key | Default | Purpose |
|
||||
| --------------------------------------- | --------------- | -------------------------------------------------------------------------------------- |
|
||||
| `enabled` | `true` | Enable or disable the channel/account. |
|
||||
| `accountSid` | — | Twilio Account SID (`AC...`). |
|
||||
| `authToken` | — | Twilio Auth Token; plaintext string or SecretRef. |
|
||||
| `fromNumber` | — | E.164 sender number. |
|
||||
| `messagingServiceSid` | — | Messaging Service SID (`MG...`) used when no `fromNumber` resolves. |
|
||||
| `defaultTo` | — | Default destination when a send flow omits an explicit target. |
|
||||
| `webhookPath` | `/webhooks/sms` | Gateway HTTP path for inbound Twilio webhooks. |
|
||||
| `publicWebhookUrl` | — | Public Twilio webhook URL; required for signature validation and outbound MMS hosting. |
|
||||
| `dangerouslyDisableSignatureValidation` | `false` | Skip `X-Twilio-Signature` checks; local tunnel testing only. |
|
||||
| `dmPolicy` | `"pairing"` | `pairing`, `allowlist`, `open`, or `disabled`. |
|
||||
| `allowFrom` | `[]` | Allowed sender numbers in E.164, or `"*"` with `dmPolicy: "open"`. |
|
||||
| `textChunkLimit` | `1500` | Maximum characters per outbound SMS chunk. |
|
||||
| `accounts`, `defaultAccount` | — | Multi-account map and default account id. |
|
||||
|
||||
### Config file
|
||||
|
||||
@@ -305,6 +305,26 @@ Agent replies from inbound SMS conversations automatically go back to the sender
|
||||
|
||||
SMS output is plain text. OpenClaw strips markdown, flattens fenced code blocks, rewrites links as `label (url)`, and splits long replies into chunks of at most `textChunkLimit` characters (default 1500) before sending them through Twilio.
|
||||
|
||||
### Sending MMS
|
||||
|
||||
Use the normal structured media field or the CLI `--media` option:
|
||||
|
||||
```bash
|
||||
openclaw message send \
|
||||
--channel sms \
|
||||
--target sms:+15551234567 \
|
||||
--message "photo" \
|
||||
--media ./photo.jpg
|
||||
```
|
||||
|
||||
OpenClaw loads the attachment through the shared outbound-media policy, stores it temporarily in plugin-scoped SQLite state, and gives Twilio a tokenized HTTPS URL on the configured `publicWebhookUrl` path. Media-only sends are supported.
|
||||
|
||||
The generated media URL is a bearer capability that expires after 10 minutes. Treat its full query string as a secret: configure reverse-proxy and access logs to omit the query string or redact every query value. OpenClaw Gateway route diagnostics record only the pathname, but cannot control upstream proxy logs.
|
||||
|
||||
Outbound OpenClaw deliveries attach one media item. OpenClaw caps JPEG, JPG, PNG, and GIF attachments at 5,000,000 bytes; other supported media types are capped at 500,000 bytes. `application/vcard` attachments must be media-only; Twilio does not accept them with a caption. Destination carriers may enforce smaller limits or reject unsupported formats. Twilio must be able to fetch the generated URL without HTTP authentication, so `publicWebhookUrl` cannot contain embedded userinfo; query-based reverse-proxy tokens are preserved.
|
||||
|
||||
For incoming MMS, OpenClaw processes at most 10 attachments and downloads at most 5 MiB total. Any additional or unavailable attachments produce a visible unavailable-media notice instead of discarding the signed message or silently delivering an empty turn. Downloads happen only after sender authorization, with Twilio authentication and an `api.twilio.com` host restriction.
|
||||
|
||||
## Verify Setup
|
||||
|
||||
After the Gateway starts:
|
||||
@@ -352,7 +372,7 @@ The webhook route also enforces, independent of signature validation:
|
||||
- Dispatchable callback rate limit of 30 accepted callbacks per minute per SMS account, webhook route, and validated sender after body parsing and signature validation pass (HTTP 429 above that). The sender key is the canonicalized, signature-covered `From` value, so equivalent SMS/RCS address forms share one budget, one flooding sender exhausts only its own budget, and callbacks from other senders behind Twilio's shared egress addresses remain dispatchable. Invalid or missing sender values share a separate empty-sender budget.
|
||||
- Aggregate validated-callback ceiling of 300 accepted callbacks per minute per SMS account and webhook route. This bounds durable-ingress pressure from many distinct signed senders without recreating shared-egress cross-throttling. If signature validation is disabled, nothing authenticates `From`; the stricter 30/min resolved-client-address dispatch cap applies instead of the validated sender and aggregate policy.
|
||||
- Client addresses are resolved through the shared Gateway trusted-proxy rules. If `gateway.trustedProxies` contains the reverse proxy that forwards Twilio callbacks, OpenClaw keys the address-based limits from the forwarded client address; otherwise it falls back to the direct socket address.
|
||||
- The payload `AccountSid` must match the configured `accountSid`. The raw callback is first committed to the durable ingress queue and acknowledged; a mismatch is then marked as a permanent invalid-payload failure during drain and is never dispatched.
|
||||
- The payload `AccountSid` must exactly match the configured `accountSid`. Direct-number callbacks must target the configured `fromNumber`; Messaging Service callbacks must carry the configured `MessagingServiceSid`. The raw callback is first committed to the durable ingress queue and acknowledged; an identity mismatch is then marked as a permanent invalid-payload failure during drain and is never dispatched or allowed to download media.
|
||||
- Replayed `MessageSid` values are deduplicated by the durable ingress queue. Completed-message tombstones are retained for 24 hours (up to 20,000 entries per account); permanent-failure tombstones are retained for 30 days (up to 1,000 entries).
|
||||
- Request bodies over 32 KB are rejected.
|
||||
|
||||
@@ -405,7 +425,7 @@ Each account must use a distinct `webhookPath`; the Gateway refuses to register
|
||||
|
||||
Check that `publicWebhookUrl` exactly matches the URL configured in Twilio, including scheme, host, path, and query string. Twilio signs the public URL string, so proxy rewrites and alternate hostnames can break signature validation.
|
||||
|
||||
A 403 with `Invalid account` means the inbound payload's `AccountSid` does not match the configured `accountSid`; check that the webhook points at the account that owns the number.
|
||||
If Twilio receives a durable acknowledgement but no pairing request appears, check the Gateway log for a permanent invalid-payload failure. Confirm the callback's `AccountSid` and `To` match the configured account and `fromNumber`, or that its `MessagingServiceSid` matches the configured Messaging Service.
|
||||
|
||||
### No pairing request appears
|
||||
|
||||
|
||||
@@ -246,6 +246,14 @@ fetch can use `createHostedOutboundMediaStore(...)` from
|
||||
route parsing and token enforcement in the channel plugin; the shared helper
|
||||
only owns media loading, expiry metadata, chunk rows, and cleanup.
|
||||
|
||||
`prepareUrl({ mediaAccess })` forwards host-authorized local media access to
|
||||
the shared outbound loader. Hosted media capacity defaults to
|
||||
`overflowPolicy: "evict-oldest"` for compatibility. Use `"reject-new"` when
|
||||
issued URLs must remain valid until expiry, and configure both backing keyed
|
||||
stores with `"reject-new"` so independent writers cannot evict live rows.
|
||||
Authenticate bearer requests with `readMetadata(...)` before calling `read(...)`
|
||||
so invalid tokens and `HEAD` requests do not hydrate stored media chunks.
|
||||
|
||||
Inbound attachments use ordered facts, not parallel `Media*` fields. Normalize
|
||||
channel records with `toInboundMediaFacts(...)` from
|
||||
`openclaw/plugin-sdk/channel-inbound` and pass them as `media` when building the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SMS OpenClaw channel
|
||||
# SMS/MMS OpenClaw channel
|
||||
|
||||
Official OpenClaw channel plugin for SMS.
|
||||
Official OpenClaw channel plugin for Twilio SMS and MMS.
|
||||
|
||||
## Install
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-con
|
||||
export default defineBundledChannelEntry({
|
||||
id: "sms",
|
||||
name: "SMS",
|
||||
description: "Twilio SMS channel plugin for OpenClaw text messages.",
|
||||
description: "Twilio SMS/MMS channel plugin for OpenClaw messages.",
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./channel-plugin-api.js",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "sms",
|
||||
"name": "SMS",
|
||||
"description": "Twilio SMS channel plugin for OpenClaw text messages.",
|
||||
"description": "Twilio SMS/MMS channel plugin for OpenClaw messages.",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@openclaw/sms",
|
||||
"version": "2026.7.2",
|
||||
"description": "OpenClaw SMS channel plugin for Twilio text messages.",
|
||||
"description": "OpenClaw SMS/MMS channel plugin for Twilio messages.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
@@ -35,10 +35,10 @@
|
||||
},
|
||||
"label": "SMS",
|
||||
"selectionLabel": "SMS (Twilio)",
|
||||
"detailLabel": "Twilio SMS",
|
||||
"detailLabel": "Twilio SMS/MMS",
|
||||
"docsPath": "/channels/sms",
|
||||
"docsLabel": "sms",
|
||||
"blurb": "Twilio-backed SMS with inbound webhooks and outbound replies.",
|
||||
"blurb": "Twilio-backed SMS/MMS with inbound webhooks and outbound replies.",
|
||||
"order": 88,
|
||||
"quickstartAllowFrom": true,
|
||||
"setup": { "fields": [
|
||||
|
||||
@@ -1,30 +1,70 @@
|
||||
// Sms tests cover channel plugin behavior.
|
||||
import { isChannelPartialDeliveryError } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { sendSmsViaTwilio as sendSmsViaTwilioType } from "./twilio.js";
|
||||
|
||||
type ChannelModule = typeof import("./channel.js");
|
||||
type PlatformMessageNotDispatchedErrorConstructor =
|
||||
(typeof import("openclaw/plugin-sdk/error-runtime"))["PlatformMessageNotDispatchedError"];
|
||||
|
||||
let smsPlugin: ChannelModule["smsPlugin"];
|
||||
let PlatformMessageNotDispatchedError: PlatformMessageNotDispatchedErrorConstructor;
|
||||
|
||||
const sendSmsViaTwilio = vi.hoisted(() =>
|
||||
vi.fn(async ({ to }) => ({
|
||||
sid: "SM-default",
|
||||
to,
|
||||
from: "+15557654321",
|
||||
status: "queued",
|
||||
})),
|
||||
vi.fn<typeof sendSmsViaTwilioType>(async ({ to, onPlatformSendDispatch }) => {
|
||||
await onPlatformSendDispatch?.();
|
||||
return {
|
||||
sid: "SM-default",
|
||||
to,
|
||||
from: "+15557654321",
|
||||
status: "queued",
|
||||
};
|
||||
}),
|
||||
);
|
||||
const hostedMediaMocks = vi.hoisted(() => {
|
||||
const cleanup = vi.fn(async () => undefined);
|
||||
return {
|
||||
cleanup,
|
||||
prepare: vi.fn(async () => ({
|
||||
url: "https://gateway.example.com/webhooks/sms/media/abc?token=token",
|
||||
cleanup,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
sendSmsViaTwilio.mockClear();
|
||||
sendSmsViaTwilio.mockReset();
|
||||
sendSmsViaTwilio.mockImplementation(async ({ to, onPlatformSendDispatch }) => {
|
||||
await onPlatformSendDispatch?.();
|
||||
return {
|
||||
sid: "SM-default",
|
||||
to,
|
||||
from: "+15557654321",
|
||||
status: "queued",
|
||||
};
|
||||
});
|
||||
hostedMediaMocks.cleanup.mockReset();
|
||||
hostedMediaMocks.cleanup.mockResolvedValue(undefined);
|
||||
hostedMediaMocks.prepare.mockReset();
|
||||
hostedMediaMocks.prepare.mockResolvedValue({
|
||||
url: "https://gateway.example.com/webhooks/sms/media/abc?token=token",
|
||||
cleanup: hostedMediaMocks.cleanup,
|
||||
});
|
||||
vi.doMock("./twilio.js", () => ({
|
||||
sendSmsViaTwilio,
|
||||
TWILIO_MESSAGE_BODY_MAX_LENGTH: 1600,
|
||||
}));
|
||||
vi.doMock("./media.js", () => ({
|
||||
prepareHostedSmsMedia: hostedMediaMocks.prepare,
|
||||
}));
|
||||
({ PlatformMessageNotDispatchedError } = await import("openclaw/plugin-sdk/error-runtime"));
|
||||
({ smsPlugin } = await import("./channel.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock("./twilio.js");
|
||||
vi.doUnmock("./media.js");
|
||||
});
|
||||
|
||||
describe("smsPlugin status", () => {
|
||||
@@ -176,4 +216,334 @@ describe("smsPlugin outbound", () => {
|
||||
}),
|
||||
).toEqual({ ok: true, to: "+15551234567" });
|
||||
});
|
||||
|
||||
it("hosts and sends outbound media as MMS with an ordered multipart receipt", async () => {
|
||||
sendSmsViaTwilio
|
||||
.mockResolvedValueOnce({
|
||||
sid: "MM-first",
|
||||
to: "+15551234567",
|
||||
from: "+15557654321",
|
||||
status: "queued",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
sid: "SM-second",
|
||||
to: "+15551234567",
|
||||
from: "+15557654321",
|
||||
status: "queued",
|
||||
});
|
||||
const ctx = {
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accountSid: "AC123",
|
||||
authToken: "secret",
|
||||
fromNumber: "+15557654321",
|
||||
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
|
||||
textChunkLimit: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
to: "+15551234567",
|
||||
text: "alpha beta",
|
||||
kind: "media" as const,
|
||||
mediaUrl: "/tmp/photo.jpg",
|
||||
mediaLocalRoots: ["/tmp"],
|
||||
mediaReadFile: async () => Buffer.from("photo"),
|
||||
};
|
||||
await smsPlugin.message?.send?.lifecycle?.beforeSendAttempt?.(ctx);
|
||||
const result = await smsPlugin.message?.send?.media?.(ctx);
|
||||
|
||||
expect(hostedMediaMocks.prepare).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mediaUrl: "/tmp/photo.jpg",
|
||||
mediaLocalRoots: ["/tmp"],
|
||||
}),
|
||||
);
|
||||
expect(sendSmsViaTwilio).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
to: "+15551234567",
|
||||
text: "alpha",
|
||||
mediaUrls: ["https://gateway.example.com/webhooks/sms/media/abc?token=token"],
|
||||
}),
|
||||
);
|
||||
expect(sendSmsViaTwilio).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
to: "+15551234567",
|
||||
text: " beta",
|
||||
}),
|
||||
);
|
||||
expect(sendSmsViaTwilio.mock.calls[1]?.[0]).not.toHaveProperty("mediaUrls");
|
||||
expect(result?.messageId).toBe("MM-first");
|
||||
expect(result?.receipt.platformMessageIds).toEqual(["MM-first", "SM-second"]);
|
||||
expect(result?.receipt.parts.map((part) => part.kind)).toEqual(["media", "text"]);
|
||||
});
|
||||
|
||||
it("hosts durable MMS media in the lifecycle before platform send starts", async () => {
|
||||
const events: string[] = [];
|
||||
hostedMediaMocks.prepare.mockImplementationOnce(async () => {
|
||||
events.push("prepare");
|
||||
return {
|
||||
url: "https://gateway.example.com/webhooks/sms/media/abc?token=token",
|
||||
cleanup: hostedMediaMocks.cleanup,
|
||||
};
|
||||
});
|
||||
sendSmsViaTwilio.mockImplementationOnce(async ({ to, onPlatformSendDispatch }) => {
|
||||
await onPlatformSendDispatch?.();
|
||||
events.push("send");
|
||||
return { sid: "MM-first", to };
|
||||
});
|
||||
const ctx = {
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accountSid: "AC123",
|
||||
authToken: "secret",
|
||||
fromNumber: "+15557654321",
|
||||
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
|
||||
},
|
||||
},
|
||||
},
|
||||
to: "+15551234567",
|
||||
text: "caption",
|
||||
kind: "media" as const,
|
||||
mediaUrl: "/tmp/photo.jpg",
|
||||
mediaLocalRoots: ["/tmp"],
|
||||
onPlatformSendDispatch: async () => {
|
||||
events.push("dispatch");
|
||||
},
|
||||
};
|
||||
|
||||
await smsPlugin.message?.send?.lifecycle?.beforeSendAttempt?.(ctx);
|
||||
events.push("platform-start");
|
||||
await smsPlugin.message?.send?.media?.(ctx);
|
||||
|
||||
expect(hostedMediaMocks.prepare).toHaveBeenCalledOnce();
|
||||
expect(events).toEqual(["prepare", "platform-start", "dispatch", "send"]);
|
||||
await expect(smsPlugin.message?.send?.media?.(ctx)).rejects.toThrow(
|
||||
"SMS message lifecycle did not prepare the MMS attachment.",
|
||||
);
|
||||
});
|
||||
|
||||
it("discards staged MMS media when the durable dispatch marker fails", async () => {
|
||||
const ctx = {
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accountSid: "AC123",
|
||||
authToken: "secret",
|
||||
fromNumber: "+15557654321",
|
||||
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
|
||||
},
|
||||
},
|
||||
},
|
||||
to: "+15551234567",
|
||||
text: "caption",
|
||||
kind: "media" as const,
|
||||
mediaUrl: "/tmp/photo.jpg",
|
||||
onPlatformSendDispatch: async () => {
|
||||
throw new Error("delivery marker failed");
|
||||
},
|
||||
};
|
||||
const lifecycle = smsPlugin.message?.send?.lifecycle;
|
||||
const attemptToken = await lifecycle?.beforeSendAttempt?.(ctx);
|
||||
let observed: unknown;
|
||||
try {
|
||||
await smsPlugin.message?.send?.media?.(ctx);
|
||||
} catch (error) {
|
||||
observed = error;
|
||||
}
|
||||
|
||||
expect(observed).toBeInstanceOf(PlatformMessageNotDispatchedError);
|
||||
await lifecycle?.afterSendFailure?.({
|
||||
...ctx,
|
||||
error: observed,
|
||||
attemptToken,
|
||||
});
|
||||
|
||||
expect(hostedMediaMocks.cleanup).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("discards staged MMS media when core fails before entering the adapter", async () => {
|
||||
const ctx = {
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accountSid: "AC123",
|
||||
authToken: "secret",
|
||||
fromNumber: "+15557654321",
|
||||
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
|
||||
},
|
||||
},
|
||||
},
|
||||
to: "+15551234567",
|
||||
text: "caption",
|
||||
kind: "media" as const,
|
||||
mediaUrl: "/tmp/photo.jpg",
|
||||
};
|
||||
const lifecycle = smsPlugin.message?.send?.lifecycle;
|
||||
const attemptToken = await lifecycle?.beforeSendAttempt?.(ctx);
|
||||
|
||||
await lifecycle?.afterSendFailure?.({
|
||||
...ctx,
|
||||
error: new Error("queue state rejected before adapter dispatch"),
|
||||
attemptToken,
|
||||
});
|
||||
|
||||
expect(hostedMediaMocks.cleanup).toHaveBeenCalledOnce();
|
||||
expect(sendSmsViaTwilio).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains staged MMS media after an ambiguous Twilio failure", async () => {
|
||||
const failure = new Error("Twilio response was lost");
|
||||
sendSmsViaTwilio.mockImplementationOnce(async ({ onPlatformSendDispatch }) => {
|
||||
await onPlatformSendDispatch?.();
|
||||
throw failure;
|
||||
});
|
||||
const ctx = {
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accountSid: "AC123",
|
||||
authToken: "secret",
|
||||
fromNumber: "+15557654321",
|
||||
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
|
||||
},
|
||||
},
|
||||
},
|
||||
to: "+15551234567",
|
||||
text: "caption",
|
||||
kind: "media" as const,
|
||||
mediaUrl: "/tmp/photo.jpg",
|
||||
onPlatformSendDispatch: async () => undefined,
|
||||
};
|
||||
const lifecycle = smsPlugin.message?.send?.lifecycle;
|
||||
const attemptToken = await lifecycle?.beforeSendAttempt?.(ctx);
|
||||
let observed: unknown;
|
||||
try {
|
||||
await smsPlugin.message?.send?.media?.(ctx);
|
||||
} catch (error) {
|
||||
observed = error;
|
||||
}
|
||||
|
||||
expect(observed).toBe(failure);
|
||||
await lifecycle?.afterSendFailure?.({
|
||||
...ctx,
|
||||
error: observed,
|
||||
attemptToken,
|
||||
});
|
||||
|
||||
expect(hostedMediaMocks.cleanup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains staged MMS media after a partial delivery", async () => {
|
||||
sendSmsViaTwilio
|
||||
.mockImplementationOnce(async ({ to, onPlatformSendDispatch }) => {
|
||||
await onPlatformSendDispatch?.();
|
||||
return { sid: "MM-first", to };
|
||||
})
|
||||
.mockRejectedValueOnce(
|
||||
new PlatformMessageNotDispatchedError("second chunk rejected before dispatch", {
|
||||
cause: new Error("provider rejected chunk"),
|
||||
}),
|
||||
);
|
||||
const ctx = {
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accountSid: "AC123",
|
||||
authToken: "secret",
|
||||
fromNumber: "+15557654321",
|
||||
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
|
||||
textChunkLimit: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
to: "+15551234567",
|
||||
text: "alpha beta",
|
||||
kind: "media" as const,
|
||||
mediaUrl: "/tmp/photo.jpg",
|
||||
onPlatformSendDispatch: async () => undefined,
|
||||
};
|
||||
const lifecycle = smsPlugin.message?.send?.lifecycle;
|
||||
const attemptToken = await lifecycle?.beforeSendAttempt?.(ctx);
|
||||
let observed: unknown;
|
||||
try {
|
||||
await smsPlugin.message?.send?.media?.(ctx);
|
||||
} catch (error) {
|
||||
observed = error;
|
||||
}
|
||||
|
||||
expect(isChannelPartialDeliveryError(observed)).toBe(true);
|
||||
await lifecycle?.afterSendFailure?.({
|
||||
...ctx,
|
||||
error: observed,
|
||||
attemptToken,
|
||||
});
|
||||
|
||||
expect(hostedMediaMocks.cleanup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports an accepted text chunk before a later durable send fails", async () => {
|
||||
const failure = new Error("second text chunk failed");
|
||||
const events: string[] = [];
|
||||
sendSmsViaTwilio
|
||||
.mockImplementationOnce(async ({ onPlatformSendDispatch }) => {
|
||||
await onPlatformSendDispatch?.();
|
||||
events.push("send:first");
|
||||
return { sid: "SM-first", to: "+15551234567" };
|
||||
})
|
||||
.mockImplementationOnce(async ({ onPlatformSendDispatch }) => {
|
||||
await onPlatformSendDispatch?.();
|
||||
events.push("send:second");
|
||||
throw failure;
|
||||
});
|
||||
const onDeliveryResult = vi.fn(async (result) => {
|
||||
events.push(`delivery:${result.messageId}`);
|
||||
});
|
||||
const onPlatformSendDispatch = vi.fn(async () => {
|
||||
events.push("dispatch");
|
||||
});
|
||||
|
||||
let observed: unknown;
|
||||
try {
|
||||
await smsPlugin.message?.send?.text?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accountSid: "AC123",
|
||||
authToken: "secret",
|
||||
fromNumber: "+15557654321",
|
||||
textChunkLimit: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
to: "+15551234567",
|
||||
text: "alpha beta",
|
||||
onPlatformSendDispatch,
|
||||
onDeliveryResult,
|
||||
});
|
||||
} catch (error) {
|
||||
observed = error;
|
||||
}
|
||||
|
||||
expect(isChannelPartialDeliveryError(observed)).toBe(true);
|
||||
expect(onDeliveryResult).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({
|
||||
messageId: "SM-first",
|
||||
receipt: expect.objectContaining({
|
||||
platformMessageIds: ["SM-first"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(onPlatformSendDispatch).toHaveBeenCalledTimes(2);
|
||||
expect(events).toEqual([
|
||||
"dispatch",
|
||||
"send:first",
|
||||
"delivery:SM-first",
|
||||
"dispatch",
|
||||
"send:second",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+136
-25
@@ -13,7 +13,6 @@ import {
|
||||
} from "openclaw/plugin-sdk/channel-core";
|
||||
import {
|
||||
createAccountStatusSink,
|
||||
createMessageReceiptFromOutboundResults,
|
||||
defineChannelMessageAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { createConditionalWarningCollector } from "openclaw/plugin-sdk/channel-policy";
|
||||
@@ -26,7 +25,7 @@ import {
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { isRecord, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
import {
|
||||
inspectSmsAccount,
|
||||
@@ -44,7 +43,14 @@ import {
|
||||
normalizeSmsPhoneNumber,
|
||||
} from "./phone.js";
|
||||
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
|
||||
import { sendSmsTextChunks, toSmsPlainText } from "./send.js";
|
||||
import {
|
||||
createSmsMessageReceipt,
|
||||
prepareSmsMediaAttempt,
|
||||
sendPreparedSmsMediaAttempt,
|
||||
sendSmsTextChunks,
|
||||
toSmsPlainText,
|
||||
type PreparedSmsMediaAttempt,
|
||||
} from "./send.js";
|
||||
import { formatSmsProbeLines, probeSmsAccount, type SmsProbe } from "./status.js";
|
||||
import type { ResolvedSmsAccount } from "./types.js";
|
||||
|
||||
@@ -192,31 +198,18 @@ const smsSetupContract = defineChannelSetupContract({
|
||||
|
||||
function createSmsReceipt(params: {
|
||||
results: Array<{ sid: string; to: string; from?: string; status?: string }>;
|
||||
kind: "text";
|
||||
kind: "text" | "media";
|
||||
}) {
|
||||
const first = params.results[0];
|
||||
if (!first) {
|
||||
throw new Error("SMS send did not return a Twilio Message SID.");
|
||||
}
|
||||
const receipt = createSmsMessageReceipt(params);
|
||||
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,
|
||||
}),
|
||||
receipt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -235,27 +228,145 @@ async function sendSmsText(ctx: {
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
text: string;
|
||||
onPlatformSendDispatch?: Parameters<typeof sendSmsTextChunks>[0]["onPlatformSendDispatch"];
|
||||
onDeliveryResult?: Parameters<typeof sendSmsTextChunks>[0]["onDeliveryResult"];
|
||||
}) {
|
||||
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 });
|
||||
const results = await sendSmsTextChunks({
|
||||
account,
|
||||
to,
|
||||
text: ctx.text,
|
||||
onPlatformSendDispatch: ctx.onPlatformSendDispatch,
|
||||
onDeliveryResult: ctx.onDeliveryResult,
|
||||
});
|
||||
return createSmsReceipt({ results, kind: "text" });
|
||||
}
|
||||
|
||||
type SmsAttachmentContext = {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
text: string;
|
||||
mediaUrl: string;
|
||||
mediaAccess?: Parameters<typeof prepareSmsMediaAttempt>[0]["mediaAccess"];
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
||||
onPlatformSendDispatch?: Parameters<
|
||||
typeof sendPreparedSmsMediaAttempt
|
||||
>[0]["onPlatformSendDispatch"];
|
||||
onDeliveryResult?: Parameters<typeof sendPreparedSmsMediaAttempt>[0]["onDeliveryResult"];
|
||||
};
|
||||
|
||||
type PreparedSmsAttachmentAttempt = {
|
||||
account: ResolvedSmsAccount;
|
||||
to: string;
|
||||
attempt: PreparedSmsMediaAttempt;
|
||||
platformDispatchStarted: boolean;
|
||||
};
|
||||
|
||||
// Core passes the same context object through lifecycle preparation and send.
|
||||
// Object identity keeps hosted bearer URLs scoped to exactly one MMS attempt.
|
||||
const preparedSmsAttachmentAttempts = new WeakMap<object, Promise<PreparedSmsAttachmentAttempt>>();
|
||||
|
||||
function resolveSmsAttachmentAttemptToken(
|
||||
attemptToken: unknown,
|
||||
): Pick<PreparedSmsAttachmentAttempt, "attempt" | "platformDispatchStarted"> | undefined {
|
||||
if (
|
||||
!isRecord(attemptToken) ||
|
||||
typeof attemptToken.platformDispatchStarted !== "boolean" ||
|
||||
!isRecord(attemptToken.attempt) ||
|
||||
typeof attemptToken.attempt.cleanupHostedMedia !== "function"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return attemptToken as Pick<PreparedSmsAttachmentAttempt, "attempt" | "platformDispatchStarted">;
|
||||
}
|
||||
|
||||
async function prepareSmsAttachmentAttempt(
|
||||
ctx: SmsAttachmentContext,
|
||||
): Promise<PreparedSmsAttachmentAttempt> {
|
||||
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 attempt = await prepareSmsMediaAttempt({
|
||||
account,
|
||||
text: ctx.text,
|
||||
mediaUrl: ctx.mediaUrl,
|
||||
mediaAccess: ctx.mediaAccess,
|
||||
mediaLocalRoots: ctx.mediaLocalRoots,
|
||||
mediaReadFile: ctx.mediaReadFile,
|
||||
});
|
||||
return { account, to, attempt, platformDispatchStarted: false };
|
||||
}
|
||||
|
||||
function getOrPrepareSmsAttachmentAttempt(
|
||||
ctx: SmsAttachmentContext,
|
||||
): Promise<PreparedSmsAttachmentAttempt> {
|
||||
const existing = preparedSmsAttachmentAttempts.get(ctx);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const created = prepareSmsAttachmentAttempt(ctx);
|
||||
preparedSmsAttachmentAttempts.set(ctx, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function sendPreparedSmsAttachment(ctx: SmsAttachmentContext) {
|
||||
const preparation = preparedSmsAttachmentAttempts.get(ctx);
|
||||
preparedSmsAttachmentAttempts.delete(ctx);
|
||||
if (!preparation) {
|
||||
throw new Error("SMS message lifecycle did not prepare the MMS attachment.");
|
||||
}
|
||||
const prepared = await preparation;
|
||||
const results = await sendPreparedSmsMediaAttempt({
|
||||
...prepared,
|
||||
onPlatformSendDispatch: async () => {
|
||||
await ctx.onPlatformSendDispatch?.();
|
||||
prepared.platformDispatchStarted = true;
|
||||
},
|
||||
onDeliveryResult: ctx.onDeliveryResult,
|
||||
});
|
||||
return createSmsReceipt({ results, kind: "media" });
|
||||
}
|
||||
|
||||
const smsMessageAdapter = defineChannelMessageAdapter({
|
||||
id: CHANNEL_ID,
|
||||
durableFinal: {
|
||||
capabilities: {
|
||||
text: true,
|
||||
media: false,
|
||||
media: true,
|
||||
messageSendingHooks: true,
|
||||
},
|
||||
},
|
||||
send: {
|
||||
lifecycle: {
|
||||
beforeSendAttempt: async (ctx) => {
|
||||
if (ctx.kind !== "media") {
|
||||
return undefined;
|
||||
}
|
||||
return await getOrPrepareSmsAttachmentAttempt(ctx);
|
||||
},
|
||||
afterSendFailure: async (ctx) => {
|
||||
if (ctx.kind !== "media") {
|
||||
return;
|
||||
}
|
||||
const attemptToken = resolveSmsAttachmentAttemptToken(ctx.attemptToken);
|
||||
// Core can fail after staging but before the adapter starts. Discard only
|
||||
// while the attempt still proves Twilio's HTTP boundary was never crossed.
|
||||
if (!attemptToken || attemptToken.platformDispatchStarted) {
|
||||
return;
|
||||
}
|
||||
await attemptToken.attempt.cleanupHostedMedia();
|
||||
},
|
||||
},
|
||||
text: async (ctx) => await sendSmsText(ctx),
|
||||
media: async (ctx) => await sendPreparedSmsAttachment(ctx),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -284,15 +395,15 @@ export const smsPlugin: ChannelPlugin<ResolvedSmsAccount, SmsProbe> = createChat
|
||||
id: CHANNEL_ID,
|
||||
label: "SMS",
|
||||
selectionLabel: "SMS (Twilio)",
|
||||
detailLabel: "Twilio SMS",
|
||||
detailLabel: "Twilio SMS/MMS",
|
||||
docsPath: "/channels/sms",
|
||||
docsLabel: "sms",
|
||||
blurb: "Twilio-backed SMS with inbound webhooks and outbound replies.",
|
||||
blurb: "Twilio-backed SMS/MMS with inbound webhooks and outbound replies.",
|
||||
order: 88,
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct"],
|
||||
media: false,
|
||||
media: true,
|
||||
threads: false,
|
||||
reactions: false,
|
||||
edit: false,
|
||||
@@ -375,7 +486,7 @@ export const smsPlugin: ChannelPlugin<ResolvedSmsAccount, SmsProbe> = createChat
|
||||
messageToolHints: () => [
|
||||
"",
|
||||
"### SMS Formatting",
|
||||
"SMS is plain text only. Keep replies brief, avoid markdown tables, and split long details into short messages.",
|
||||
"SMS text is plain text. MMS attachments are supported; keep captions brief and avoid markdown tables.",
|
||||
],
|
||||
},
|
||||
message: smsMessageAdapter,
|
||||
|
||||
@@ -48,7 +48,7 @@ export const SmsChannelConfigSchema = buildChannelConfigSchema(SmsConfigSchema,
|
||||
uiHints: {
|
||||
"": {
|
||||
label: "SMS",
|
||||
help: "Twilio SMS channel configuration for inbound webhooks and outbound text replies.",
|
||||
help: "Twilio SMS/MMS channel configuration for inbound webhooks and outbound replies.",
|
||||
},
|
||||
accountSid: {
|
||||
label: "Twilio Account SID",
|
||||
@@ -60,7 +60,7 @@ export const SmsChannelConfigSchema = buildChannelConfigSchema(SmsConfigSchema,
|
||||
},
|
||||
fromNumber: {
|
||||
label: "SMS From Number",
|
||||
help: "Twilio SMS-capable phone number in E.164 format, for example +15551234567.",
|
||||
help: "Twilio SMS-capable phone number in E.164 format; outbound attachments also require MMS capability.",
|
||||
presentation: "phone-number",
|
||||
},
|
||||
messagingServiceSid: {
|
||||
@@ -74,7 +74,7 @@ export const SmsChannelConfigSchema = buildChannelConfigSchema(SmsConfigSchema,
|
||||
},
|
||||
publicWebhookUrl: {
|
||||
label: "SMS Public Webhook URL",
|
||||
help: "Public URL configured in Twilio for incoming messages. Must match Twilio's signed URL exactly.",
|
||||
help: "Public URL configured in Twilio for incoming messages. Must match Twilio's signed URL exactly; outbound MMS also requires this same path to be reachable over HTTPS.",
|
||||
},
|
||||
webhookPath: {
|
||||
label: "SMS Webhook Path",
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
// Sms tests cover gateway plugin behavior.
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { registerPluginHttpRoute as registerPluginHttpRouteType } from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { startSmsGatewayAccount } from "./gateway.js";
|
||||
import type { SmsChannelRuntime } from "./inbound.js";
|
||||
import type { ResolvedSmsAccount } from "./types.js";
|
||||
|
||||
const smsWebhookHandler = vi.hoisted(() => vi.fn(async (_req: unknown, _res: unknown) => true));
|
||||
const createSmsWebhookHandler = vi.hoisted(() => vi.fn((_params: unknown) => smsWebhookHandler));
|
||||
const tryHandleHostedSmsMediaRequest = vi.hoisted(() =>
|
||||
vi.fn(async (_req: unknown, _res: unknown, _accountId: string) => true),
|
||||
);
|
||||
const startSmsIngress = vi.hoisted(() => vi.fn());
|
||||
const pauseSmsIngress = vi.hoisted(() => vi.fn<() => Promise<void>>(async () => {}));
|
||||
const stopSmsIngress = vi.hoisted(() => vi.fn<() => Promise<void>>(async () => {}));
|
||||
@@ -23,7 +30,7 @@ const { registeredRoutes, routeUnregisters, registerPluginHttpRoute, waitUntilAb
|
||||
return {
|
||||
registeredRoutes: routeCleanups,
|
||||
routeUnregisters: unregisters,
|
||||
registerPluginHttpRoute: vi.fn(() => {
|
||||
registerPluginHttpRoute: vi.fn<typeof registerPluginHttpRouteType>(() => {
|
||||
const unregister = vi.fn();
|
||||
unregisters.push(unregister);
|
||||
return unregister;
|
||||
@@ -40,6 +47,8 @@ const { registeredRoutes, routeUnregisters, registerPluginHttpRoute, waitUntilAb
|
||||
vi.mock("openclaw/plugin-sdk/channel-outbound", () => ({ waitUntilAbort }));
|
||||
|
||||
vi.mock("./ingress-spool.js", () => ({ createSmsIngressSpool }));
|
||||
vi.mock("./media.js", () => ({ tryHandleHostedSmsMediaRequest }));
|
||||
vi.mock("./webhook.js", () => ({ createSmsWebhookHandler }));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/webhook-ingress", () => ({
|
||||
createFixedWindowRateLimiter: () => ({
|
||||
@@ -77,6 +86,9 @@ describe("startSmsGatewayAccount", () => {
|
||||
startSmsIngress.mockClear();
|
||||
pauseSmsIngress.mockClear();
|
||||
stopSmsIngress.mockClear();
|
||||
createSmsWebhookHandler.mockClear();
|
||||
smsWebhookHandler.mockClear();
|
||||
tryHandleHostedSmsMediaRequest.mockClear();
|
||||
routeUnregisters.length = 0;
|
||||
});
|
||||
|
||||
@@ -213,6 +225,100 @@ describe("startSmsGatewayAccount", () => {
|
||||
expect(registerPluginHttpRoute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("fails startup when the shared route registry rejects the route", async () => {
|
||||
registerPluginHttpRoute.mockImplementationOnce(() => {
|
||||
throw new Error("plugin: route conflict at /webhooks/sms (exact)");
|
||||
});
|
||||
|
||||
await expect(
|
||||
startRoute({
|
||||
cfg: {},
|
||||
account: createAccount("default"),
|
||||
channelRuntime: {} as SmsChannelRuntime,
|
||||
}),
|
||||
).rejects.toThrow("plugin: route conflict");
|
||||
|
||||
expect(registerPluginHttpRoute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ throwOnFailure: true }),
|
||||
);
|
||||
expect(startSmsIngress).not.toHaveBeenCalled();
|
||||
expect(stopSmsIngress).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("serves hosted media and Twilio callbacks from one exact route", async () => {
|
||||
await startRoute({
|
||||
cfg: {},
|
||||
account: createAccount("default"),
|
||||
channelRuntime: {} as SmsChannelRuntime,
|
||||
});
|
||||
|
||||
type RegisteredRoute = {
|
||||
path?: string;
|
||||
match?: string;
|
||||
handler: (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => Promise<boolean | void> | boolean | void;
|
||||
};
|
||||
const route = registerPluginHttpRoute.mock.calls[0]?.[0] as RegisteredRoute | undefined;
|
||||
expect(route).toMatchObject({ path: "/webhooks/sms" });
|
||||
expect(route?.match).toBeUndefined();
|
||||
if (!route) {
|
||||
throw new Error("SMS route was not registered");
|
||||
}
|
||||
|
||||
const getReq = { method: "GET" } as IncomingMessage;
|
||||
const getRes = {} as ServerResponse;
|
||||
await route.handler(getReq, getRes);
|
||||
expect(tryHandleHostedSmsMediaRequest).toHaveBeenCalledWith(getReq, getRes, "default");
|
||||
expect(smsWebhookHandler).not.toHaveBeenCalled();
|
||||
|
||||
const headReq = { method: "HEAD" } as IncomingMessage;
|
||||
const headRes = {} as ServerResponse;
|
||||
await route.handler(headReq, headRes);
|
||||
expect(tryHandleHostedSmsMediaRequest).toHaveBeenCalledWith(headReq, headRes, "default");
|
||||
expect(smsWebhookHandler).not.toHaveBeenCalled();
|
||||
|
||||
tryHandleHostedSmsMediaRequest.mockResolvedValueOnce(false);
|
||||
const postReq = { method: "POST" } as IncomingMessage;
|
||||
const postRes = {} as ServerResponse;
|
||||
await route.handler(postReq, postRes);
|
||||
expect(smsWebhookHandler).toHaveBeenCalledWith(postReq, postRes);
|
||||
expect(tryHandleHostedSmsMediaRequest).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("falls through tokenless reads but keeps token-bearing non-GET media requests isolated", async () => {
|
||||
await startRoute({
|
||||
cfg: {},
|
||||
account: createAccount("default"),
|
||||
channelRuntime: {} as SmsChannelRuntime,
|
||||
});
|
||||
type RegisteredRoute = {
|
||||
handler: (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => Promise<boolean | void> | boolean | void;
|
||||
};
|
||||
const route = registerPluginHttpRoute.mock.calls[0]?.[0] as RegisteredRoute | undefined;
|
||||
if (!route) {
|
||||
throw new Error("SMS route was not registered");
|
||||
}
|
||||
|
||||
tryHandleHostedSmsMediaRequest.mockResolvedValueOnce(false);
|
||||
const tokenlessGet = { method: "GET", url: "/webhooks/sms" } as IncomingMessage;
|
||||
const getRes = {} as ServerResponse;
|
||||
await route.handler(tokenlessGet, getRes);
|
||||
expect(smsWebhookHandler).toHaveBeenCalledWith(tokenlessGet, getRes);
|
||||
|
||||
tryHandleHostedSmsMediaRequest.mockResolvedValueOnce(true);
|
||||
const tokenizedPost = {
|
||||
method: "POST",
|
||||
url: `/webhooks/sms?__openclaw_mms_token_${"a".repeat(24)}=secret`,
|
||||
} as IncomingMessage;
|
||||
await route.handler(tokenizedPost, {} as ServerResponse);
|
||||
expect(smsWebhookHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("serializes overlapping replacements of the same webhook route", async () => {
|
||||
let releaseStop: (() => void) | undefined;
|
||||
stopSmsIngress.mockImplementationOnce(
|
||||
|
||||
@@ -108,14 +108,21 @@ async function registerSmsWebhookRoute(params: {
|
||||
});
|
||||
let unregisterRoute: () => void;
|
||||
try {
|
||||
const webhookHandler = createSmsWebhookHandler({ ...params, ingress });
|
||||
unregisterRoute = registerPluginHttpRoute({
|
||||
path: webhookPath,
|
||||
auth: "plugin",
|
||||
pluginId: CHANNEL_ID,
|
||||
accountId: params.account.accountId,
|
||||
log: (msg) => params.log?.info?.(msg),
|
||||
throwOnFailure: true,
|
||||
handler: createSmsWebhookHandler({ ...params, ingress }),
|
||||
log: (msg) => params.log?.info?.(msg),
|
||||
handler: async (req, res) => {
|
||||
const { tryHandleHostedSmsMediaRequest } = await import("./media.js");
|
||||
if (await tryHandleHostedSmsMediaRequest(req, res, params.account.accountId)) {
|
||||
return true;
|
||||
}
|
||||
return await webhookHandler(req, res);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await Promise.allSettled([predecessorStop, ingress.stop()]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Sms tests cover inbound plugin behavior.
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { unlinkIfExists as unlinkIfExistsType } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { beforeEach, 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";
|
||||
@@ -8,10 +9,21 @@ import type { ResolvedSmsAccount } from "./types.js";
|
||||
const sendSmsViaTwilio = vi.hoisted(() =>
|
||||
vi.fn<typeof sendSmsViaTwilioType>(async () => ({ sid: "SM-pair", to: "+15551234567" })),
|
||||
);
|
||||
const unlinkIfExistsMock = vi.hoisted(() =>
|
||||
vi.fn<typeof unlinkIfExistsType>(async () => undefined),
|
||||
);
|
||||
|
||||
vi.mock("./twilio.js", () => ({
|
||||
vi.mock("./twilio.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./twilio.js")>()),
|
||||
sendSmsViaTwilio,
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
|
||||
unlinkIfExists: unlinkIfExistsMock,
|
||||
}));
|
||||
|
||||
type SmsTurnAdoptionLifecycle = NonNullable<
|
||||
Parameters<SmsChannelRuntime["inbound"]["run"]>[0]["turnAdoptionLifecycle"]
|
||||
>;
|
||||
|
||||
function createAccount(overrides: Partial<ResolvedSmsAccount> = {}): ResolvedSmsAccount {
|
||||
return {
|
||||
@@ -40,7 +52,7 @@ function createRuntime() {
|
||||
const shouldComputeCommandAuthorized = vi.fn((body: string) => body.trim().startsWith("/"));
|
||||
const run = vi.fn<
|
||||
(params: {
|
||||
turnAdoptionLifecycle?: { onAdopted: () => void | Promise<void> };
|
||||
turnAdoptionLifecycle?: SmsTurnAdoptionLifecycle;
|
||||
adapter: {
|
||||
ingest: (msg: {
|
||||
from: string;
|
||||
@@ -53,10 +65,16 @@ function createRuntime() {
|
||||
ingested: unknown,
|
||||
) => Promise<{ route: { agentId: string; sessionKey: string } }>;
|
||||
};
|
||||
}) => void
|
||||
>();
|
||||
}) => Promise<void>
|
||||
>(async () => undefined);
|
||||
const buildContext = vi.fn();
|
||||
const resolveStorePath = vi.fn();
|
||||
const saveRemoteMedia = vi.fn(async () => ({
|
||||
id: "media-1",
|
||||
path: "/tmp/mms-1.jpg",
|
||||
size: 128,
|
||||
contentType: "image/jpeg",
|
||||
}));
|
||||
const runtime = {
|
||||
commands: {
|
||||
isControlCommandMessage,
|
||||
@@ -73,6 +91,9 @@ function createRuntime() {
|
||||
run,
|
||||
buildContext,
|
||||
},
|
||||
media: {
|
||||
saveRemoteMedia,
|
||||
},
|
||||
session: {
|
||||
resolveStorePath,
|
||||
recordInboundSession: vi.fn(),
|
||||
@@ -91,6 +112,7 @@ function createRuntime() {
|
||||
run,
|
||||
buildContext,
|
||||
resolveStorePath,
|
||||
saveRemoteMedia,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,6 +148,7 @@ async function resolveAuthorizedSmsTurn(params: {
|
||||
body: params.body,
|
||||
messageSid: params.messageSid,
|
||||
accountSid: "AC123",
|
||||
media: [],
|
||||
};
|
||||
await dispatchSmsInboundEvent({
|
||||
cfg: {},
|
||||
@@ -144,8 +167,13 @@ async function resolveAuthorizedSmsTurn(params: {
|
||||
}
|
||||
|
||||
describe("dispatchSmsInboundEvent", () => {
|
||||
beforeEach(() => {
|
||||
unlinkIfExistsMock.mockClear();
|
||||
});
|
||||
|
||||
it("creates and sends a pairing challenge for first-time SMS senders", async () => {
|
||||
const { runtime, readAllowFromStore, upsertPairingRequest } = createRuntime();
|
||||
const { runtime, readAllowFromStore, run, saveRemoteMedia, upsertPairingRequest } =
|
||||
createRuntime();
|
||||
|
||||
await dispatchSmsInboundEvent({
|
||||
cfg: {},
|
||||
@@ -158,6 +186,12 @@ describe("dispatchSmsInboundEvent", () => {
|
||||
body: "hello",
|
||||
messageSid: "SM-inbound",
|
||||
accountSid: "AC123",
|
||||
media: [
|
||||
{
|
||||
url: `https://api.twilio.com/2010-04-01/Accounts/AC123/Messages/SM-inbound/Media/ME${"a".repeat(32)}`,
|
||||
contentType: "image/jpeg",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -178,6 +212,8 @@ describe("dispatchSmsInboundEvent", () => {
|
||||
text: expect.stringContaining("PAIR123"),
|
||||
}),
|
||||
);
|
||||
expect(saveRemoteMedia).not.toHaveBeenCalled();
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the canonical routed session key for authorized SMS turns", async () => {
|
||||
@@ -211,6 +247,257 @@ describe("dispatchSmsInboundEvent", () => {
|
||||
expect(turn.route.sessionKey).toBe(SMS_SESSION_KEY);
|
||||
});
|
||||
|
||||
it("downloads authorized MMS media with Twilio auth and exposes media facts", async () => {
|
||||
const mocks = createRuntime();
|
||||
mocks.resolveAgentRoute.mockReturnValue({
|
||||
agentId: "main",
|
||||
accountId: "default",
|
||||
sessionKey: SMS_SESSION_KEY,
|
||||
});
|
||||
mocks.buildContext.mockReturnValue({ SessionKey: SMS_SESSION_KEY });
|
||||
const msg = {
|
||||
from: SMS_FROM,
|
||||
to: SMS_TO,
|
||||
body: "",
|
||||
messageSid: "MM-inbound",
|
||||
accountSid: "AC123",
|
||||
media: [
|
||||
{
|
||||
url: `https://api.twilio.com/2010-04-01/Accounts/AC123/Messages/MM-inbound/Media/ME${"1".repeat(32)}`,
|
||||
contentType: "image/jpeg",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await dispatchSmsInboundEvent({
|
||||
cfg: {},
|
||||
account: createAccount({ dmPolicy: "allowlist", allowFrom: [SMS_FROM] }),
|
||||
channelRuntime: mocks.runtime,
|
||||
receivedAt: 1_700_000_000_123,
|
||||
msg,
|
||||
});
|
||||
|
||||
expect(mocks.saveRemoteMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: msg.media[0]?.url,
|
||||
maxBytes: 5 * 1024 * 1024,
|
||||
ssrfPolicy: { hostnameAllowlist: ["api.twilio.com"] },
|
||||
timeoutMs: 60_000,
|
||||
retry: {
|
||||
attempts: 2,
|
||||
minDelayMs: 500,
|
||||
maxDelayMs: 2_000,
|
||||
jitter: 0.2,
|
||||
},
|
||||
requestInit: {
|
||||
headers: {
|
||||
authorization: `Basic ${Buffer.from("AC123:secret").toString("base64")}`,
|
||||
},
|
||||
signal: expect.any(AbortSignal),
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(unlinkIfExistsMock).toHaveBeenCalledOnce();
|
||||
expect(unlinkIfExistsMock).toHaveBeenCalledWith("/tmp/mms-1.jpg");
|
||||
const runParams = expectDefined(mocks.run.mock.calls[0]?.[0], "SMS inbound run parameters");
|
||||
await runParams.adapter.resolveTurn(runParams.adapter.ingest(msg));
|
||||
expect(mocks.buildContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.objectContaining({ bodyForAgent: "" }),
|
||||
media: [
|
||||
expect.objectContaining({
|
||||
path: "/tmp/mms-1.jpg",
|
||||
contentType: "image/jpeg",
|
||||
messageId: "MM-inbound",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("cleans materialized MMS files when inbound.run fails before adoption", async () => {
|
||||
const mocks = createRuntime();
|
||||
const runError = new Error("inbound dispatch failed");
|
||||
mocks.resolveAgentRoute.mockReturnValue({
|
||||
agentId: "main",
|
||||
accountId: "default",
|
||||
sessionKey: SMS_SESSION_KEY,
|
||||
});
|
||||
mocks.run.mockRejectedValueOnce(runError);
|
||||
|
||||
await expect(
|
||||
dispatchSmsInboundEvent({
|
||||
cfg: {},
|
||||
account: createAccount({ dmPolicy: "allowlist", allowFrom: [SMS_FROM] }),
|
||||
channelRuntime: mocks.runtime,
|
||||
receivedAt: 1_700_000_000_123,
|
||||
turnAdoptionLifecycle: {
|
||||
onAdopted: vi.fn(async () => undefined),
|
||||
onDeferred: vi.fn(),
|
||||
onAbandoned: vi.fn(),
|
||||
},
|
||||
msg: {
|
||||
from: SMS_FROM,
|
||||
to: SMS_TO,
|
||||
body: "",
|
||||
messageSid: "MM-run-failure",
|
||||
accountSid: "AC123",
|
||||
media: [
|
||||
{
|
||||
url: `https://api.twilio.com/2010-04-01/Accounts/AC123/Messages/MM-run-failure/Media/ME${"1".repeat(32)}`,
|
||||
contentType: "image/jpeg",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).rejects.toBe(runError);
|
||||
|
||||
expect(unlinkIfExistsMock).toHaveBeenCalledOnce();
|
||||
expect(unlinkIfExistsMock).toHaveBeenCalledWith("/tmp/mms-1.jpg");
|
||||
});
|
||||
|
||||
it("retains deferred MMS files until the turn is abandoned", async () => {
|
||||
const mocks = createRuntime();
|
||||
const events: string[] = [];
|
||||
unlinkIfExistsMock.mockImplementationOnce(async () => {
|
||||
events.push("cleanup");
|
||||
});
|
||||
const originalLifecycle: SmsTurnAdoptionLifecycle = {
|
||||
onAdopted: vi.fn(async () => undefined),
|
||||
onDeferred: vi.fn(),
|
||||
onAbandoned: vi.fn(() => {
|
||||
events.push("abandon");
|
||||
}),
|
||||
};
|
||||
let wrappedLifecycle: SmsTurnAdoptionLifecycle | undefined;
|
||||
mocks.resolveAgentRoute.mockReturnValue({
|
||||
agentId: "main",
|
||||
accountId: "default",
|
||||
sessionKey: SMS_SESSION_KEY,
|
||||
});
|
||||
mocks.run.mockImplementationOnce(async (runParams) => {
|
||||
wrappedLifecycle = runParams.turnAdoptionLifecycle;
|
||||
wrappedLifecycle?.onDeferred?.();
|
||||
});
|
||||
|
||||
await dispatchSmsInboundEvent({
|
||||
cfg: {},
|
||||
account: createAccount({ dmPolicy: "allowlist", allowFrom: [SMS_FROM] }),
|
||||
channelRuntime: mocks.runtime,
|
||||
receivedAt: 1_700_000_000_123,
|
||||
turnAdoptionLifecycle: originalLifecycle,
|
||||
msg: {
|
||||
from: SMS_FROM,
|
||||
to: SMS_TO,
|
||||
body: "",
|
||||
messageSid: "MM-deferred",
|
||||
accountSid: "AC123",
|
||||
media: [
|
||||
{
|
||||
url: `https://api.twilio.com/2010-04-01/Accounts/AC123/Messages/MM-deferred/Media/ME${"1".repeat(32)}`,
|
||||
contentType: "image/jpeg",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(originalLifecycle.onDeferred).toHaveBeenCalledOnce();
|
||||
expect(unlinkIfExistsMock).not.toHaveBeenCalled();
|
||||
wrappedLifecycle?.onAbandoned?.();
|
||||
await vi.waitFor(() => expect(originalLifecycle.onAbandoned).toHaveBeenCalledOnce());
|
||||
expect(unlinkIfExistsMock).toHaveBeenCalledOnce();
|
||||
expect(unlinkIfExistsMock).toHaveBeenCalledWith("/tmp/mms-1.jpg");
|
||||
expect(events).toEqual(["cleanup", "abandon"]);
|
||||
});
|
||||
|
||||
it("retains MMS files after successful turn adoption", async () => {
|
||||
const mocks = createRuntime();
|
||||
const originalLifecycle: SmsTurnAdoptionLifecycle = {
|
||||
onAdopted: vi.fn(async () => undefined),
|
||||
onDeferred: vi.fn(),
|
||||
onAbandoned: vi.fn(),
|
||||
};
|
||||
mocks.resolveAgentRoute.mockReturnValue({
|
||||
agentId: "main",
|
||||
accountId: "default",
|
||||
sessionKey: SMS_SESSION_KEY,
|
||||
});
|
||||
mocks.run.mockImplementationOnce(async (runParams) => {
|
||||
await runParams.turnAdoptionLifecycle?.onAdopted();
|
||||
});
|
||||
|
||||
await dispatchSmsInboundEvent({
|
||||
cfg: {},
|
||||
account: createAccount({ dmPolicy: "allowlist", allowFrom: [SMS_FROM] }),
|
||||
channelRuntime: mocks.runtime,
|
||||
receivedAt: 1_700_000_000_123,
|
||||
turnAdoptionLifecycle: originalLifecycle,
|
||||
msg: {
|
||||
from: SMS_FROM,
|
||||
to: SMS_TO,
|
||||
body: "",
|
||||
messageSid: "MM-adopted",
|
||||
accountSid: "AC123",
|
||||
media: [
|
||||
{
|
||||
url: `https://api.twilio.com/2010-04-01/Accounts/AC123/Messages/MM-adopted/Media/ME${"1".repeat(32)}`,
|
||||
contentType: "image/jpeg",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(originalLifecycle.onAdopted).toHaveBeenCalledOnce();
|
||||
expect(unlinkIfExistsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cleans deferred MMS files when turn adoption fails", async () => {
|
||||
const mocks = createRuntime();
|
||||
const adoptionError = new Error("durable adoption failed");
|
||||
const originalLifecycle: SmsTurnAdoptionLifecycle = {
|
||||
onAdopted: vi.fn(async () => {
|
||||
throw adoptionError;
|
||||
}),
|
||||
onDeferred: vi.fn(),
|
||||
onAbandoned: vi.fn(),
|
||||
};
|
||||
mocks.resolveAgentRoute.mockReturnValue({
|
||||
agentId: "main",
|
||||
accountId: "default",
|
||||
sessionKey: SMS_SESSION_KEY,
|
||||
});
|
||||
mocks.run.mockImplementationOnce(async (runParams) => {
|
||||
runParams.turnAdoptionLifecycle?.onDeferred?.();
|
||||
await runParams.turnAdoptionLifecycle?.onAdopted();
|
||||
});
|
||||
|
||||
await expect(
|
||||
dispatchSmsInboundEvent({
|
||||
cfg: {},
|
||||
account: createAccount({ dmPolicy: "allowlist", allowFrom: [SMS_FROM] }),
|
||||
channelRuntime: mocks.runtime,
|
||||
receivedAt: 1_700_000_000_123,
|
||||
turnAdoptionLifecycle: originalLifecycle,
|
||||
msg: {
|
||||
from: SMS_FROM,
|
||||
to: SMS_TO,
|
||||
body: "",
|
||||
messageSid: "MM-adoption-failed",
|
||||
accountSid: "AC123",
|
||||
media: [
|
||||
{
|
||||
url: `https://api.twilio.com/2010-04-01/Accounts/AC123/Messages/MM-adoption-failed/Media/ME${"1".repeat(32)}`,
|
||||
contentType: "image/jpeg",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).rejects.toBe(adoptionError);
|
||||
|
||||
expect(unlinkIfExistsMock).toHaveBeenCalledOnce();
|
||||
expect(unlinkIfExistsMock).toHaveBeenCalledWith("/tmp/mms-1.jpg");
|
||||
});
|
||||
|
||||
it("marks allowlisted SMS slash commands as text command turns", async () => {
|
||||
const { shouldComputeCommandAuthorized, isControlCommandMessage, buildContext } =
|
||||
await resolveAuthorizedSmsTurn({
|
||||
|
||||
+168
-111
@@ -16,7 +16,7 @@ type SmsLog = {
|
||||
|
||||
export type SmsChannelRuntime = Pick<
|
||||
PluginRuntime["channel"],
|
||||
"commands" | "inbound" | "pairing" | "reply" | "routing" | "session"
|
||||
"commands" | "inbound" | "media" | "pairing" | "reply" | "routing" | "session"
|
||||
>;
|
||||
|
||||
async function authorizeSmsSender(params: {
|
||||
@@ -128,118 +128,175 @@ export async function dispatchSmsInboundEvent(params: {
|
||||
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;
|
||||
const commandRequested = auth.commandAccess.requested;
|
||||
const commandAuthorized = auth.commandAccess.authorized;
|
||||
const isTextCommand = params.channelRuntime.commands.isControlCommandMessage(
|
||||
params.msg.body,
|
||||
params.cfg,
|
||||
);
|
||||
|
||||
await params.channelRuntime.inbound.run({
|
||||
channel: CHANNEL_ID,
|
||||
accountId: params.account.accountId,
|
||||
raw: params.msg,
|
||||
...(params.turnAdoptionLifecycle
|
||||
? { turnAdoptionLifecycle: params.turnAdoptionLifecycle }
|
||||
: {}),
|
||||
adapter: {
|
||||
ingest: (msg) => ({
|
||||
id: msg.messageSid,
|
||||
timestamp: params.receivedAt,
|
||||
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,
|
||||
},
|
||||
access: commandRequested
|
||||
? {
|
||||
commands: {
|
||||
authorized: commandAuthorized,
|
||||
},
|
||||
const materialized =
|
||||
params.msg.media.length > 0 || (params.msg.unavailableMediaCount ?? 0) > 0
|
||||
? await (
|
||||
await import("./media.js")
|
||||
).materializeSmsInboundMedia({
|
||||
account: params.account,
|
||||
msg: params.msg,
|
||||
mediaRuntime: params.channelRuntime,
|
||||
abortSignal: params.turnAdoptionLifecycle?.abortSignal,
|
||||
log: params.log,
|
||||
})
|
||||
: { body: params.msg.body, media: [], cleanup: async () => undefined };
|
||||
let adoptionState: "pending" | "deferred" | "adopted" | "abandoned" = "pending";
|
||||
try {
|
||||
const turnAdoptionLifecycle =
|
||||
materialized.media.length > 0 && params.turnAdoptionLifecycle
|
||||
? {
|
||||
...params.turnAdoptionLifecycle,
|
||||
onAdopted: async () => {
|
||||
try {
|
||||
await params.turnAdoptionLifecycle?.onAdopted();
|
||||
adoptionState = "adopted";
|
||||
} catch (error) {
|
||||
await materialized.cleanup();
|
||||
throw error;
|
||||
}
|
||||
: undefined,
|
||||
command: isTextCommand
|
||||
? {
|
||||
kind: "text-slash",
|
||||
body: input.textForCommands,
|
||||
authorized: commandAuthorized,
|
||||
}
|
||||
: undefined,
|
||||
extra: {
|
||||
MessageSid: params.msg.messageSid,
|
||||
SenderE164: from,
|
||||
To: params.msg.to,
|
||||
},
|
||||
});
|
||||
return {
|
||||
cfg: params.cfg,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: params.account.accountId,
|
||||
route: { agentId: route.agentId, sessionKey },
|
||||
ctxPayload,
|
||||
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}`);
|
||||
onDeferred: () => {
|
||||
const deferred = params.turnAdoptionLifecycle?.onDeferred?.();
|
||||
if (deferred !== false) {
|
||||
adoptionState = "deferred";
|
||||
}
|
||||
return deferred;
|
||||
},
|
||||
},
|
||||
};
|
||||
onAbandoned: () => {
|
||||
adoptionState = "abandoned";
|
||||
// Queue abandonment can be fire-and-forget. Start cleanup before
|
||||
// releasing the durable claim and contain asynchronous failures.
|
||||
void materialized
|
||||
.cleanup()
|
||||
.then(() => params.turnAdoptionLifecycle?.onAbandoned?.())
|
||||
.catch((error: unknown) => {
|
||||
params.log?.warn?.(
|
||||
`Failed to abandon Twilio MMS ingress ${params.msg.messageSid}: ${String(error)}`,
|
||||
);
|
||||
});
|
||||
},
|
||||
}
|
||||
: params.turnAdoptionLifecycle;
|
||||
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;
|
||||
const commandRequested = auth.commandAccess.requested;
|
||||
const commandAuthorized = auth.commandAccess.authorized;
|
||||
const isTextCommand = params.channelRuntime.commands.isControlCommandMessage(
|
||||
params.msg.body,
|
||||
params.cfg,
|
||||
);
|
||||
|
||||
await params.channelRuntime.inbound.run({
|
||||
channel: CHANNEL_ID,
|
||||
accountId: params.account.accountId,
|
||||
raw: params.msg,
|
||||
...(turnAdoptionLifecycle ? { turnAdoptionLifecycle } : {}),
|
||||
adapter: {
|
||||
ingest: (msg) => ({
|
||||
id: msg.messageSid,
|
||||
timestamp: params.receivedAt,
|
||||
rawText: msg.body,
|
||||
textForAgent: materialized.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,
|
||||
},
|
||||
media: materialized.media,
|
||||
access: commandRequested
|
||||
? {
|
||||
commands: {
|
||||
authorized: commandAuthorized,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
command: isTextCommand
|
||||
? {
|
||||
kind: "text-slash",
|
||||
body: input.textForCommands,
|
||||
authorized: commandAuthorized,
|
||||
}
|
||||
: undefined,
|
||||
extra: {
|
||||
MessageSid: params.msg.messageSid,
|
||||
SenderE164: from,
|
||||
To: params.msg.to,
|
||||
},
|
||||
});
|
||||
return {
|
||||
cfg: params.cfg,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: params.account.accountId,
|
||||
route: { agentId: route.agentId, sessionKey },
|
||||
ctxPayload,
|
||||
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}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
if (adoptionState === "pending" || adoptionState === "abandoned") {
|
||||
await materialized.cleanup();
|
||||
}
|
||||
} catch (error) {
|
||||
if (adoptionState === "pending" || adoptionState === "abandoned") {
|
||||
await materialized.cleanup();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,15 +309,178 @@ describe("createSmsIngressSpool", () => {
|
||||
expect(reloadedDeliver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches callbacks bound to the configured Messaging Service", async () => {
|
||||
const stateDir = await createStateDir();
|
||||
const serviceAccount = {
|
||||
...account,
|
||||
fromNumber: "",
|
||||
messagingServiceSid: "MG123",
|
||||
};
|
||||
const deliver = vi.fn<SmsIngressDeliver>(async (_message, lifecycle) => {
|
||||
await lifecycle.onAdopted();
|
||||
});
|
||||
const spool = createSmsIngressSpool({
|
||||
cfg: {},
|
||||
account: serviceAccount,
|
||||
channelRuntime: {} as SmsChannelRuntime,
|
||||
queue: createQueue(stateDir),
|
||||
deliver,
|
||||
});
|
||||
disposers.push(spool.stop);
|
||||
|
||||
await spool.enqueue({
|
||||
...form("SM-service"),
|
||||
MessagingServiceSid: "MG123",
|
||||
});
|
||||
await drainSpool(spool);
|
||||
|
||||
expect(deliver).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ messagingServiceSid: "MG123" }),
|
||||
expect.any(Object),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["invalid payload", { MessageSid: "SM-invalid", From: "+15551234567" }],
|
||||
["account mismatch", { ...form("SM-account"), AccountSid: "AC-other" }],
|
||||
])("dead-letters a permanent %s failure", async (_label, rawForm) => {
|
||||
{
|
||||
name: "fromNumber-only RCS compatibility",
|
||||
ingressAccount: account,
|
||||
rawForm: {
|
||||
...form("SM-rcs-number"),
|
||||
From: "rcs:+15551234567",
|
||||
To: "rcs:example-agent",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Messaging Service RCS identity",
|
||||
ingressAccount: { ...account, fromNumber: "", messagingServiceSid: "MG123" },
|
||||
rawForm: {
|
||||
...form("SM-rcs-service"),
|
||||
From: "rcs:+15551234567",
|
||||
To: "rcs:example-agent",
|
||||
MessagingServiceSid: "MG123",
|
||||
},
|
||||
},
|
||||
])("preserves $name", async ({ ingressAccount, rawForm }) => {
|
||||
const stateDir = await createStateDir();
|
||||
const deliver = vi.fn<SmsIngressDeliver>(async (_message, lifecycle) => {
|
||||
await lifecycle.onAdopted();
|
||||
});
|
||||
const spool = createSmsIngressSpool({
|
||||
cfg: {},
|
||||
account: ingressAccount,
|
||||
channelRuntime: {} as SmsChannelRuntime,
|
||||
queue: createQueue(stateDir),
|
||||
deliver,
|
||||
});
|
||||
disposers.push(spool.stop);
|
||||
|
||||
await spool.enqueue(rawForm);
|
||||
await drainSpool(spool);
|
||||
|
||||
expect(deliver).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("preserves shipped RCS text while ignoring unsupported media fields", async () => {
|
||||
const stateDir = await createStateDir();
|
||||
const deliver = vi.fn<SmsIngressDeliver>(async (_message, lifecycle) => {
|
||||
await lifecycle.onAdopted();
|
||||
});
|
||||
const spool = createSmsIngressSpool({
|
||||
cfg: {},
|
||||
account,
|
||||
channelRuntime: {} as SmsChannelRuntime,
|
||||
queue: createQueue(stateDir),
|
||||
deliver,
|
||||
});
|
||||
disposers.push(spool.stop);
|
||||
|
||||
await spool.enqueue({
|
||||
...form("SM-rcs-text-media"),
|
||||
Body: "keep this RCS text",
|
||||
From: "rcs:+15551234567",
|
||||
To: "rcs:example-agent",
|
||||
NumMedia: "1",
|
||||
MediaUrl0: "https://api.twilio.com/media/photo",
|
||||
});
|
||||
await drainSpool(spool);
|
||||
|
||||
expect(deliver).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: "keep this RCS text",
|
||||
media: [],
|
||||
}),
|
||||
expect.any(Object),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "invalid payload",
|
||||
ingressAccount: account,
|
||||
rawForm: { MessageSid: "SM-invalid", From: "+15551234567" },
|
||||
},
|
||||
{
|
||||
name: "missing account",
|
||||
ingressAccount: account,
|
||||
rawForm: { ...form("SM-account-missing"), AccountSid: "" },
|
||||
},
|
||||
{
|
||||
name: "account mismatch",
|
||||
ingressAccount: account,
|
||||
rawForm: { ...form("SM-account"), AccountSid: "AC-other" },
|
||||
},
|
||||
{
|
||||
name: "recipient mismatch",
|
||||
ingressAccount: account,
|
||||
rawForm: {
|
||||
...form("MM-recipient"),
|
||||
To: "+15550000000",
|
||||
NumMedia: "1",
|
||||
MediaUrl0: "https://api.twilio.com/media/photo",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Messaging Service mismatch",
|
||||
ingressAccount: { ...account, fromNumber: "", messagingServiceSid: "MG123" },
|
||||
rawForm: {
|
||||
...form("SM-service-mismatch"),
|
||||
MessagingServiceSid: "MG-other",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing Messaging Service",
|
||||
ingressAccount: { ...account, fromNumber: "", messagingServiceSid: "MG123" },
|
||||
rawForm: form("SM-service-missing"),
|
||||
},
|
||||
{
|
||||
name: "fromNumber precedence",
|
||||
ingressAccount: { ...account, messagingServiceSid: "MG123" },
|
||||
rawForm: {
|
||||
...form("SM-number-precedence"),
|
||||
To: "+15550000000",
|
||||
MessagingServiceSid: "MG123",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "RCS media-only",
|
||||
ingressAccount: account,
|
||||
rawForm: {
|
||||
...form("MM-rcs-media"),
|
||||
Body: "",
|
||||
From: "rcs:+15551234567",
|
||||
To: "rcs:example-agent",
|
||||
NumMedia: "1",
|
||||
MediaUrl0: "https://api.twilio.com/media/photo",
|
||||
},
|
||||
},
|
||||
])("dead-letters a permanent $name failure", async ({ ingressAccount, rawForm }) => {
|
||||
const stateDir = await createStateDir();
|
||||
const deliver = vi.fn<SmsIngressDeliver>(async () => undefined);
|
||||
const spool = createSmsIngressSpool({
|
||||
cfg: {},
|
||||
account,
|
||||
account: ingressAccount,
|
||||
channelRuntime: {} as SmsChannelRuntime,
|
||||
queue: createQueue(stateDir),
|
||||
deliver,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { runDetachedWebhookWork } from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import { dispatchSmsInboundEvent, type SmsChannelRuntime } from "./inbound.js";
|
||||
import { looksLikeSmsPhoneNumber, normalizeSmsPhoneNumber } from "./phone.js";
|
||||
import { getSmsRuntime } from "./runtime.js";
|
||||
import {
|
||||
buildTwilioInboundMessage,
|
||||
@@ -39,9 +40,42 @@ function parseSmsIngressForm(
|
||||
if (!message) {
|
||||
throw new SmsIngressPermanentError("SMS ingress payload is invalid.");
|
||||
}
|
||||
if (message.accountSid && message.accountSid !== account.accountSid) {
|
||||
if (!message.accountSid || message.accountSid !== account.accountSid) {
|
||||
throw new SmsIngressPermanentError("SMS ingress payload has an invalid Twilio account.");
|
||||
}
|
||||
const isRcsRecipient = /^rcs:/iu.test(message.to);
|
||||
if (isRcsRecipient) {
|
||||
if (!message.body) {
|
||||
throw new SmsIngressPermanentError("SMS ingress payload is invalid.");
|
||||
}
|
||||
if (
|
||||
account.messagingServiceSid &&
|
||||
message.messagingServiceSid !== account.messagingServiceSid
|
||||
) {
|
||||
throw new SmsIngressPermanentError(
|
||||
"SMS ingress payload has an invalid Twilio Messaging Service.",
|
||||
);
|
||||
}
|
||||
// Shipped fromNumber-only RCS callbacks use an agent address in `To`, so
|
||||
// they cannot be bound to the phone number without a new RCS config contract.
|
||||
// MMS support must not change shipped RCS text behavior. Ignore unsupported
|
||||
// RCS media fields until the RCS owner defines its media contract.
|
||||
const { unavailableMediaCount: _unavailableMediaCount, ...textMessage } = message;
|
||||
return { ...textMessage, media: [] };
|
||||
}
|
||||
if (account.fromNumber) {
|
||||
const recipient = normalizeSmsPhoneNumber(message.to);
|
||||
if (!looksLikeSmsPhoneNumber(recipient) || recipient !== account.fromNumber) {
|
||||
throw new SmsIngressPermanentError("SMS ingress payload has an invalid Twilio recipient.");
|
||||
}
|
||||
} else if (
|
||||
!message.messagingServiceSid ||
|
||||
message.messagingServiceSid !== account.messagingServiceSid
|
||||
) {
|
||||
throw new SmsIngressPermanentError(
|
||||
"SMS ingress payload has an invalid Twilio Messaging Service.",
|
||||
);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,891 @@
|
||||
// Sms tests cover outbound MMS media hosting behavior.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { unlinkIfExists as unlinkIfExistsType } from "openclaw/plugin-sdk/media-runtime";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import type {
|
||||
OpenKeyedStoreOptions,
|
||||
PluginStateKeyedStore,
|
||||
} from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { createPluginStateKeyedStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import type { loadWebMedia as loadWebMediaType } from "openclaw/plugin-sdk/web-media";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
materializeSmsInboundMedia,
|
||||
prepareHostedSmsMedia,
|
||||
tryHandleHostedSmsMediaRequest,
|
||||
} from "./media.js";
|
||||
import { setSmsRuntime } from "./runtime.js";
|
||||
import type { ResolvedSmsAccount } from "./types.js";
|
||||
|
||||
const loadWebMediaMock = vi.hoisted(() => vi.fn<typeof loadWebMediaType>());
|
||||
const unlinkIfExistsMock = vi.hoisted(() =>
|
||||
vi.fn<typeof unlinkIfExistsType>(async () => undefined),
|
||||
);
|
||||
const ACCOUNT_SID = `AC${"a".repeat(32)}`;
|
||||
const OTHER_ACCOUNT_SID = `AC${"b".repeat(32)}`;
|
||||
const MESSAGE_SID = `MM${"c".repeat(32)}`;
|
||||
const OTHER_MESSAGE_SID = `MM${"d".repeat(32)}`;
|
||||
const MEDIA_SID = `ME${"e".repeat(32)}`;
|
||||
const OTHER_MEDIA_SID = `ME${"f".repeat(32)}`;
|
||||
const TWILIO_MMS_FILENAME_CASES = [
|
||||
["application/pdf", ".pdf"],
|
||||
["application/vcard", ".vcf"],
|
||||
["audio/3gpp", ".3gp"],
|
||||
["audio/3gpp2", ".3g2"],
|
||||
["audio/ac3", ".ac3"],
|
||||
["audio/amr", ".amr"],
|
||||
["audio/amr-nb", ".amr"],
|
||||
["audio/basic", ".au"],
|
||||
["audio/l24", ".l24"],
|
||||
["audio/mp3", ".mp3"],
|
||||
["audio/mp4", ".m4a"],
|
||||
["audio/mpeg", ".mp3"],
|
||||
["audio/ogg", ".ogg"],
|
||||
["audio/vnd.rn-realaudio", ".ra"],
|
||||
["audio/vnd.wave", ".wav"],
|
||||
["audio/webm", ".webm"],
|
||||
["image/bmp", ".bmp"],
|
||||
["image/gif", ".gif"],
|
||||
["image/heic", ".heic"],
|
||||
["image/heif", ".heif"],
|
||||
["image/jpeg", ".jpg"],
|
||||
["image/jpg", ".jpg"],
|
||||
["image/png", ".png"],
|
||||
["image/tiff", ".tiff"],
|
||||
["text/calendar", ".ics"],
|
||||
["text/csv", ".csv"],
|
||||
["text/directory", ".vcf"],
|
||||
["text/richtext", ".rtx"],
|
||||
["text/rtf", ".rtf"],
|
||||
["text/vcard", ".vcf"],
|
||||
["text/x-vcard", ".vcf"],
|
||||
["video/3gpp", ".3gp"],
|
||||
["video/3gpp-tt", ".3gp"],
|
||||
["video/3gpp2", ".3g2"],
|
||||
["video/h261", ".h261"],
|
||||
["video/h263", ".h263"],
|
||||
["video/h263-1998", ".h263"],
|
||||
["video/h263-2000", ".h263"],
|
||||
["video/h264", ".h264"],
|
||||
["video/h265", ".h265"],
|
||||
["video/mp4", ".mp4"],
|
||||
["video/mpeg", ".mpg"],
|
||||
["video/mpeg4", ".mp4"],
|
||||
["video/quicktime", ".mov"],
|
||||
["video/webm", ".webm"],
|
||||
] as const;
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/web-media", () => ({
|
||||
loadWebMedia: loadWebMediaMock,
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
|
||||
unlinkIfExists: unlinkIfExistsMock,
|
||||
}));
|
||||
|
||||
const testStateEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: fs.mkdtempSync(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-sms-media-"),
|
||||
),
|
||||
};
|
||||
|
||||
function createAccount(): ResolvedSmsAccount {
|
||||
const publicWebhookUrl = new URL("https://gateway.example.com/public/sms");
|
||||
publicWebhookUrl.searchParams.set("upstream-token", "keep");
|
||||
publicWebhookUrl.hash = "rp=all";
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
accountSid: ACCOUNT_SID,
|
||||
authToken: "secret",
|
||||
fromNumber: "+15557654321",
|
||||
messagingServiceSid: "",
|
||||
defaultTo: "",
|
||||
webhookPath: "/internal/sms",
|
||||
publicWebhookUrl: publicWebhookUrl.toString(),
|
||||
dangerouslyDisableSignatureValidation: false,
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
textChunkLimit: 1500,
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareHostedSmsMediaUrl(
|
||||
params: Parameters<typeof prepareHostedSmsMedia>[0],
|
||||
): Promise<string> {
|
||||
return (await prepareHostedSmsMedia(params)).url;
|
||||
}
|
||||
|
||||
function twilioMediaUrl(
|
||||
params: {
|
||||
accountSid?: string;
|
||||
messageSid?: string;
|
||||
mediaSid?: string;
|
||||
} = {},
|
||||
): string {
|
||||
return `https://api.twilio.com/2010-04-01/Accounts/${
|
||||
params.accountSid ?? ACCOUNT_SID
|
||||
}/Messages/${params.messageSid ?? MESSAGE_SID}/Media/${params.mediaSid ?? MEDIA_SID}`;
|
||||
}
|
||||
|
||||
function installRuntime() {
|
||||
const openKeyedStore = vi.fn((options: OpenKeyedStoreOptions) =>
|
||||
createPluginStateKeyedStoreForTests("sms", { ...options, env: testStateEnv }),
|
||||
);
|
||||
setSmsRuntime({
|
||||
state: {
|
||||
openKeyedStore,
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
return openKeyedStore;
|
||||
}
|
||||
|
||||
function createMockResponse() {
|
||||
const headers = new Map<string, string>();
|
||||
return {
|
||||
headers,
|
||||
res: {
|
||||
statusCode: 200,
|
||||
headersSent: false,
|
||||
setHeader(name: string, value: string) {
|
||||
headers.set(name, value);
|
||||
},
|
||||
end: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("SMS outbound hosted media", () => {
|
||||
let openKeyedStore: ReturnType<typeof installRuntime>;
|
||||
|
||||
beforeEach(() => {
|
||||
openKeyedStore = installRuntime();
|
||||
loadWebMediaMock.mockReset();
|
||||
loadWebMediaMock.mockResolvedValue({
|
||||
buffer: Buffer.from("image-bytes"),
|
||||
kind: "image",
|
||||
contentType: "image/png",
|
||||
fileName: "photo.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses reject-new capacity policy for hosted MMS backing stores", async () => {
|
||||
await prepareHostedSmsMediaUrl({
|
||||
account: createAccount(),
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
});
|
||||
|
||||
expect(openKeyedStore).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ overflowPolicy: "reject-new" }),
|
||||
);
|
||||
expect(openKeyedStore).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ overflowPolicy: "reject-new" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("releases reject-new capacity after a failed staged MMS is discarded", async () => {
|
||||
const account = { ...createAccount(), accountId: "capacity-cleanup" };
|
||||
const staged = [];
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
staged.push(
|
||||
await prepareHostedSmsMedia({
|
||||
account,
|
||||
mediaUrl: `https://example.com/photo-${index}.png`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await expect(
|
||||
prepareHostedSmsMedia({
|
||||
account,
|
||||
mediaUrl: "https://example.com/full.png",
|
||||
}),
|
||||
).rejects.toThrow("hosted outbound media capacity is full");
|
||||
|
||||
const first = staged[0];
|
||||
if (!first) {
|
||||
throw new Error("expected a staged MMS entry");
|
||||
}
|
||||
await first.cleanup();
|
||||
await first.cleanup();
|
||||
|
||||
await expect(
|
||||
prepareHostedSmsMedia({
|
||||
account,
|
||||
mediaUrl: "https://example.com/replacement.png",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
url: expect.stringMatching(/^https:\/\/gateway\.example\.com\//u),
|
||||
cleanup: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it("retries a hosted MMS cleanup after a transient store failure", async () => {
|
||||
const prepared = await prepareHostedSmsMedia({
|
||||
account: { ...createAccount(), accountId: "cleanup-retry" },
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
});
|
||||
const chunkStore = openKeyedStore.mock.results[1]?.value as
|
||||
| PluginStateKeyedStore<unknown>
|
||||
| undefined;
|
||||
if (!chunkStore) {
|
||||
throw new Error("expected hosted media chunk store");
|
||||
}
|
||||
const originalDelete = chunkStore.delete.bind(chunkStore);
|
||||
let failed = false;
|
||||
vi.spyOn(chunkStore, "delete").mockImplementation(async (key) => {
|
||||
if (!failed) {
|
||||
failed = true;
|
||||
throw new Error("transient chunk delete failure");
|
||||
}
|
||||
return await originalDelete(key);
|
||||
});
|
||||
|
||||
await expect(prepared.cleanup()).rejects.toThrow("transient chunk delete failure");
|
||||
await expect(prepared.cleanup()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("hosts media on the exact webhook path and supports repeat GET/HEAD fetches", async () => {
|
||||
const hostedUrl = await prepareHostedSmsMediaUrl({
|
||||
account: createAccount(),
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
});
|
||||
const publicUrl = new URL(hostedUrl);
|
||||
const chunkStore = openKeyedStore.mock.results[1]?.value;
|
||||
if (!chunkStore) {
|
||||
throw new Error("expected hosted media chunk store");
|
||||
}
|
||||
const chunkLookup = vi.spyOn(chunkStore, "lookup");
|
||||
|
||||
expect(publicUrl.origin).toBe("https://gateway.example.com");
|
||||
expect(publicUrl.pathname).toBe("/public/sms");
|
||||
expect(publicUrl.searchParams.get("upstream-token")).toBe("keep");
|
||||
expect(publicUrl.hash).toBe("");
|
||||
const tokenEntry = [...publicUrl.searchParams.entries()].find(([key]) =>
|
||||
key.startsWith("__openclaw_mms_token_"),
|
||||
);
|
||||
const id = tokenEntry?.[0].slice("__openclaw_mms_token_".length);
|
||||
expect(id).toMatch(/^[a-f0-9]{24}$/u);
|
||||
expect(publicUrl.searchParams.get(`__openclaw_mms_token_${id}`)).toMatch(/^[a-f0-9]{48}$/u);
|
||||
|
||||
const internalUrl = `/internal/sms${publicUrl.search}`;
|
||||
const getResponse = createMockResponse();
|
||||
await tryHandleHostedSmsMediaRequest(
|
||||
{ method: "GET", url: internalUrl } as never,
|
||||
getResponse.res as never,
|
||||
);
|
||||
expect(getResponse.res.statusCode).toBe(200);
|
||||
expect(getResponse.headers.get("Content-Type")).toBe("image/png");
|
||||
expect(getResponse.headers.get("Content-Disposition")).toMatch(
|
||||
/^inline; filename="mms-[a-f0-9]{10}\.png"$/u,
|
||||
);
|
||||
expect(getResponse.res.end).toHaveBeenCalledWith(Buffer.from("image-bytes"));
|
||||
expect(chunkLookup).toHaveBeenCalled();
|
||||
|
||||
chunkLookup.mockClear();
|
||||
const headResponse = createMockResponse();
|
||||
await tryHandleHostedSmsMediaRequest(
|
||||
{ method: "HEAD", url: internalUrl } as never,
|
||||
headResponse.res as never,
|
||||
);
|
||||
expect(headResponse.res.statusCode).toBe(200);
|
||||
expect(headResponse.res.end).toHaveBeenCalledWith(undefined);
|
||||
expect(chunkLookup).not.toHaveBeenCalled();
|
||||
|
||||
const repeatedGetResponse = createMockResponse();
|
||||
await tryHandleHostedSmsMediaRequest(
|
||||
{ method: "GET", url: internalUrl } as never,
|
||||
repeatedGetResponse.res as never,
|
||||
);
|
||||
expect(repeatedGetResponse.res.statusCode).toBe(200);
|
||||
expect(chunkLookup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(TWILIO_MMS_FILENAME_CASES)(
|
||||
"serves %s with Twilio's required %s filename",
|
||||
async (contentType, extension) => {
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("media"),
|
||||
kind: "document",
|
||||
contentType,
|
||||
fileName: `attachment${extension}`,
|
||||
});
|
||||
const hostedUrl = new URL(
|
||||
await prepareHostedSmsMediaUrl({
|
||||
account: createAccount(),
|
||||
mediaUrl: `https://example.com/attachment${extension}`,
|
||||
}),
|
||||
);
|
||||
const response = createMockResponse();
|
||||
|
||||
await tryHandleHostedSmsMediaRequest(
|
||||
{ method: "GET", url: `/internal/sms${hostedUrl.search}` } as never,
|
||||
response.res as never,
|
||||
);
|
||||
|
||||
const contentDisposition = response.headers.get("Content-Disposition") ?? "";
|
||||
const fileName = /filename="([^"]+)"/u.exec(contentDisposition)?.[1] ?? "";
|
||||
expect(response.res.statusCode).toBe(200);
|
||||
expect(fileName).toMatch(new RegExp(`^mms-[a-f0-9]{10}\\${extension}$`, "u"));
|
||||
expect(fileName).toMatch(/^[\x20-\x7e]+$/u);
|
||||
expect(fileName.length).toBeLessThanOrEqual(20);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects hosted media requests with the wrong token", async () => {
|
||||
const hostedUrl = new URL(
|
||||
await prepareHostedSmsMediaUrl({
|
||||
account: createAccount(),
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
}),
|
||||
);
|
||||
const tokenEntry = [...hostedUrl.searchParams.entries()].find(([key]) =>
|
||||
key.startsWith("__openclaw_mms_token_"),
|
||||
);
|
||||
const id = tokenEntry?.[0].slice("__openclaw_mms_token_".length) ?? "";
|
||||
const tokenParam = `__openclaw_mms_token_${id}`;
|
||||
hostedUrl.searchParams.set(tokenParam, "wrong");
|
||||
const chunkStore = openKeyedStore.mock.results[1]?.value;
|
||||
if (!chunkStore) {
|
||||
throw new Error("expected hosted media chunk store");
|
||||
}
|
||||
const chunkLookup = vi.spyOn(chunkStore, "lookup");
|
||||
const response = createMockResponse();
|
||||
|
||||
await tryHandleHostedSmsMediaRequest(
|
||||
{
|
||||
method: "GET",
|
||||
url: `/internal/sms${hostedUrl.search}`,
|
||||
} as never,
|
||||
response.res as never,
|
||||
);
|
||||
|
||||
expect(response.res.statusCode).toBe(401);
|
||||
expect(response.res.end).toHaveBeenCalledWith("Unauthorized");
|
||||
expect(chunkLookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects multiple hosted-media token candidates before reading state", async () => {
|
||||
const response = createMockResponse();
|
||||
const firstId = "a".repeat(24);
|
||||
const secondId = "b".repeat(24);
|
||||
|
||||
await expect(
|
||||
tryHandleHostedSmsMediaRequest(
|
||||
{
|
||||
method: "GET",
|
||||
url: `/internal/sms?__openclaw_mms_token_${firstId}=first&__openclaw_mms_token_${secondId}=second`,
|
||||
} as never,
|
||||
response.res as never,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(response.res.statusCode).toBe(400);
|
||||
expect(response.res.end).toHaveBeenCalledWith("Bad Request");
|
||||
expect(openKeyedStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not serve media when metadata disappears before chunk hydration", async () => {
|
||||
const hostedUrl = new URL(
|
||||
await prepareHostedSmsMediaUrl({
|
||||
account: createAccount(),
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
}),
|
||||
);
|
||||
const metadataStore = openKeyedStore.mock.results[0]?.value as
|
||||
| PluginStateKeyedStore<unknown>
|
||||
| undefined;
|
||||
const chunkStore = openKeyedStore.mock.results[1]?.value as
|
||||
| PluginStateKeyedStore<unknown>
|
||||
| undefined;
|
||||
if (!metadataStore || !chunkStore) {
|
||||
throw new Error("expected hosted media stores");
|
||||
}
|
||||
const originalLookup = metadataStore.lookup.bind(metadataStore);
|
||||
let metadataLookups = 0;
|
||||
vi.spyOn(metadataStore, "lookup").mockImplementation(async (key) => {
|
||||
metadataLookups += 1;
|
||||
return metadataLookups === 1 ? await originalLookup(key) : undefined;
|
||||
});
|
||||
const chunkLookup = vi.spyOn(chunkStore, "lookup");
|
||||
const response = createMockResponse();
|
||||
|
||||
await tryHandleHostedSmsMediaRequest(
|
||||
{
|
||||
method: "GET",
|
||||
url: `/internal/sms${hostedUrl.search}`,
|
||||
} as never,
|
||||
response.res as never,
|
||||
);
|
||||
|
||||
expect(response.res.statusCode).toBe(404);
|
||||
expect(response.res.end).toHaveBeenCalledWith("Not Found");
|
||||
expect(chunkLookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores tokenless webhook requests before enforcing media methods", async () => {
|
||||
const response = createMockResponse();
|
||||
|
||||
await expect(
|
||||
tryHandleHostedSmsMediaRequest(
|
||||
{ method: "POST", url: "/internal/sms" } as never,
|
||||
response.res as never,
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(response.res.end).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("isolates hosted media by SMS account", async () => {
|
||||
const hostedUrl = new URL(
|
||||
await prepareHostedSmsMediaUrl({
|
||||
account: { ...createAccount(), accountId: "secondary" },
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
}),
|
||||
);
|
||||
const internalUrl = `/internal/sms${hostedUrl.search}`;
|
||||
const wrongAccountResponse = createMockResponse();
|
||||
|
||||
await tryHandleHostedSmsMediaRequest(
|
||||
{ method: "GET", url: internalUrl } as never,
|
||||
wrongAccountResponse.res as never,
|
||||
"default",
|
||||
);
|
||||
expect(wrongAccountResponse.res.statusCode).toBe(404);
|
||||
|
||||
const correctAccountResponse = createMockResponse();
|
||||
await tryHandleHostedSmsMediaRequest(
|
||||
{ method: "GET", url: internalUrl } as never,
|
||||
correctAccountResponse.res as never,
|
||||
"secondary",
|
||||
);
|
||||
expect(correctAccountResponse.res.statusCode).toBe(200);
|
||||
expect(correctAccountResponse.res.end).toHaveBeenCalledWith(Buffer.from("image-bytes"));
|
||||
});
|
||||
|
||||
it("requires a public webhook URL before hosting outbound MMS", async () => {
|
||||
await expect(
|
||||
prepareHostedSmsMediaUrl({
|
||||
account: { ...createAccount(), publicWebhookUrl: "" },
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
}),
|
||||
).rejects.toThrow("MMS send requires channels.sms.publicWebhookUrl");
|
||||
});
|
||||
|
||||
it("rejects public webhook URLs that require HTTP authentication", async () => {
|
||||
const authenticatedUrl = new URL("https://gateway.example.com/public/sms");
|
||||
authenticatedUrl.username = "user";
|
||||
authenticatedUrl.password = "password";
|
||||
|
||||
await expect(
|
||||
prepareHostedSmsMediaUrl({
|
||||
account: {
|
||||
...createAccount(),
|
||||
publicWebhookUrl: authenticatedUrl.toString(),
|
||||
},
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
}),
|
||||
).rejects.toThrow("without embedded HTTP authentication");
|
||||
expect(loadWebMediaMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["http://gateway.example.com/public/sms", "file:///tmp/public-sms"])(
|
||||
"rejects non-HTTPS public webhook URL %s",
|
||||
async (publicWebhookUrl) => {
|
||||
await expect(
|
||||
prepareHostedSmsMediaUrl({
|
||||
account: { ...createAccount(), publicWebhookUrl },
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
}),
|
||||
).rejects.toThrow("requires an HTTPS publicWebhookUrl with a hostname");
|
||||
expect(loadWebMediaMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ contentType: "application/pdf", byteLength: 500_000, outcome: "accepts" },
|
||||
{ contentType: "application/vcard", byteLength: 1, outcome: "accepts" },
|
||||
{ contentType: "application/pdf", byteLength: 500_001, outcome: "rejects" },
|
||||
{ contentType: "image/png; charset=binary", byteLength: 500_001, outcome: "accepts" },
|
||||
{ contentType: "image/heic", byteLength: 500_001, outcome: "rejects" },
|
||||
{ contentType: "application/msword", byteLength: 1, outcome: "unsupported" },
|
||||
])(
|
||||
"$outcome detected $contentType media at $byteLength bytes",
|
||||
async ({ contentType, byteLength, outcome }) => {
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: Buffer.alloc(byteLength),
|
||||
kind: "document",
|
||||
contentType,
|
||||
fileName: "attachment.bin",
|
||||
});
|
||||
const prepared = prepareHostedSmsMediaUrl({
|
||||
account: createAccount(),
|
||||
mediaUrl: "https://example.com/attachment.bin",
|
||||
});
|
||||
|
||||
if (outcome === "accepts") {
|
||||
await expect(prepared).resolves.toMatch(/^https:\/\/gateway\.example\.com\//u);
|
||||
} else if (outcome === "unsupported") {
|
||||
await expect(prepared).rejects.toThrow(
|
||||
`Twilio MMS does not support media type ${contentType}`,
|
||||
);
|
||||
} else {
|
||||
const expectedLimit = ["image/gif", "image/jpeg", "image/jpg", "image/png"].some((type) =>
|
||||
contentType.startsWith(type),
|
||||
)
|
||||
? "5,000,000"
|
||||
: "500,000";
|
||||
await expect(prepared).rejects.toThrow(
|
||||
`Twilio MMS media exceeds the ${expectedLimit} byte limit`,
|
||||
);
|
||||
}
|
||||
expect(loadWebMediaMock).toHaveBeenLastCalledWith(
|
||||
"https://example.com/attachment.bin",
|
||||
expect.objectContaining({ maxBytes: 4_999_999 }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects captioned media types that Twilio only accepts as media-only MMS", async () => {
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("BEGIN:VCARD"),
|
||||
kind: "document",
|
||||
contentType: "application/vcard",
|
||||
fileName: "contact.vcf",
|
||||
});
|
||||
|
||||
await expect(
|
||||
prepareHostedSmsMediaUrl({
|
||||
account: createAccount(),
|
||||
mediaUrl: "https://example.com/contact.vcf",
|
||||
captionByteLength: 7,
|
||||
}),
|
||||
).rejects.toThrow("Twilio MMS media type application/vcard must be sent without a caption");
|
||||
});
|
||||
|
||||
it("enforces Twilio's aggregate body-and-media limit", async () => {
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: Buffer.alloc(4_999_997),
|
||||
kind: "image",
|
||||
contentType: "image/png",
|
||||
fileName: "attachment.png",
|
||||
});
|
||||
|
||||
await expect(
|
||||
prepareHostedSmsMediaUrl({
|
||||
account: createAccount(),
|
||||
mediaUrl: "https://example.com/attachment.png",
|
||||
captionByteLength: 3,
|
||||
}),
|
||||
).rejects.toThrow("attachment and caption must total less than 5,000,000 bytes");
|
||||
expect(loadWebMediaMock).toHaveBeenCalledWith(
|
||||
"https://example.com/attachment.png",
|
||||
expect.objectContaining({ maxBytes: 4_999_996 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SMS inbound MMS materialization", () => {
|
||||
beforeEach(() => {
|
||||
unlinkIfExistsMock.mockClear();
|
||||
});
|
||||
|
||||
it("keeps the message visible when declared attachments exceed the download bound", async () => {
|
||||
const saveRemoteMedia = vi.fn();
|
||||
|
||||
const result = await materializeSmsInboundMedia({
|
||||
account: createAccount(),
|
||||
msg: {
|
||||
accountSid: ACCOUNT_SID,
|
||||
from: "+15551234567",
|
||||
to: "+15557654321",
|
||||
body: "many photos",
|
||||
messageSid: MESSAGE_SID,
|
||||
media: [],
|
||||
unavailableMediaCount: 2,
|
||||
},
|
||||
mediaRuntime: { media: { saveRemoteMedia } } as never,
|
||||
});
|
||||
|
||||
expect(saveRemoteMedia).not.toHaveBeenCalled();
|
||||
expect(result.media).toEqual([]);
|
||||
expect(result.body).toContain("many photos");
|
||||
expect(result.body).toContain("[2 Twilio MMS attachments unavailable]");
|
||||
});
|
||||
|
||||
it("rejects non-Twilio media hosts and exposes a visible failure notice", async () => {
|
||||
const saveRemoteMedia = vi.fn();
|
||||
|
||||
const result = await materializeSmsInboundMedia({
|
||||
account: createAccount(),
|
||||
msg: {
|
||||
accountSid: ACCOUNT_SID,
|
||||
from: "+15551234567",
|
||||
to: "+15557654321",
|
||||
body: "",
|
||||
messageSid: MESSAGE_SID,
|
||||
media: [{ url: "https://example.com/not-twilio.jpg", contentType: "image/jpeg" }],
|
||||
},
|
||||
mediaRuntime: { media: { saveRemoteMedia } } as never,
|
||||
});
|
||||
|
||||
expect(saveRemoteMedia).not.toHaveBeenCalled();
|
||||
expect(result.media).toEqual([]);
|
||||
expect(result.body).toContain("Twilio MMS attachment unavailable");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "an arbitrary Twilio API path",
|
||||
url: "https://api.twilio.com/2010-04-01/Accounts.json",
|
||||
},
|
||||
{
|
||||
name: "a different Twilio account",
|
||||
url: twilioMediaUrl({ accountSid: OTHER_ACCOUNT_SID }),
|
||||
},
|
||||
{
|
||||
name: "a different parent message",
|
||||
url: twilioMediaUrl({ messageSid: OTHER_MESSAGE_SID }),
|
||||
},
|
||||
{
|
||||
name: "an invalid media SID",
|
||||
url: twilioMediaUrl({ mediaSid: "ME1" }),
|
||||
},
|
||||
{
|
||||
name: "embedded URL credentials",
|
||||
url: twilioMediaUrl().replace("https://", "https://user:password@"),
|
||||
},
|
||||
])("rejects $name before adding Twilio credentials", async ({ url }) => {
|
||||
const saveRemoteMedia = vi.fn();
|
||||
|
||||
const result = await materializeSmsInboundMedia({
|
||||
account: createAccount(),
|
||||
msg: {
|
||||
accountSid: ACCOUNT_SID,
|
||||
from: "+15551234567",
|
||||
to: "+15557654321",
|
||||
body: "",
|
||||
messageSid: MESSAGE_SID,
|
||||
media: [{ url, contentType: "image/jpeg" }],
|
||||
},
|
||||
mediaRuntime: { media: { saveRemoteMedia } } as never,
|
||||
});
|
||||
|
||||
expect(saveRemoteMedia).not.toHaveBeenCalled();
|
||||
expect(result.media).toEqual([]);
|
||||
expect(result.body).toContain("Twilio MMS attachment unavailable");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "missing", accountSid: "" },
|
||||
{ name: "mismatched", accountSid: OTHER_ACCOUNT_SID },
|
||||
{ name: "padded", accountSid: ` ${ACCOUNT_SID} ` },
|
||||
])("refuses downloads when AccountSid is $name", async ({ accountSid }) => {
|
||||
const saveRemoteMedia = vi.fn();
|
||||
|
||||
const result = await materializeSmsInboundMedia({
|
||||
account: createAccount(),
|
||||
msg: {
|
||||
accountSid,
|
||||
from: "+15551234567",
|
||||
to: "+15557654321",
|
||||
body: "caption",
|
||||
messageSid: MESSAGE_SID,
|
||||
media: [{ url: twilioMediaUrl(), contentType: "image/jpeg" }],
|
||||
},
|
||||
mediaRuntime: { media: { saveRemoteMedia } } as never,
|
||||
});
|
||||
|
||||
expect(saveRemoteMedia).not.toHaveBeenCalled();
|
||||
expect(result.media).toEqual([]);
|
||||
expect(result.body).toContain("caption");
|
||||
expect(result.body).toContain("Twilio MMS attachment unavailable");
|
||||
});
|
||||
|
||||
it("applies one combined byte budget across inbound attachments", async () => {
|
||||
const firstSize = 4 * 1024 * 1024;
|
||||
const abortController = new AbortController();
|
||||
const saveRemoteMedia = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
path: "/tmp/first.jpg",
|
||||
size: firstSize,
|
||||
contentType: "image/jpeg",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
path: "/tmp/second.jpg",
|
||||
size: 1024,
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
|
||||
await materializeSmsInboundMedia({
|
||||
account: createAccount(),
|
||||
msg: {
|
||||
accountSid: ACCOUNT_SID,
|
||||
from: "+15551234567",
|
||||
to: "+15557654321",
|
||||
body: "photos",
|
||||
messageSid: MESSAGE_SID,
|
||||
media: [
|
||||
{ url: twilioMediaUrl(), contentType: "image/jpeg" },
|
||||
{ url: twilioMediaUrl({ mediaSid: OTHER_MEDIA_SID }), contentType: "image/jpeg" },
|
||||
],
|
||||
},
|
||||
mediaRuntime: { media: { saveRemoteMedia } } as never,
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(saveRemoteMedia).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
maxBytes: 5 * 1024 * 1024,
|
||||
requestInit: expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
ssrfPolicy: { hostnameAllowlist: ["api.twilio.com"] },
|
||||
timeoutMs: 60_000,
|
||||
responseHeaderTimeoutMs: 30_000,
|
||||
readIdleTimeoutMs: 30_000,
|
||||
retry: {
|
||||
attempts: 2,
|
||||
minDelayMs: 500,
|
||||
maxDelayMs: 2_000,
|
||||
jitter: 0.2,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(saveRemoteMedia).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ maxBytes: 1024 * 1024 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates durable claim cancellation instead of hiding it as unavailable media", async () => {
|
||||
const abortController = new AbortController();
|
||||
const abortReason = new Error("SMS ingress claim superseded");
|
||||
abortController.abort(abortReason);
|
||||
const saveRemoteMedia = vi.fn();
|
||||
|
||||
await expect(
|
||||
materializeSmsInboundMedia({
|
||||
account: createAccount(),
|
||||
msg: {
|
||||
accountSid: ACCOUNT_SID,
|
||||
from: "+15551234567",
|
||||
to: "+15557654321",
|
||||
body: "photo",
|
||||
messageSid: MESSAGE_SID,
|
||||
media: [{ url: twilioMediaUrl(), contentType: "image/jpeg" }],
|
||||
},
|
||||
mediaRuntime: { media: { saveRemoteMedia } } as never,
|
||||
abortSignal: abortController.signal,
|
||||
}),
|
||||
).rejects.toBe(abortReason);
|
||||
expect(saveRemoteMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cleans already-saved files when a later attachment aborts the batch", async () => {
|
||||
const abortController = new AbortController();
|
||||
const abortReason = new Error("SMS ingress claim superseded");
|
||||
const saveRemoteMedia = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
path: "/tmp/first.jpg",
|
||||
size: 128,
|
||||
contentType: "image/jpeg",
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
abortController.abort(abortReason);
|
||||
throw abortReason;
|
||||
});
|
||||
|
||||
await expect(
|
||||
materializeSmsInboundMedia({
|
||||
account: createAccount(),
|
||||
msg: {
|
||||
accountSid: ACCOUNT_SID,
|
||||
from: "+15551234567",
|
||||
to: "+15557654321",
|
||||
body: "photos",
|
||||
messageSid: MESSAGE_SID,
|
||||
media: [
|
||||
{ url: twilioMediaUrl(), contentType: "image/jpeg" },
|
||||
{ url: twilioMediaUrl({ mediaSid: OTHER_MEDIA_SID }), contentType: "image/jpeg" },
|
||||
],
|
||||
},
|
||||
mediaRuntime: { media: { saveRemoteMedia } } as never,
|
||||
abortSignal: abortController.signal,
|
||||
}),
|
||||
).rejects.toBe(abortReason);
|
||||
|
||||
expect(unlinkIfExistsMock).toHaveBeenCalledOnce();
|
||||
expect(unlinkIfExistsMock).toHaveBeenCalledWith("/tmp/first.jpg");
|
||||
});
|
||||
|
||||
it("exposes idempotent cleanup for successfully materialized files", async () => {
|
||||
const result = await materializeSmsInboundMedia({
|
||||
account: createAccount(),
|
||||
msg: {
|
||||
accountSid: ACCOUNT_SID,
|
||||
from: "+15551234567",
|
||||
to: "+15557654321",
|
||||
body: "photo",
|
||||
messageSid: MESSAGE_SID,
|
||||
media: [{ url: twilioMediaUrl(), contentType: "image/jpeg" }],
|
||||
},
|
||||
mediaRuntime: {
|
||||
media: {
|
||||
saveRemoteMedia: async () => ({
|
||||
path: "/tmp/photo.jpg",
|
||||
size: 128,
|
||||
contentType: "image/jpeg",
|
||||
}),
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
await Promise.all([result.cleanup(), result.cleanup()]);
|
||||
expect(unlinkIfExistsMock).toHaveBeenCalledOnce();
|
||||
expect(unlinkIfExistsMock).toHaveBeenCalledWith("/tmp/photo.jpg");
|
||||
});
|
||||
|
||||
it("bounds the complete inbound MMS download batch below the ingress watchdog", async () => {
|
||||
const batchAbort = new AbortController();
|
||||
const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValueOnce(batchAbort.signal);
|
||||
const saveRemoteMedia = vi.fn(
|
||||
async (options: { requestInit?: { signal?: AbortSignal } }) =>
|
||||
await new Promise<never>((_resolve, reject) => {
|
||||
options.requestInit?.signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
const reason = options.requestInit?.signal?.reason;
|
||||
reject(reason instanceof Error ? reason : new Error(String(reason ?? "aborted")));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const pending = materializeSmsInboundMedia({
|
||||
account: createAccount(),
|
||||
msg: {
|
||||
accountSid: ACCOUNT_SID,
|
||||
from: "+15551234567",
|
||||
to: "+15557654321",
|
||||
body: "photo",
|
||||
messageSid: MESSAGE_SID,
|
||||
media: [{ url: twilioMediaUrl(), contentType: "image/jpeg" }],
|
||||
},
|
||||
mediaRuntime: { media: { saveRemoteMedia } } as never,
|
||||
});
|
||||
await vi.waitFor(() => expect(saveRemoteMedia).toHaveBeenCalledOnce());
|
||||
|
||||
const timeoutReason = new DOMException("MMS batch timed out", "TimeoutError");
|
||||
batchAbort.abort(timeoutReason);
|
||||
|
||||
await expect(pending).rejects.toBe(timeoutReason);
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(4 * 60_000);
|
||||
timeoutSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,537 @@
|
||||
// Sms plugin module owns inbound Twilio media downloads and outbound MMS hosting.
|
||||
import { createHash } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import {
|
||||
formatInboundMediaUnavailableText,
|
||||
toInboundMediaFactsWithMetadata,
|
||||
type InboundMediaFacts,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
|
||||
import { unlinkIfExists } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
createHostedOutboundMediaStore,
|
||||
type HostedOutboundMediaChunkRecord,
|
||||
type HostedOutboundMediaMetaRecord,
|
||||
type HostedOutboundMediaStore,
|
||||
type OutboundMediaLoadOptions,
|
||||
} from "openclaw/plugin-sdk/outbound-media";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { getSmsRuntime } from "./runtime.js";
|
||||
import { TWILIO_MMS_MAX_BYTES } from "./twilio.js";
|
||||
import type { ResolvedSmsAccount, SmsInboundMessage } from "./types.js";
|
||||
|
||||
const TWILIO_API_HOSTNAME = "api.twilio.com";
|
||||
const TWILIO_MEDIA_PATH_RE =
|
||||
/^\/2010-04-01\/Accounts\/([^/]+)\/Messages\/([^/]+)\/Media\/(ME[0-9a-fA-F]{32})$/u;
|
||||
const TWILIO_MEDIA_TOTAL_TIMEOUT_MS = 60_000;
|
||||
const TWILIO_MEDIA_RESPONSE_HEADER_TIMEOUT_MS = 30_000;
|
||||
const TWILIO_MEDIA_READ_IDLE_TIMEOUT_MS = 30_000;
|
||||
const TWILIO_MEDIA_BATCH_TIMEOUT_MS = 4 * 60_000;
|
||||
const TWILIO_MEDIA_RETRY = {
|
||||
attempts: 2,
|
||||
minDelayMs: 500,
|
||||
maxDelayMs: 2_000,
|
||||
jitter: 0.2,
|
||||
} as const;
|
||||
const TWILIO_MMS_IMAGE_MAX_BYTES = 5_000_000;
|
||||
const TWILIO_MMS_OTHER_MAX_BYTES = 500_000;
|
||||
const TWILIO_MMS_LARGE_MEDIA_TYPES = new Set(["image/gif", "image/jpeg", "image/jpg", "image/png"]);
|
||||
const TWILIO_MMS_MEDIA_ONLY_TYPES = new Set(["application/vcard"]);
|
||||
// Twilio validates the Content-Disposition filename extension for outbound MMS.
|
||||
const TWILIO_MMS_EXTENSION_BY_TYPE: Readonly<Record<string, string>> = {
|
||||
"application/pdf": ".pdf",
|
||||
"application/vcard": ".vcf",
|
||||
"audio/3gpp": ".3gp",
|
||||
"audio/3gpp2": ".3g2",
|
||||
"audio/ac3": ".ac3",
|
||||
"audio/amr": ".amr",
|
||||
"audio/amr-nb": ".amr",
|
||||
"audio/basic": ".au",
|
||||
"audio/l24": ".l24",
|
||||
"audio/mp3": ".mp3",
|
||||
"audio/mp4": ".m4a",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/vnd.rn-realaudio": ".ra",
|
||||
"audio/vnd.wave": ".wav",
|
||||
"audio/webm": ".webm",
|
||||
"image/bmp": ".bmp",
|
||||
"image/gif": ".gif",
|
||||
"image/heic": ".heic",
|
||||
"image/heif": ".heif",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/tiff": ".tiff",
|
||||
"text/calendar": ".ics",
|
||||
"text/csv": ".csv",
|
||||
"text/directory": ".vcf",
|
||||
"text/richtext": ".rtx",
|
||||
"text/rtf": ".rtf",
|
||||
"text/vcard": ".vcf",
|
||||
"text/x-vcard": ".vcf",
|
||||
"video/3gpp": ".3gp",
|
||||
"video/3gpp-tt": ".3gp",
|
||||
"video/3gpp2": ".3g2",
|
||||
"video/h261": ".h261",
|
||||
"video/h263": ".h263",
|
||||
"video/h263-1998": ".h263",
|
||||
"video/h263-2000": ".h263",
|
||||
"video/h264": ".h264",
|
||||
"video/h265": ".h265",
|
||||
"video/mp4": ".mp4",
|
||||
"video/mpeg": ".mpg",
|
||||
"video/mpeg4": ".mp4",
|
||||
"video/quicktime": ".mov",
|
||||
"video/webm": ".webm",
|
||||
};
|
||||
const SMS_OUTBOUND_MEDIA_TTL_MS = 10 * 60_000;
|
||||
const SMS_OUTBOUND_MEDIA_ID_RE = /^[a-f0-9]{24}$/;
|
||||
const SMS_OUTBOUND_MEDIA_TOKEN_PARAM_PREFIX = "__openclaw_mms_token";
|
||||
const SMS_OUTBOUND_MEDIA_NAMESPACE = "hosted-outbound-media";
|
||||
const SMS_OUTBOUND_MEDIA_CHUNKS_NAMESPACE = "hosted-outbound-media-chunks";
|
||||
const SMS_OUTBOUND_MEDIA_MAX_ENTRIES = 64;
|
||||
const SMS_OUTBOUND_MEDIA_CHUNK_ROWS_PER_ENTRY_BUDGET = 160;
|
||||
const SMS_OUTBOUND_MEDIA_MAX_CHUNK_ROWS =
|
||||
SMS_OUTBOUND_MEDIA_MAX_ENTRIES * SMS_OUTBOUND_MEDIA_CHUNK_ROWS_PER_ENTRY_BUDGET;
|
||||
|
||||
const hostedSmsMediaStores = new Map<string, HostedOutboundMediaStore>();
|
||||
let hostedSmsMediaRuntime: ReturnType<typeof getSmsRuntime> | undefined;
|
||||
|
||||
type PrepareHostedSmsMediaParams = {
|
||||
account: ResolvedSmsAccount;
|
||||
mediaUrl: string;
|
||||
mediaAccess?: OutboundMediaLoadOptions["mediaAccess"];
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
||||
captionByteLength?: number;
|
||||
};
|
||||
|
||||
type PreparedHostedSmsMedia = {
|
||||
url: string;
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
function normalizeBasePath(path: string): string {
|
||||
const withLeadingSlash = path.trim().startsWith("/") ? path.trim() : `/${path.trim()}`;
|
||||
return withLeadingSlash === "/" ? "" : withLeadingSlash.replace(/\/+$/u, "");
|
||||
}
|
||||
|
||||
function normalizeExactPath(path: string): string {
|
||||
return normalizeBasePath(path) || "/";
|
||||
}
|
||||
|
||||
function toHostedStoreRoutePath(path: string): string {
|
||||
const normalized = normalizeExactPath(path);
|
||||
return normalized === "/" ? normalized : `${normalized}/`;
|
||||
}
|
||||
|
||||
export function resolveSmsHostedMediaRoute(params: {
|
||||
webhookPath: string;
|
||||
publicWebhookUrl: string;
|
||||
}): {
|
||||
localRoutePath: string;
|
||||
publicBaseUrl: string;
|
||||
publicRoutePath: string;
|
||||
publicSearch: string;
|
||||
} {
|
||||
if (!params.publicWebhookUrl.trim()) {
|
||||
throw new Error("MMS send requires channels.sms.publicWebhookUrl.");
|
||||
}
|
||||
const publicWebhookUrl = new URL(params.publicWebhookUrl);
|
||||
if (publicWebhookUrl.protocol !== "https:" || !publicWebhookUrl.hostname) {
|
||||
throw new Error("MMS send requires an HTTPS publicWebhookUrl with a hostname.");
|
||||
}
|
||||
if (publicWebhookUrl.username || publicWebhookUrl.password) {
|
||||
throw new Error("MMS send requires a publicWebhookUrl without embedded HTTP authentication.");
|
||||
}
|
||||
return {
|
||||
localRoutePath: toHostedStoreRoutePath(params.webhookPath),
|
||||
publicBaseUrl: publicWebhookUrl.origin,
|
||||
publicRoutePath: normalizeExactPath(publicWebhookUrl.pathname),
|
||||
publicSearch: publicWebhookUrl.search,
|
||||
};
|
||||
}
|
||||
|
||||
function createHostedSmsMediaStore(
|
||||
runtime: ReturnType<typeof getSmsRuntime>,
|
||||
accountId: string,
|
||||
): HostedOutboundMediaStore {
|
||||
const accountScope = createHash("sha256").update(accountId).digest("hex").slice(0, 16);
|
||||
return createHostedOutboundMediaStore({
|
||||
metadataStore: runtime.state.openKeyedStore<HostedOutboundMediaMetaRecord>({
|
||||
namespace: `${SMS_OUTBOUND_MEDIA_NAMESPACE}-${accountScope}`,
|
||||
maxEntries: SMS_OUTBOUND_MEDIA_MAX_ENTRIES,
|
||||
overflowPolicy: "reject-new",
|
||||
}),
|
||||
chunkStore: runtime.state.openKeyedStore<HostedOutboundMediaChunkRecord>({
|
||||
namespace: `${SMS_OUTBOUND_MEDIA_CHUNKS_NAMESPACE}-${accountScope}`,
|
||||
maxEntries: SMS_OUTBOUND_MEDIA_MAX_CHUNK_ROWS,
|
||||
overflowPolicy: "reject-new",
|
||||
}),
|
||||
ttlMs: SMS_OUTBOUND_MEDIA_TTL_MS,
|
||||
maxEntries: SMS_OUTBOUND_MEDIA_MAX_ENTRIES,
|
||||
maxChunkRows: SMS_OUTBOUND_MEDIA_MAX_CHUNK_ROWS,
|
||||
overflowPolicy: "reject-new",
|
||||
resolveExpiresAtMs: (ttlMs) => resolveExpiresAtMsFromDurationMs(ttlMs),
|
||||
});
|
||||
}
|
||||
|
||||
function getHostedSmsMediaStore(accountId: string): HostedOutboundMediaStore {
|
||||
const runtime = getSmsRuntime();
|
||||
if (hostedSmsMediaRuntime !== runtime) {
|
||||
hostedSmsMediaRuntime = runtime;
|
||||
hostedSmsMediaStores.clear();
|
||||
}
|
||||
const existing = hostedSmsMediaStores.get(accountId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const created = createHostedSmsMediaStore(runtime, accountId);
|
||||
hostedSmsMediaStores.set(accountId, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function createHostedSmsMediaCleanup(
|
||||
store: HostedOutboundMediaStore,
|
||||
id: string,
|
||||
): () => Promise<void> {
|
||||
let cleanup: Promise<void> | undefined;
|
||||
return async () => {
|
||||
const activeCleanup = cleanup ?? store.delete(id);
|
||||
cleanup = activeCleanup;
|
||||
try {
|
||||
await activeCleanup;
|
||||
} catch (error) {
|
||||
if (cleanup === activeCleanup) {
|
||||
cleanup = undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function prepareHostedSmsMedia(
|
||||
params: PrepareHostedSmsMediaParams,
|
||||
): Promise<PreparedHostedSmsMedia> {
|
||||
const route = resolveSmsHostedMediaRoute({
|
||||
webhookPath: params.account.webhookPath,
|
||||
publicWebhookUrl: params.account.publicWebhookUrl,
|
||||
});
|
||||
const store = getHostedSmsMediaStore(params.account.accountId);
|
||||
const captionByteLength = params.captionByteLength ?? 0;
|
||||
if (!Number.isSafeInteger(captionByteLength) || captionByteLength < 0) {
|
||||
throw new Error("MMS caption byte length must be a non-negative integer.");
|
||||
}
|
||||
const aggregateMediaBudget = TWILIO_MMS_IMAGE_MAX_BYTES - captionByteLength - 1;
|
||||
if (aggregateMediaBudget < 1) {
|
||||
throw new Error("Twilio MMS caption leaves no room for an attachment below 5,000,000 bytes.");
|
||||
}
|
||||
const mediaAccess: OutboundMediaLoadOptions["mediaAccess"] = (() => {
|
||||
const localRoots = params.mediaAccess?.localRoots ?? params.mediaLocalRoots;
|
||||
const readFile = params.mediaAccess?.readFile ?? params.mediaReadFile;
|
||||
const workspaceDir = params.mediaAccess?.workspaceDir;
|
||||
if (!localRoots && !readFile && !workspaceDir) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...(localRoots ? { localRoots } : {}),
|
||||
...(readFile ? { readFile } : {}),
|
||||
...(workspaceDir ? { workspaceDir } : {}),
|
||||
};
|
||||
})();
|
||||
const stagedUrl = new URL(
|
||||
await store.prepareUrl({
|
||||
mediaUrl: params.mediaUrl,
|
||||
routePath: route.localRoutePath,
|
||||
publicBaseUrl: route.publicBaseUrl,
|
||||
maxBytes: aggregateMediaBudget,
|
||||
mediaAccess,
|
||||
}),
|
||||
);
|
||||
const id = stagedUrl.pathname.split("/").at(-1) ?? "";
|
||||
const token = stagedUrl.searchParams.get("token");
|
||||
if (!SMS_OUTBOUND_MEDIA_ID_RE.test(id) || !token) {
|
||||
throw new Error("Hosted MMS media URL could not be prepared.");
|
||||
}
|
||||
const cleanup = createHostedSmsMediaCleanup(store, id);
|
||||
const entry = await store.read(id);
|
||||
if (!entry) {
|
||||
throw new Error("Hosted MMS media expired before it could be sent.");
|
||||
}
|
||||
const contentType = entry.metadata.contentType?.split(";", 1)[0]?.trim().toLowerCase();
|
||||
if (!contentType || !TWILIO_MMS_EXTENSION_BY_TYPE[contentType]) {
|
||||
await cleanup();
|
||||
throw new Error(
|
||||
`Twilio MMS does not support media type ${contentType || "unknown content type"}.`,
|
||||
);
|
||||
}
|
||||
if (captionByteLength > 0 && TWILIO_MMS_MEDIA_ONLY_TYPES.has(contentType)) {
|
||||
await cleanup();
|
||||
throw new Error(`Twilio MMS media type ${contentType} must be sent without a caption.`);
|
||||
}
|
||||
const maxBytes = TWILIO_MMS_LARGE_MEDIA_TYPES.has(contentType)
|
||||
? TWILIO_MMS_IMAGE_MAX_BYTES
|
||||
: TWILIO_MMS_OTHER_MAX_BYTES;
|
||||
if (entry.metadata.byteLength > maxBytes) {
|
||||
await cleanup();
|
||||
throw new Error(
|
||||
`Twilio MMS media exceeds the ${maxBytes.toLocaleString("en-US")} byte limit for ${
|
||||
contentType || "unknown content type"
|
||||
}.`,
|
||||
);
|
||||
}
|
||||
if (entry.metadata.byteLength + captionByteLength >= TWILIO_MMS_IMAGE_MAX_BYTES) {
|
||||
await cleanup();
|
||||
throw new Error("Twilio MMS attachment and caption must total less than 5,000,000 bytes.");
|
||||
}
|
||||
|
||||
// The random media id namespaces the token parameter so existing proxy query
|
||||
// parameters survive without being overwritten or becoming the auth value.
|
||||
const tokenParam = `${SMS_OUTBOUND_MEDIA_TOKEN_PARAM_PREFIX}_${id}`;
|
||||
const querySeparator = route.publicSearch ? "&" : "?";
|
||||
return {
|
||||
url: `${route.publicBaseUrl}${route.publicRoutePath}${route.publicSearch}${querySeparator}${tokenParam}=${encodeURIComponent(token)}`,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
function requireTwilioMediaUrl(
|
||||
rawUrl: string,
|
||||
identity: { accountSid: string; messageSid: string },
|
||||
): string {
|
||||
const url = new URL(rawUrl);
|
||||
if (
|
||||
url.protocol !== "https:" ||
|
||||
url.hostname !== TWILIO_API_HOSTNAME ||
|
||||
url.port ||
|
||||
url.username ||
|
||||
url.password
|
||||
) {
|
||||
throw new Error("Twilio MMS media URL must use https://api.twilio.com.");
|
||||
}
|
||||
const match = TWILIO_MEDIA_PATH_RE.exec(url.pathname);
|
||||
if (!match || match[1] !== identity.accountSid || match[2] !== identity.messageSid) {
|
||||
throw new Error("Twilio MMS media URL does not match the inbound message.");
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function inboundMediaUnavailableBody(body: string, count: number): string {
|
||||
return formatInboundMediaUnavailableText({
|
||||
body,
|
||||
notice: `[${count} Twilio MMS attachment${count === 1 ? "" : "s"} unavailable]`,
|
||||
});
|
||||
}
|
||||
|
||||
function inboundMediaFileName(contentType: string | undefined, index: number): string {
|
||||
return `mms-${index + 1}${extensionForMime(contentType) ?? ".bin"}`;
|
||||
}
|
||||
|
||||
function createInboundMediaCleanup(paths: string[]): () => Promise<void> {
|
||||
let cleanup: Promise<void> | undefined;
|
||||
return async () => {
|
||||
cleanup ??= Promise.all(paths.map(async (filePath) => await unlinkIfExists(filePath))).then(
|
||||
() => undefined,
|
||||
);
|
||||
await cleanup;
|
||||
};
|
||||
}
|
||||
|
||||
export async function materializeSmsInboundMedia(params: {
|
||||
account: ResolvedSmsAccount;
|
||||
msg: SmsInboundMessage;
|
||||
mediaRuntime: Pick<PluginRuntime["channel"], "media">;
|
||||
abortSignal?: AbortSignal;
|
||||
log?: { warn?: (message: string) => void };
|
||||
}): Promise<{ body: string; media: InboundMediaFacts[]; cleanup: () => Promise<void> }> {
|
||||
const savedPaths: string[] = [];
|
||||
const cleanup = createInboundMediaCleanup(savedPaths);
|
||||
const declaredUnavailableCount = params.msg.unavailableMediaCount ?? 0;
|
||||
if (params.msg.media.length === 0) {
|
||||
return {
|
||||
body:
|
||||
declaredUnavailableCount > 0
|
||||
? inboundMediaUnavailableBody(params.msg.body, declaredUnavailableCount)
|
||||
: params.msg.body,
|
||||
media: [],
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
const callbackAccountSid = params.msg.accountSid;
|
||||
if (!callbackAccountSid || callbackAccountSid !== params.account.accountSid) {
|
||||
params.log?.warn?.(
|
||||
`Refused Twilio MMS attachments for ${params.msg.messageSid}: webhook account mismatch`,
|
||||
);
|
||||
return {
|
||||
body: inboundMediaUnavailableBody(
|
||||
params.msg.body,
|
||||
declaredUnavailableCount + params.msg.media.length,
|
||||
),
|
||||
media: [],
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
let remainingBytes = TWILIO_MMS_MAX_BYTES;
|
||||
let unavailableCount = declaredUnavailableCount;
|
||||
const batchTimeoutSignal = AbortSignal.timeout(TWILIO_MEDIA_BATCH_TIMEOUT_MS);
|
||||
const abortSignal = params.abortSignal
|
||||
? AbortSignal.any([params.abortSignal, batchTimeoutSignal])
|
||||
: batchTimeoutSignal;
|
||||
const savedMedia: Array<{ path: string; contentType?: string; messageId: string }> = [];
|
||||
try {
|
||||
for (const [index, media] of params.msg.media.entries()) {
|
||||
abortSignal.throwIfAborted();
|
||||
if (remainingBytes <= 0) {
|
||||
unavailableCount += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const saved = await params.mediaRuntime.media.saveRemoteMedia({
|
||||
url: requireTwilioMediaUrl(media.url, {
|
||||
accountSid: callbackAccountSid,
|
||||
messageSid: params.msg.messageSid,
|
||||
}),
|
||||
requestInit: {
|
||||
headers: {
|
||||
authorization: `Basic ${Buffer.from(
|
||||
`${params.account.accountSid}:${params.account.authToken}`,
|
||||
).toString("base64")}`,
|
||||
},
|
||||
signal: abortSignal,
|
||||
},
|
||||
filePathHint: inboundMediaFileName(media.contentType, index),
|
||||
fallbackContentType: media.contentType,
|
||||
maxBytes: remainingBytes,
|
||||
ssrfPolicy: { hostnameAllowlist: [TWILIO_API_HOSTNAME] },
|
||||
timeoutMs: TWILIO_MEDIA_TOTAL_TIMEOUT_MS,
|
||||
responseHeaderTimeoutMs: TWILIO_MEDIA_RESPONSE_HEADER_TIMEOUT_MS,
|
||||
readIdleTimeoutMs: TWILIO_MEDIA_READ_IDLE_TIMEOUT_MS,
|
||||
retry: TWILIO_MEDIA_RETRY,
|
||||
});
|
||||
remainingBytes -= saved.size;
|
||||
savedPaths.push(saved.path);
|
||||
savedMedia.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType ?? media.contentType,
|
||||
messageId: params.msg.messageSid,
|
||||
});
|
||||
} catch {
|
||||
abortSignal.throwIfAborted();
|
||||
unavailableCount += 1;
|
||||
params.log?.warn?.(
|
||||
`Failed to download Twilio MMS attachment ${index + 1} for ${params.msg.messageSid}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const body =
|
||||
unavailableCount > 0
|
||||
? inboundMediaUnavailableBody(params.msg.body, unavailableCount)
|
||||
: params.msg.body;
|
||||
return {
|
||||
body,
|
||||
media: await toInboundMediaFactsWithMetadata(savedMedia),
|
||||
cleanup,
|
||||
};
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function hostedSmsMediaFileName(id: string, contentType: string | undefined): string | undefined {
|
||||
const normalizedContentType = contentType?.split(";", 1)[0]?.trim().toLowerCase();
|
||||
const extension = normalizedContentType
|
||||
? TWILIO_MMS_EXTENSION_BY_TYPE[normalizedContentType]
|
||||
: undefined;
|
||||
return extension ? `mms-${id.slice(0, 10)}${extension}` : undefined;
|
||||
}
|
||||
|
||||
export async function tryHandleHostedSmsMediaRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
accountId = "default",
|
||||
): Promise<boolean> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(req.url ?? "/", "http://localhost");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const tokenCandidates = [...url.searchParams.entries()]
|
||||
.filter(([key]) => key.startsWith(`${SMS_OUTBOUND_MEDIA_TOKEN_PARAM_PREFIX}_`))
|
||||
.map(([key, token]) => ({
|
||||
id: key.slice(SMS_OUTBOUND_MEDIA_TOKEN_PARAM_PREFIX.length + 1),
|
||||
token,
|
||||
}))
|
||||
.filter((candidate) => SMS_OUTBOUND_MEDIA_ID_RE.test(candidate.id));
|
||||
if (tokenCandidates.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (tokenCandidates.length !== 1) {
|
||||
res.statusCode = 400;
|
||||
res.end("Bad Request");
|
||||
return true;
|
||||
}
|
||||
const method = req.method ?? "GET";
|
||||
if (method !== "GET" && method !== "HEAD") {
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Allow", "GET, HEAD");
|
||||
res.end("Method Not Allowed");
|
||||
return true;
|
||||
}
|
||||
const routePath = toHostedStoreRoutePath(url.pathname);
|
||||
const store = getHostedSmsMediaStore(accountId);
|
||||
const candidate = tokenCandidates[0];
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
const metadata = await store.readMetadata(candidate.id);
|
||||
if (!metadata || metadata.routePath !== routePath) {
|
||||
res.statusCode = 404;
|
||||
res.end("Not Found");
|
||||
return true;
|
||||
}
|
||||
if (!safeEqualSecret(candidate.token, metadata.token)) {
|
||||
res.statusCode = 401;
|
||||
res.end("Unauthorized");
|
||||
return true;
|
||||
}
|
||||
|
||||
let servedMetadata = metadata;
|
||||
let body: Buffer | undefined;
|
||||
if (method === "GET") {
|
||||
const entry = await store.read(candidate.id);
|
||||
if (
|
||||
!entry ||
|
||||
entry.metadata.routePath !== routePath ||
|
||||
!safeEqualSecret(candidate.token, entry.metadata.token)
|
||||
) {
|
||||
res.statusCode = 404;
|
||||
res.end("Not Found");
|
||||
return true;
|
||||
}
|
||||
servedMetadata = entry.metadata;
|
||||
body = entry.buffer;
|
||||
}
|
||||
|
||||
const contentType = servedMetadata.contentType ?? "application/octet-stream";
|
||||
const fileName = hostedSmsMediaFileName(candidate.id, contentType);
|
||||
if (!fileName) {
|
||||
res.statusCode = 415;
|
||||
res.end("Unsupported Media Type");
|
||||
return true;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader("Content-Length", String(servedMetadata.byteLength));
|
||||
res.setHeader("Content-Disposition", `inline; filename="${fileName}"`);
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.end(body);
|
||||
return true;
|
||||
}
|
||||
@@ -1,27 +1,64 @@
|
||||
// Sms tests cover send plugin behavior.
|
||||
import { isChannelPartialDeliveryError } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedSmsAccount } from "./types.js";
|
||||
|
||||
type SendModule = typeof import("./send.js");
|
||||
type SendSmsMediaParams = Parameters<SendModule["prepareSmsMediaAttempt"]>[0] &
|
||||
Omit<Parameters<SendModule["sendPreparedSmsMediaAttempt"]>[0], "attempt">;
|
||||
|
||||
let sendSmsTextChunks: SendModule["sendSmsTextChunks"];
|
||||
let prepareSmsMediaAttempt: SendModule["prepareSmsMediaAttempt"];
|
||||
let sendPreparedSmsMediaAttempt: SendModule["sendPreparedSmsMediaAttempt"];
|
||||
let toSmsPlainText: SendModule["toSmsPlainText"];
|
||||
let resolveSmsAccount: (typeof import("./accounts.js"))["resolveSmsAccount"];
|
||||
|
||||
const sendSmsViaTwilio = vi.hoisted(() => vi.fn(async ({ to }) => ({ sid: `SM-${to}`, to })));
|
||||
const sendSmsViaTwilio = vi.hoisted(() =>
|
||||
vi.fn(async ({ to, onPlatformSendDispatch }) => {
|
||||
await onPlatformSendDispatch?.();
|
||||
return { sid: `SM-${to}`, to };
|
||||
}),
|
||||
);
|
||||
const hostedMediaMocks = vi.hoisted(() => {
|
||||
const cleanup = vi.fn(async () => undefined);
|
||||
return {
|
||||
cleanup,
|
||||
prepare: vi.fn(async () => ({
|
||||
url: "https://gateway.example.com/webhooks/sms/media/abc?token=token",
|
||||
cleanup,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
sendSmsViaTwilio.mockClear();
|
||||
sendSmsViaTwilio.mockReset();
|
||||
sendSmsViaTwilio.mockImplementation(async ({ to, onPlatformSendDispatch }) => {
|
||||
await onPlatformSendDispatch?.();
|
||||
return { sid: `SM-${to}`, to };
|
||||
});
|
||||
hostedMediaMocks.cleanup.mockReset();
|
||||
hostedMediaMocks.cleanup.mockResolvedValue(undefined);
|
||||
hostedMediaMocks.prepare.mockReset();
|
||||
hostedMediaMocks.prepare.mockResolvedValue({
|
||||
url: "https://gateway.example.com/webhooks/sms/media/abc?token=token",
|
||||
cleanup: hostedMediaMocks.cleanup,
|
||||
});
|
||||
vi.doMock("./twilio.js", () => ({
|
||||
sendSmsViaTwilio,
|
||||
TWILIO_MESSAGE_BODY_MAX_LENGTH: 1600,
|
||||
}));
|
||||
({ sendSmsTextChunks, toSmsPlainText } = await import("./send.js"));
|
||||
vi.doMock("./media.js", () => ({
|
||||
prepareHostedSmsMedia: hostedMediaMocks.prepare,
|
||||
}));
|
||||
({ prepareSmsMediaAttempt, sendPreparedSmsMediaAttempt, sendSmsTextChunks, toSmsPlainText } =
|
||||
await import("./send.js"));
|
||||
({ resolveSmsAccount } = await import("./accounts.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock("./twilio.js");
|
||||
vi.doUnmock("./media.js");
|
||||
delete process.env.TWILIO_ACCOUNT_SID;
|
||||
delete process.env.TWILIO_AUTH_TOKEN;
|
||||
delete process.env.TWILIO_PHONE_NUMBER;
|
||||
@@ -46,7 +83,38 @@ function createAccount(textChunkLimit: number): ResolvedSmsAccount {
|
||||
};
|
||||
}
|
||||
|
||||
async function sendSmsMedia(params: SendSmsMediaParams) {
|
||||
const attempt = await prepareSmsMediaAttempt(params);
|
||||
return await sendPreparedSmsMediaAttempt({
|
||||
account: params.account,
|
||||
to: params.to,
|
||||
attempt,
|
||||
onPlatformSendDispatch: params.onPlatformSendDispatch,
|
||||
onDeliveryResult: params.onDeliveryResult,
|
||||
});
|
||||
}
|
||||
|
||||
describe("sendSmsTextChunks", () => {
|
||||
it("preserves ambiguous Twilio failures after the dispatch boundary", async () => {
|
||||
const failure = new Error("Twilio response was lost");
|
||||
const onPlatformSendDispatch = vi.fn(async () => {});
|
||||
sendSmsViaTwilio.mockImplementationOnce(async ({ onPlatformSendDispatch: onDispatch }) => {
|
||||
await onDispatch?.();
|
||||
throw failure;
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendSmsTextChunks({
|
||||
account: createAccount(1500),
|
||||
to: "+15551234567",
|
||||
text: "sent or not",
|
||||
onPlatformSendDispatch,
|
||||
}),
|
||||
).rejects.toBe(failure);
|
||||
|
||||
expect(onPlatformSendDispatch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("splits long SMS text before sending to Twilio", async () => {
|
||||
await sendSmsTextChunks({
|
||||
account: createAccount(5),
|
||||
@@ -123,4 +191,223 @@ describe("sendSmsTextChunks", () => {
|
||||
expect(sendSmsViaTwilio).toHaveBeenCalledOnce();
|
||||
expect(sendSmsViaTwilio.mock.calls[0]?.[0].text).toBe("Done.");
|
||||
});
|
||||
|
||||
it("caps configured SMS chunks at Twilio's provider maximum", async () => {
|
||||
await sendSmsTextChunks({
|
||||
account: createAccount(5000),
|
||||
to: "+15551234567",
|
||||
text: "x".repeat(1601),
|
||||
});
|
||||
|
||||
expect(sendSmsViaTwilio).toHaveBeenCalledTimes(2);
|
||||
expect(sendSmsViaTwilio.mock.calls.map(([call]) => call.text?.length)).toEqual([1600, 1]);
|
||||
});
|
||||
|
||||
it("preserves accepted SIDs when a later SMS chunk fails", async () => {
|
||||
const failure = new Error("second chunk failed");
|
||||
const events: string[] = [];
|
||||
sendSmsViaTwilio
|
||||
.mockImplementationOnce(async ({ onPlatformSendDispatch }) => {
|
||||
await onPlatformSendDispatch?.();
|
||||
events.push("send:first");
|
||||
return { sid: "SM-first", to: "+15551234567" };
|
||||
})
|
||||
.mockImplementationOnce(async ({ onPlatformSendDispatch }) => {
|
||||
await onPlatformSendDispatch?.();
|
||||
events.push("send:second");
|
||||
throw failure;
|
||||
});
|
||||
const onDeliveryResult = vi.fn(async (result) => {
|
||||
events.push(`delivery:${result.messageId}`);
|
||||
});
|
||||
const onPlatformSendDispatch = vi.fn(async () => {
|
||||
events.push("dispatch");
|
||||
});
|
||||
|
||||
let observed: unknown;
|
||||
try {
|
||||
await sendSmsTextChunks({
|
||||
account: createAccount(5),
|
||||
to: "+15551234567",
|
||||
text: "alpha beta",
|
||||
onPlatformSendDispatch,
|
||||
onDeliveryResult,
|
||||
});
|
||||
} catch (error) {
|
||||
observed = error;
|
||||
}
|
||||
|
||||
expect(isChannelPartialDeliveryError(observed)).toBe(true);
|
||||
if (!isChannelPartialDeliveryError(observed)) {
|
||||
throw observed;
|
||||
}
|
||||
expect(observed.deliveryResult).toMatchObject({
|
||||
messageIds: ["SM-first"],
|
||||
visibleReplySent: true,
|
||||
receipt: {
|
||||
parts: [{ platformMessageId: "SM-first", kind: "text" }],
|
||||
},
|
||||
});
|
||||
expect(onDeliveryResult).toHaveBeenCalledExactlyOnceWith({
|
||||
channel: "sms",
|
||||
messageId: "SM-first",
|
||||
chatId: "+15551234567",
|
||||
receipt: expect.objectContaining({
|
||||
platformMessageIds: ["SM-first"],
|
||||
parts: [expect.objectContaining({ platformMessageId: "SM-first", kind: "text" })],
|
||||
}),
|
||||
});
|
||||
expect(onPlatformSendDispatch).toHaveBeenCalledTimes(2);
|
||||
expect(events).toEqual([
|
||||
"dispatch",
|
||||
"send:first",
|
||||
"delivery:SM-first",
|
||||
"dispatch",
|
||||
"send:second",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendSmsMedia", () => {
|
||||
it("preserves existing pre-dispatch proof from hosted-media staging", async () => {
|
||||
const { PlatformMessageNotDispatchedError } = await import("openclaw/plugin-sdk/error-runtime");
|
||||
const rejection = new PlatformMessageNotDispatchedError("unsupported hosted media", {
|
||||
cause: new Error("unsupported content type"),
|
||||
retryable: false,
|
||||
});
|
||||
hostedMediaMocks.prepare.mockRejectedValueOnce(rejection);
|
||||
|
||||
await expect(
|
||||
sendSmsMedia({
|
||||
account: createAccount(1500),
|
||||
to: "+15551234567",
|
||||
text: "photo",
|
||||
mediaUrl: "/tmp/photo.jpg",
|
||||
mediaLocalRoots: ["/tmp"],
|
||||
}),
|
||||
).rejects.toBe(rejection);
|
||||
|
||||
expect(sendSmsViaTwilio).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches media only to the first caption chunk and returns every SID in order", async () => {
|
||||
sendSmsViaTwilio
|
||||
.mockResolvedValueOnce({ sid: "MM-first", to: "+15551234567" })
|
||||
.mockResolvedValueOnce({ sid: "SM-second", to: "+15551234567" });
|
||||
|
||||
const results = await sendSmsMedia({
|
||||
account: createAccount(5000),
|
||||
to: "+15551234567",
|
||||
text: "x".repeat(1601),
|
||||
mediaUrl: "/tmp/photo.jpg",
|
||||
mediaLocalRoots: ["/tmp"],
|
||||
});
|
||||
|
||||
expect(results.map((result) => result.sid)).toEqual(["MM-first", "SM-second"]);
|
||||
expect(sendSmsViaTwilio).toHaveBeenNthCalledWith(1, {
|
||||
account: createAccount(5000),
|
||||
to: "+15551234567",
|
||||
text: "x".repeat(1600),
|
||||
mediaUrls: ["https://gateway.example.com/webhooks/sms/media/abc?token=token"],
|
||||
onPlatformSendDispatch: expect.any(Function),
|
||||
});
|
||||
expect(sendSmsViaTwilio).toHaveBeenNthCalledWith(2, {
|
||||
account: createAccount(5000),
|
||||
to: "+15551234567",
|
||||
text: "x",
|
||||
onPlatformSendDispatch: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it("sends media-only MMS without a Body", async () => {
|
||||
await sendSmsMedia({
|
||||
account: createAccount(1500),
|
||||
to: "+15551234567",
|
||||
text: " ",
|
||||
mediaUrl: "/tmp/photo.jpg",
|
||||
mediaLocalRoots: ["/tmp"],
|
||||
});
|
||||
|
||||
expect(sendSmsViaTwilio).toHaveBeenCalledExactlyOnceWith({
|
||||
account: createAccount(1500),
|
||||
to: "+15551234567",
|
||||
mediaUrls: ["https://gateway.example.com/webhooks/sms/media/abc?token=token"],
|
||||
onPlatformSendDispatch: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the accepted MMS when a later caption chunk fails", async () => {
|
||||
const failure = new Error("second chunk failed");
|
||||
const events: string[] = [];
|
||||
sendSmsViaTwilio
|
||||
.mockImplementationOnce(async ({ onPlatformSendDispatch }) => {
|
||||
await onPlatformSendDispatch?.();
|
||||
events.push("send:first");
|
||||
return { sid: "MM-first", to: "+15551234567" };
|
||||
})
|
||||
.mockImplementationOnce(async ({ onPlatformSendDispatch }) => {
|
||||
await onPlatformSendDispatch?.();
|
||||
events.push("send:second");
|
||||
throw failure;
|
||||
});
|
||||
const onDeliveryResult = vi.fn(async (result) => {
|
||||
events.push(`delivery:${result.messageId}`);
|
||||
});
|
||||
const onPlatformSendDispatch = vi.fn(async () => {
|
||||
events.push("dispatch");
|
||||
});
|
||||
|
||||
let observed: unknown;
|
||||
try {
|
||||
await sendSmsMedia({
|
||||
account: createAccount(5),
|
||||
to: "+15551234567",
|
||||
text: "alpha beta gamma",
|
||||
mediaUrl: "/tmp/photo.jpg",
|
||||
mediaLocalRoots: ["/tmp"],
|
||||
onPlatformSendDispatch,
|
||||
onDeliveryResult,
|
||||
});
|
||||
} catch (error) {
|
||||
observed = error;
|
||||
}
|
||||
|
||||
expect(sendSmsViaTwilio).toHaveBeenCalledTimes(2);
|
||||
expect(sendSmsViaTwilio.mock.calls[0]?.[0]).toMatchObject({
|
||||
text: "alpha",
|
||||
mediaUrls: ["https://gateway.example.com/webhooks/sms/media/abc?token=token"],
|
||||
});
|
||||
expect(sendSmsViaTwilio.mock.calls[1]?.[0]).toMatchObject({
|
||||
text: " beta",
|
||||
});
|
||||
expect(sendSmsViaTwilio.mock.calls[1]?.[0]).not.toHaveProperty("mediaUrls");
|
||||
expect(isChannelPartialDeliveryError(observed)).toBe(true);
|
||||
if (!isChannelPartialDeliveryError(observed)) {
|
||||
throw observed;
|
||||
}
|
||||
expect(observed.deliveryResult).toMatchObject({
|
||||
messageIds: ["MM-first"],
|
||||
visibleReplySent: true,
|
||||
receipt: {
|
||||
parts: [{ platformMessageId: "MM-first", kind: "media" }],
|
||||
},
|
||||
});
|
||||
expect(onDeliveryResult).toHaveBeenCalledExactlyOnceWith({
|
||||
channel: "sms",
|
||||
messageId: "MM-first",
|
||||
chatId: "+15551234567",
|
||||
receipt: expect.objectContaining({
|
||||
platformMessageIds: ["MM-first"],
|
||||
parts: [expect.objectContaining({ platformMessageId: "MM-first", kind: "media" })],
|
||||
}),
|
||||
});
|
||||
expect(onPlatformSendDispatch).toHaveBeenCalledTimes(2);
|
||||
expect(events).toEqual([
|
||||
"dispatch",
|
||||
"send:first",
|
||||
"delivery:MM-first",
|
||||
"dispatch",
|
||||
"send:second",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+220
-11
@@ -1,14 +1,92 @@
|
||||
// Sms plugin module implements send behavior.
|
||||
import { createChannelPartialDeliveryError } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
type ChannelMessageSendResult,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
formatErrorMessage,
|
||||
PlatformMessageNotDispatchedError,
|
||||
} from "openclaw/plugin-sdk/error-runtime";
|
||||
import type { OutboundMediaLoadOptions } from "openclaw/plugin-sdk/outbound-media";
|
||||
import {
|
||||
type MarkdownIR,
|
||||
renderMarkdownIRChunksWithinLimit,
|
||||
sanitizeAssistantVisibleText,
|
||||
stripMarkdown,
|
||||
} from "openclaw/plugin-sdk/text-chunking";
|
||||
import { sendSmsViaTwilio } from "./twilio.js";
|
||||
import { sendSmsViaTwilio, TWILIO_MESSAGE_BODY_MAX_LENGTH } from "./twilio.js";
|
||||
import type { ResolvedSmsAccount, SmsSendResult } from "./types.js";
|
||||
|
||||
const SMS_ASSISTANT_TRANSCRIPT_ROLE_PREFIX = "[assistant-authored transcript] ";
|
||||
type SmsMessageKind = "text" | "media";
|
||||
type SmsDeliveryProgressResult = ChannelMessageSendResult & {
|
||||
channel: "sms";
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
};
|
||||
type SmsDeliveryProgress = (result: SmsDeliveryProgressResult) => Promise<void> | void;
|
||||
|
||||
export type PreparedSmsMediaAttempt = {
|
||||
hostedMediaUrl: string;
|
||||
cleanupHostedMedia: () => Promise<void>;
|
||||
caption?: string;
|
||||
remainingChunks: readonly string[];
|
||||
};
|
||||
|
||||
export function createSmsMessageReceipt(params: {
|
||||
results: SmsSendResult[];
|
||||
kind: SmsMessageKind;
|
||||
}) {
|
||||
const receipt = createMessageReceiptFromOutboundResults({
|
||||
results: params.results.map((result) => ({
|
||||
channel: "sms",
|
||||
messageId: result.sid,
|
||||
chatId: result.to,
|
||||
toJid: result.to,
|
||||
conversationId: result.to,
|
||||
meta: {
|
||||
...(result.from ? { from: result.from } : {}),
|
||||
...(result.status ? { status: result.status } : {}),
|
||||
},
|
||||
})),
|
||||
threadId: params.results[0]?.to,
|
||||
kind: params.kind,
|
||||
});
|
||||
if (params.kind === "media") {
|
||||
receipt.parts = receipt.parts.map((part, index) =>
|
||||
index === 0 ? part : { ...part, kind: "text" },
|
||||
);
|
||||
}
|
||||
return receipt;
|
||||
}
|
||||
|
||||
function createSmsDeliveryProgressResult(
|
||||
result: SmsSendResult,
|
||||
kind: SmsMessageKind,
|
||||
): SmsDeliveryProgressResult {
|
||||
return {
|
||||
channel: "sms",
|
||||
messageId: result.sid,
|
||||
chatId: result.to,
|
||||
receipt: createSmsMessageReceipt({ results: [result], kind }),
|
||||
};
|
||||
}
|
||||
|
||||
function throwSmsPartialDeliveryError(
|
||||
error: unknown,
|
||||
results: SmsSendResult[],
|
||||
kind: SmsMessageKind,
|
||||
): never {
|
||||
if (results.length === 0) {
|
||||
throw error;
|
||||
}
|
||||
throw createChannelPartialDeliveryError(error, {
|
||||
messageIds: results.map((result) => result.sid),
|
||||
receipt: createSmsMessageReceipt({ results, kind }),
|
||||
visibleReplySent: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function toSmsPlainText(text: string): string {
|
||||
const visibleText = sanitizeAssistantVisibleText(text);
|
||||
@@ -40,26 +118,157 @@ function chunkSmsPlainText(text: string, limit: number): string[] {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function prepareSmsTextChunks(params: { text: string; configuredLimit: number }): string[] {
|
||||
const text = toSmsPlainText(params.text);
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
return chunkSmsPlainText(text, Math.min(params.configuredLimit, TWILIO_MESSAGE_BODY_MAX_LENGTH));
|
||||
}
|
||||
|
||||
async function sendSmsProviderMessage(params: {
|
||||
account: ResolvedSmsAccount;
|
||||
to: string;
|
||||
text?: string;
|
||||
mediaUrls?: readonly string[];
|
||||
onPlatformSendDispatch?: () => Promise<void>;
|
||||
}): Promise<SmsSendResult> {
|
||||
let platformDispatchStarted = false;
|
||||
try {
|
||||
return await sendSmsViaTwilio({
|
||||
account: params.account,
|
||||
to: params.to,
|
||||
...(params.text !== undefined ? { text: params.text } : {}),
|
||||
...(params.mediaUrls !== undefined ? { mediaUrls: params.mediaUrls } : {}),
|
||||
onPlatformSendDispatch: async () => {
|
||||
// Twilio validates locally before this callback and performs HTTP after it.
|
||||
// Only failures before a persisted dispatch marker are proven safe to replay.
|
||||
await params.onPlatformSendDispatch?.();
|
||||
platformDispatchStarted = true;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (platformDispatchStarted || error instanceof PlatformMessageNotDispatchedError) {
|
||||
throw error;
|
||||
}
|
||||
throw new PlatformMessageNotDispatchedError(
|
||||
`SMS send failed before Twilio dispatch: ${formatErrorMessage(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendSmsTextChunks(params: {
|
||||
account: ResolvedSmsAccount;
|
||||
to: string;
|
||||
text: string;
|
||||
onPlatformSendDispatch?: () => Promise<void>;
|
||||
onDeliveryResult?: SmsDeliveryProgress;
|
||||
}): Promise<SmsSendResult[]> {
|
||||
const text = toSmsPlainText(params.text);
|
||||
if (!text) {
|
||||
const chunks = prepareSmsTextChunks({
|
||||
text: params.text,
|
||||
configuredLimit: params.account.textChunkLimit,
|
||||
});
|
||||
if (chunks.length === 0) {
|
||||
throw new Error("SMS send requires non-empty text.");
|
||||
}
|
||||
const chunks = chunkSmsPlainText(text, params.account.textChunkLimit);
|
||||
const sendChunks = chunks.length ? chunks : [text];
|
||||
const results: SmsSendResult[] = [];
|
||||
for (const textLocal of sendChunks) {
|
||||
results.push(
|
||||
await sendSmsViaTwilio({
|
||||
try {
|
||||
for (const text of chunks) {
|
||||
const result = await sendSmsProviderMessage({
|
||||
account: params.account,
|
||||
to: params.to,
|
||||
text: textLocal,
|
||||
}),
|
||||
);
|
||||
text,
|
||||
onPlatformSendDispatch: params.onPlatformSendDispatch,
|
||||
});
|
||||
results.push(result);
|
||||
await params.onDeliveryResult?.(createSmsDeliveryProgressResult(result, "text"));
|
||||
}
|
||||
} catch (error) {
|
||||
throwSmsPartialDeliveryError(error, results, "text");
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function prepareSmsMediaAttempt(params: {
|
||||
account: ResolvedSmsAccount;
|
||||
text: string;
|
||||
mediaUrl: string;
|
||||
mediaAccess?: OutboundMediaLoadOptions["mediaAccess"];
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
||||
}): Promise<PreparedSmsMediaAttempt> {
|
||||
if (!params.mediaUrl) {
|
||||
throw new Error("MMS send requires mediaUrl.");
|
||||
}
|
||||
const chunks = prepareSmsTextChunks({
|
||||
text: params.text,
|
||||
configuredLimit: params.account.textChunkLimit,
|
||||
});
|
||||
const [caption, ...remainingChunks] = chunks;
|
||||
let hostedMedia: Pick<PreparedSmsMediaAttempt, "hostedMediaUrl" | "cleanupHostedMedia">;
|
||||
try {
|
||||
const { prepareHostedSmsMedia } = await import("./media.js");
|
||||
const prepared = await prepareHostedSmsMedia({
|
||||
account: params.account,
|
||||
mediaUrl: params.mediaUrl,
|
||||
mediaAccess: params.mediaAccess,
|
||||
mediaLocalRoots: params.mediaLocalRoots,
|
||||
mediaReadFile: params.mediaReadFile,
|
||||
captionByteLength: Buffer.byteLength(caption ?? "", "utf8"),
|
||||
});
|
||||
hostedMedia = {
|
||||
hostedMediaUrl: prepared.url,
|
||||
cleanupHostedMedia: prepared.cleanup,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof PlatformMessageNotDispatchedError) {
|
||||
throw error;
|
||||
}
|
||||
// Hosting completes before Twilio's POST boundary. Preserve that proof so
|
||||
// durable recovery cannot mistake a staging failure for an unknown send.
|
||||
throw new PlatformMessageNotDispatchedError(
|
||||
`SMS media preparation failed before Twilio dispatch: ${formatErrorMessage(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
return {
|
||||
...hostedMedia,
|
||||
...(caption ? { caption } : {}),
|
||||
remainingChunks,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendPreparedSmsMediaAttempt(params: {
|
||||
account: ResolvedSmsAccount;
|
||||
to: string;
|
||||
attempt: PreparedSmsMediaAttempt;
|
||||
onPlatformSendDispatch?: () => Promise<void>;
|
||||
onDeliveryResult?: SmsDeliveryProgress;
|
||||
}): Promise<SmsSendResult[]> {
|
||||
const results: SmsSendResult[] = [];
|
||||
try {
|
||||
const mediaResult = await sendSmsProviderMessage({
|
||||
account: params.account,
|
||||
to: params.to,
|
||||
...(params.attempt.caption ? { text: params.attempt.caption } : {}),
|
||||
mediaUrls: [params.attempt.hostedMediaUrl],
|
||||
onPlatformSendDispatch: params.onPlatformSendDispatch,
|
||||
});
|
||||
results.push(mediaResult);
|
||||
await params.onDeliveryResult?.(createSmsDeliveryProgressResult(mediaResult, "media"));
|
||||
for (const text of params.attempt.remainingChunks) {
|
||||
const result = await sendSmsProviderMessage({
|
||||
account: params.account,
|
||||
to: params.to,
|
||||
text,
|
||||
onPlatformSendDispatch: params.onPlatformSendDispatch,
|
||||
});
|
||||
results.push(result);
|
||||
await params.onDeliveryResult?.(createSmsDeliveryProgressResult(result, "text"));
|
||||
}
|
||||
} catch (error) {
|
||||
throwSmsPartialDeliveryError(error, results, "media");
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -118,6 +118,92 @@ describe("Twilio SMS helpers", () => {
|
||||
body: "hello there",
|
||||
messageSid: "SM123",
|
||||
accountSid: "",
|
||||
media: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses media-only Twilio MMS callbacks in provider order", async () => {
|
||||
const form = await readTestTwilioForm(
|
||||
[
|
||||
"From=%2B15551234567",
|
||||
"To=%2B15557654321",
|
||||
"Body=",
|
||||
"MessageSid=MM123",
|
||||
"NumMedia=2",
|
||||
"MediaUrl0=https%3A%2F%2Fapi.twilio.com%2Fmedia%2Ffirst",
|
||||
"MediaContentType0=image%2Fjpeg",
|
||||
"MediaUrl1=https%3A%2F%2Fapi.twilio.com%2Fmedia%2Fsecond",
|
||||
"MediaContentType1=video%2Fmp4",
|
||||
].join("&"),
|
||||
);
|
||||
|
||||
expect(buildTwilioInboundMessage(form)).toEqual({
|
||||
from: "+15551234567",
|
||||
to: "+15557654321",
|
||||
body: "",
|
||||
messageSid: "MM123",
|
||||
accountSid: "",
|
||||
media: [
|
||||
{ url: "https://api.twilio.com/media/first", contentType: "image/jpeg" },
|
||||
{ url: "https://api.twilio.com/media/second", contentType: "video/mp4" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the signed Messaging Service identity on inbound messages", () => {
|
||||
expect(
|
||||
buildTwilioInboundMessage({
|
||||
AccountSid: "AC123",
|
||||
MessagingServiceSid: "MG123",
|
||||
From: "+15551234567",
|
||||
To: "+15557654321",
|
||||
Body: "hello",
|
||||
MessageSid: "SM123",
|
||||
}),
|
||||
).toMatchObject({
|
||||
accountSid: "AC123",
|
||||
messagingServiceSid: "MG123",
|
||||
messageSid: "SM123",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed counts and marks missing Twilio MMS media as unavailable", () => {
|
||||
const base = {
|
||||
From: "+15551234567",
|
||||
To: "+15557654321",
|
||||
Body: "",
|
||||
MessageSid: "MM123",
|
||||
MediaUrl0: "https://api.twilio.com/media/first",
|
||||
};
|
||||
expect(buildTwilioInboundMessage({ ...base, NumMedia: "not-a-number" })).toBeNull();
|
||||
expect(buildTwilioInboundMessage({ ...base, NumMedia: "1", MediaUrl0: "" })).toMatchObject({
|
||||
media: [],
|
||||
unavailableMediaCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds inbound downloads without discarding a signed message with more media", () => {
|
||||
const mediaFields = Object.fromEntries(
|
||||
Array.from({ length: 11 }, (_value, index) => [
|
||||
`MediaUrl${index}`,
|
||||
`https://api.twilio.com/media/${index}`,
|
||||
]),
|
||||
);
|
||||
|
||||
expect(
|
||||
buildTwilioInboundMessage({
|
||||
From: "+15551234567",
|
||||
To: "+15557654321",
|
||||
Body: "",
|
||||
MessageSid: "MM123",
|
||||
NumMedia: "11",
|
||||
...mediaFields,
|
||||
}),
|
||||
).toMatchObject({
|
||||
media: Array.from({ length: 10 }, (_value, index) => ({
|
||||
url: `https://api.twilio.com/media/${index}`,
|
||||
})),
|
||||
unavailableMediaCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -257,6 +343,110 @@ describe("Twilio SMS helpers", () => {
|
||||
expect(body.get("Body")).toBe("hello");
|
||||
});
|
||||
|
||||
it("marks dispatch immediately before the Twilio POST", async () => {
|
||||
const events: string[] = [];
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => {
|
||||
events.push("post");
|
||||
return new Response(JSON.stringify({ sid: "SM-dispatched" }), {
|
||||
status: 201,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
const onPlatformSendDispatch = vi.fn(async () => {
|
||||
events.push("dispatch");
|
||||
});
|
||||
|
||||
await sendSmsViaTwilio({
|
||||
account: createAccount(),
|
||||
to: "+15551234567",
|
||||
text: "hello",
|
||||
onPlatformSendDispatch,
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
expect(events).toEqual(["dispatch", "post"]);
|
||||
expect(onPlatformSendDispatch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not mark dispatch for an invalid Twilio send", async () => {
|
||||
const onPlatformSendDispatch = vi.fn(async () => {});
|
||||
|
||||
await expect(
|
||||
sendSmsViaTwilio({
|
||||
account: createAccount(),
|
||||
to: "+15551234567",
|
||||
onPlatformSendDispatch,
|
||||
}),
|
||||
).rejects.toThrow("Twilio SMS/MMS send requires text or media.");
|
||||
|
||||
expect(onPlatformSendDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends MMS with repeated MediaUrl fields and no required text body", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ sid: "MM456" }), {
|
||||
status: 201,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
await sendSmsViaTwilio({
|
||||
account: createAccount(),
|
||||
to: "+15551234567",
|
||||
mediaUrls: [
|
||||
"https://gateway.example.com/media/first",
|
||||
"https://gateway.example.com/media/second",
|
||||
],
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
const body = readUrlEncodedRequestBody(fetchImpl.mock.calls[0]?.[1]);
|
||||
expect(body.get("Body")).toBeNull();
|
||||
expect(body.getAll("MediaUrl")).toEqual([
|
||||
"https://gateway.example.com/media/first",
|
||||
"https://gateway.example.com/media/second",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects outbound MMS requests above Twilio's media count limit", async () => {
|
||||
await expect(
|
||||
sendSmsViaTwilio({
|
||||
account: createAccount(),
|
||||
to: "+15551234567",
|
||||
mediaUrls: Array.from({ length: 11 }, (_, index) => `https://example.com/${index}.jpg`),
|
||||
}),
|
||||
).rejects.toThrow("Twilio MMS send supports at most 10 media URLs");
|
||||
});
|
||||
|
||||
it("enforces Twilio's provider-owned Message Body limit", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ sid: "SM1600" }), {
|
||||
status: 201,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
sendSmsViaTwilio({
|
||||
account: createAccount(),
|
||||
to: "+15551234567",
|
||||
text: "x".repeat(1600),
|
||||
fetchImpl,
|
||||
}),
|
||||
).resolves.toMatchObject({ sid: "SM1600" });
|
||||
await expect(
|
||||
sendSmsViaTwilio({
|
||||
account: createAccount(),
|
||||
to: "+15551234567",
|
||||
text: "x".repeat(1601),
|
||||
fetchImpl,
|
||||
}),
|
||||
).rejects.toThrow("Twilio SMS/MMS Body supports at most 1600 characters");
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lists Twilio phone-number webhook settings", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(
|
||||
async () =>
|
||||
|
||||
@@ -22,6 +22,10 @@ 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;
|
||||
export const TWILIO_MESSAGE_BODY_MAX_LENGTH = 1600;
|
||||
const TWILIO_MMS_MAX_OUTBOUND_MEDIA_COUNT = 10;
|
||||
export const TWILIO_MMS_MAX_BYTES = 5 * 1024 * 1024;
|
||||
const SMS_MAX_INBOUND_MEDIA_DOWNLOADS = 10;
|
||||
|
||||
type ParsedTwilioApiError = {
|
||||
code?: number;
|
||||
@@ -244,12 +248,43 @@ export function buildTwilioInboundMessage(form: Record<string, string>): SmsInbo
|
||||
const from = resolveTwilioInboundSender(form);
|
||||
const to = firstTrimmedString(form.To);
|
||||
const body = firstString(form.Body);
|
||||
const accountSid = firstTrimmedString(form.AccountSid);
|
||||
const accountSid = firstString(form.AccountSid);
|
||||
const messagingServiceSid = firstString(form.MessagingServiceSid);
|
||||
const messageSid = resolveTwilioMessageSid(form);
|
||||
if (!from || !to || !body || !messageSid) {
|
||||
const rawMediaCount = firstTrimmedString(form.NumMedia);
|
||||
const mediaCount = rawMediaCount ? Number(rawMediaCount) : 0;
|
||||
if (!Number.isSafeInteger(mediaCount) || mediaCount < 0) {
|
||||
return null;
|
||||
}
|
||||
return { accountSid, from, to, body, messageSid };
|
||||
let unavailableMediaCount = Math.max(0, mediaCount - SMS_MAX_INBOUND_MEDIA_DOWNLOADS);
|
||||
const media = Array.from(
|
||||
{ length: Math.min(mediaCount, SMS_MAX_INBOUND_MEDIA_DOWNLOADS) },
|
||||
(_value, index) => {
|
||||
const url = firstTrimmedString(form[`MediaUrl${index}`]);
|
||||
const contentType = firstTrimmedString(form[`MediaContentType${index}`]);
|
||||
if (!url) {
|
||||
unavailableMediaCount += 1;
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
url,
|
||||
...(contentType ? { contentType } : {}),
|
||||
};
|
||||
},
|
||||
).filter((item): item is NonNullable<typeof item> => item !== null);
|
||||
if (!from || !to || (!body && mediaCount === 0) || !messageSid) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
accountSid,
|
||||
from,
|
||||
to,
|
||||
body,
|
||||
messageSid,
|
||||
media,
|
||||
...(messagingServiceSid ? { messagingServiceSid } : {}),
|
||||
...(unavailableMediaCount > 0 ? { unavailableMediaCount } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveTwilioMessageSid(form: Record<string, string>): string {
|
||||
@@ -524,16 +559,35 @@ export async function listTwilioMessages(params: {
|
||||
export async function sendSmsViaTwilio(params: {
|
||||
account: ResolvedSmsAccount;
|
||||
to: string;
|
||||
text: string;
|
||||
text?: string;
|
||||
mediaUrls?: readonly string[];
|
||||
fetchImpl?: typeof fetch;
|
||||
onPlatformSendDispatch?: () => Promise<void>;
|
||||
}): 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.text && params.text.length > TWILIO_MESSAGE_BODY_MAX_LENGTH) {
|
||||
throw new Error(
|
||||
`Twilio SMS/MMS Body supports at most ${TWILIO_MESSAGE_BODY_MAX_LENGTH} characters.`,
|
||||
);
|
||||
}
|
||||
const mediaUrls = (params.mediaUrls ?? []).map((url) => url.trim()).filter(Boolean);
|
||||
if (!params.text && mediaUrls.length === 0) {
|
||||
throw new Error("Twilio SMS/MMS send requires text or media.");
|
||||
}
|
||||
if (mediaUrls.length > TWILIO_MMS_MAX_OUTBOUND_MEDIA_COUNT) {
|
||||
throw new Error(
|
||||
`Twilio MMS send supports at most ${TWILIO_MMS_MAX_OUTBOUND_MEDIA_COUNT} media URLs.`,
|
||||
);
|
||||
}
|
||||
const body = new URLSearchParams({ To: params.to });
|
||||
if (params.text) {
|
||||
body.set("Body", params.text);
|
||||
}
|
||||
for (const mediaUrl of mediaUrls) {
|
||||
body.append("MediaUrl", mediaUrl);
|
||||
}
|
||||
if (params.account.fromNumber) {
|
||||
body.set("From", params.account.fromNumber);
|
||||
} else {
|
||||
@@ -546,6 +600,7 @@ export async function sendSmsViaTwilio(params: {
|
||||
},
|
||||
body,
|
||||
} satisfies RequestInit;
|
||||
await params.onPlatformSendDispatch?.();
|
||||
const response = await requestTwilioApi({
|
||||
account: params.account,
|
||||
url: twilioApiUrl(params.account.accountSid, "/Messages.json"),
|
||||
|
||||
@@ -42,11 +42,19 @@ export interface ResolvedSmsAccount {
|
||||
export interface SmsInboundMessage {
|
||||
messageSid: string;
|
||||
accountSid: string;
|
||||
messagingServiceSid?: string;
|
||||
from: string;
|
||||
to: string;
|
||||
body: string;
|
||||
media: SmsInboundMedia[];
|
||||
unavailableMediaCount?: number;
|
||||
}
|
||||
|
||||
type SmsInboundMedia = {
|
||||
url: string;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export type SmsSendResult = {
|
||||
sid: string;
|
||||
to: string;
|
||||
|
||||
@@ -181,6 +181,42 @@ describe("zalo outbound hosted media", () => {
|
||||
expect(secondResponse.res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("serves HEAD metadata without consuming the hosted media", async () => {
|
||||
const hostedUrl = await prepareHostedZaloMediaUrl({
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
webhookUrl: "https://gateway.example.com/zalo-webhook",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
const { pathname, search } = new URL(hostedUrl);
|
||||
const headResponse = createMockResponse();
|
||||
|
||||
const handledHead = await tryHandleHostedZaloMediaRequest(
|
||||
{
|
||||
method: "HEAD",
|
||||
url: `${pathname}${search}`,
|
||||
} as never,
|
||||
headResponse.res as never,
|
||||
);
|
||||
|
||||
expect(handledHead).toBe(true);
|
||||
expect(headResponse.res.statusCode).toBe(200);
|
||||
expect(headResponse.headers.get("Content-Length")).toBe(
|
||||
String(Buffer.byteLength("image-bytes")),
|
||||
);
|
||||
expect(headResponse.res.end).toHaveBeenCalledWith(undefined);
|
||||
|
||||
const getResponse = createMockResponse();
|
||||
await tryHandleHostedZaloMediaRequest(
|
||||
{
|
||||
method: "GET",
|
||||
url: `${pathname}${search}`,
|
||||
} as never,
|
||||
getResponse.res as never,
|
||||
);
|
||||
expect(getResponse.res.statusCode).toBe(200);
|
||||
expect(getResponse.res.end).toHaveBeenCalledWith(Buffer.from("image-bytes"));
|
||||
});
|
||||
|
||||
it("rejects hosted media preparation when the expiry would exceed a valid Date", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
|
||||
@@ -146,14 +146,14 @@ export async function tryHandleHostedZaloMediaRequest(
|
||||
return true;
|
||||
}
|
||||
|
||||
const entry = await store.read(id, now);
|
||||
if (!entry || entry.metadata.routePath !== routePath) {
|
||||
const metadata = await store.readMetadata(id, now);
|
||||
if (!metadata || metadata.routePath !== routePath) {
|
||||
res.statusCode = 404;
|
||||
res.end("Not Found");
|
||||
return true;
|
||||
}
|
||||
|
||||
const expiresAt = asDateTimestampMs(entry.metadata.expiresAt);
|
||||
const expiresAt = asDateTimestampMs(metadata.expiresAt);
|
||||
if (expiresAt === undefined || expiresAt <= now) {
|
||||
await store.delete(id);
|
||||
res.statusCode = 410;
|
||||
@@ -161,27 +161,41 @@ export async function tryHandleHostedZaloMediaRequest(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!safeEqualSecret(url.searchParams.get("token"), entry.metadata.token)) {
|
||||
const token = url.searchParams.get("token");
|
||||
if (!safeEqualSecret(token, metadata.token)) {
|
||||
res.statusCode = 401;
|
||||
res.end("Unauthorized");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entry.metadata.contentType) {
|
||||
res.setHeader("Content-Type", entry.metadata.contentType);
|
||||
let servedMetadata = metadata;
|
||||
let body: Buffer | undefined;
|
||||
if (method === "GET") {
|
||||
const entry = await store.read(id, now);
|
||||
if (
|
||||
!entry ||
|
||||
entry.metadata.routePath !== routePath ||
|
||||
!safeEqualSecret(token, entry.metadata.token)
|
||||
) {
|
||||
res.statusCode = 404;
|
||||
res.end("Not Found");
|
||||
return true;
|
||||
}
|
||||
servedMetadata = entry.metadata;
|
||||
body = entry.buffer;
|
||||
}
|
||||
|
||||
if (servedMetadata.contentType) {
|
||||
res.setHeader("Content-Type", servedMetadata.contentType);
|
||||
}
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.setHeader("Content-Length", String(entry.metadata.byteLength));
|
||||
|
||||
res.setHeader("Content-Length", String(servedMetadata.byteLength));
|
||||
res.statusCode = 200;
|
||||
res.end(body);
|
||||
if (method === "HEAD") {
|
||||
res.statusCode = 200;
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.end(entry.buffer);
|
||||
await store.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
isInboundPathAllowed,
|
||||
isValidInboundPathRootPattern,
|
||||
mergeInboundPathRoots,
|
||||
resolveInboundPathRoot,
|
||||
} from "./inbound-path-policy.js";
|
||||
|
||||
describe("inbound-path-policy", () => {
|
||||
@@ -56,6 +57,45 @@ describe("inbound-path-policy", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves wildcard patterns to the concrete matched root", () => {
|
||||
expect(
|
||||
resolveInboundPathRoot({
|
||||
filePath: "/Users/alice/Library/Messages/Attachments/12/34/IMG_0001.jpeg",
|
||||
roots: ["/Users/*/Library/Messages/Attachments"],
|
||||
}),
|
||||
).toEqual({
|
||||
anchorRoot: "/Users",
|
||||
matchedRoot: "/Users/alice/Library/Messages/Attachments",
|
||||
});
|
||||
expect(
|
||||
resolveInboundPathRoot({
|
||||
filePath: "C:\\Users\\Alice\\Library\\Messages\\Attachments\\12\\IMG_0001.jpeg",
|
||||
roots: ["c:/users/*/library/messages/attachments"],
|
||||
}),
|
||||
).toEqual({
|
||||
anchorRoot: "c:/users",
|
||||
matchedRoot: "c:/users/alice/library/messages/attachments",
|
||||
});
|
||||
expect(
|
||||
resolveInboundPathRoot({
|
||||
filePath: "/tmp/inbound/file.bin",
|
||||
roots: ["/*"],
|
||||
}),
|
||||
).toEqual({
|
||||
anchorRoot: "/",
|
||||
matchedRoot: "/tmp",
|
||||
});
|
||||
expect(
|
||||
resolveInboundPathRoot({
|
||||
filePath: "C:\\inbound\\file.bin",
|
||||
roots: ["c:/*"],
|
||||
}),
|
||||
).toEqual({
|
||||
anchorRoot: "c:/",
|
||||
matchedRoot: "c:/inbound",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "normalizes and de-duplicates merged roots",
|
||||
|
||||
@@ -30,23 +30,50 @@ function splitPathSegments(value: string): string[] {
|
||||
return value.split("/").filter(Boolean);
|
||||
}
|
||||
|
||||
function matchesRootPattern(params: { candidatePath: string; rootPattern: string }): boolean {
|
||||
export type InboundPathRootMatch = {
|
||||
anchorRoot: string;
|
||||
matchedRoot: string;
|
||||
};
|
||||
|
||||
function joinAbsolutePathSegments(candidatePath: string, segments: readonly string[]): string {
|
||||
const joined = segments.join("/");
|
||||
if (!WINDOWS_DRIVE_ABS_RE.test(candidatePath)) {
|
||||
return `/${joined}`;
|
||||
}
|
||||
return segments.length === 1 ? `${joined}/` : joined;
|
||||
}
|
||||
|
||||
function resolveRootPatternMatch(params: {
|
||||
candidatePath: string;
|
||||
rootPattern: string;
|
||||
}): InboundPathRootMatch | undefined {
|
||||
const candidateSegments = splitPathSegments(params.candidatePath);
|
||||
const rootSegments = splitPathSegments(params.rootPattern);
|
||||
if (candidateSegments.length < rootSegments.length) {
|
||||
return false;
|
||||
return undefined;
|
||||
}
|
||||
for (let idx = 0; idx < rootSegments.length; idx += 1) {
|
||||
const expected = rootSegments[idx];
|
||||
const resolvedSegments: string[] = [];
|
||||
for (const [idx, expected] of rootSegments.entries()) {
|
||||
const actual = candidateSegments[idx];
|
||||
if (!actual) {
|
||||
return undefined;
|
||||
}
|
||||
if (expected === WILDCARD_SEGMENT) {
|
||||
resolvedSegments.push(actual);
|
||||
continue;
|
||||
}
|
||||
if (expected !== actual) {
|
||||
return false;
|
||||
return undefined;
|
||||
}
|
||||
resolvedSegments.push(expected);
|
||||
}
|
||||
return true;
|
||||
const firstWildcardIndex = rootSegments.indexOf(WILDCARD_SEGMENT);
|
||||
const anchorSegments =
|
||||
firstWildcardIndex === -1 ? resolvedSegments : rootSegments.slice(0, firstWildcardIndex);
|
||||
return {
|
||||
anchorRoot: joinAbsolutePathSegments(params.candidatePath, anchorSegments),
|
||||
matchedRoot: joinAbsolutePathSegments(params.candidatePath, resolvedSegments),
|
||||
};
|
||||
}
|
||||
|
||||
/** Validates an absolute inbound root pattern with whole-segment wildcards only. */
|
||||
@@ -102,21 +129,36 @@ export function mergeInboundPathRoots(
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Resolves the concrete lexical root matched by an inbound path pattern. */
|
||||
export function resolveInboundPathRoot(params: {
|
||||
filePath: string;
|
||||
roots: readonly string[];
|
||||
fallbackRoots?: readonly string[];
|
||||
}): InboundPathRootMatch | undefined {
|
||||
const candidatePath = normalizePosixAbsolutePath(params.filePath);
|
||||
if (!candidatePath) {
|
||||
return undefined;
|
||||
}
|
||||
const roots = normalizeInboundPathRoots(params.roots);
|
||||
const effectiveRoots =
|
||||
roots.length > 0 ? roots : normalizeInboundPathRoots(params.fallbackRoots ?? undefined);
|
||||
if (effectiveRoots.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
for (const rootPattern of effectiveRoots) {
|
||||
const resolved = resolveRootPatternMatch({ candidatePath, rootPattern });
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Checks whether a candidate inbound media path is covered by configured or fallback roots. */
|
||||
export function isInboundPathAllowed(params: {
|
||||
filePath: string;
|
||||
roots: readonly string[];
|
||||
fallbackRoots?: readonly string[];
|
||||
}): boolean {
|
||||
const candidatePath = normalizePosixAbsolutePath(params.filePath);
|
||||
if (!candidatePath) {
|
||||
return false;
|
||||
}
|
||||
const roots = normalizeInboundPathRoots(params.roots);
|
||||
const effectiveRoots =
|
||||
roots.length > 0 ? roots : normalizeInboundPathRoots(params.fallbackRoots ?? undefined);
|
||||
if (effectiveRoots.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return effectiveRoots.some((rootPattern) => matchesRootPattern({ candidatePath, rootPattern }));
|
||||
return resolveInboundPathRoot(params) !== undefined;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,16 @@ describe("redactSensitiveUrl", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts resource-scoped bearer token query params", () => {
|
||||
expect(
|
||||
redactSensitiveUrl(
|
||||
`https://gateway.example.com/webhooks/sms?upstream-token=keep&__openclaw_mms_token_${"a".repeat(24)}=${"b".repeat(48)}`,
|
||||
),
|
||||
).toBe(
|
||||
`https://gateway.example.com/webhooks/sms?upstream-token=***&__openclaw_mms_token_${"a".repeat(24)}=***`,
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts encoded and invisible-spliced sensitive query param names", () => {
|
||||
expect(
|
||||
redactSensitiveUrl("https://example.com/mcp?client%5Fse%E2%80%8Bcret=secret&safe=value"),
|
||||
@@ -287,8 +297,11 @@ describe("isSensitiveUrlQueryParamName", () => {
|
||||
expect(isSensitiveUrlQueryParamName("X-Api-Key")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("x-access-token")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("x-auth-token")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("upstream-token")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName(`__openclaw_mms_token_${"a".repeat(24)}`)).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("signal")).toBe(false);
|
||||
expect(isSensitiveUrlQueryParamName("sigmoid")).toBe(false);
|
||||
expect(isSensitiveUrlQueryParamName("token_count")).toBe(false);
|
||||
expect(isSensitiveUrlQueryParamName("x-api-version")).toBe(false);
|
||||
expect(isSensitiveUrlQueryParamName("x-request-id")).toBe(false);
|
||||
expect(isSensitiveUrlQueryParamName("safe")).toBe(false);
|
||||
|
||||
@@ -44,6 +44,8 @@ const SENSITIVE_URL_QUERY_PARAM_NAMES = new Set([
|
||||
]);
|
||||
// Align with FORM_BODY_KEY_SEPARATOR_RE: category-Lo Hangul fillers can splice sensitive names.
|
||||
const URL_QUERY_NAME_SEPARATOR_RE = /[\p{C}\p{Z}\u115F\u1160\u3164\uFFA0+]/gu;
|
||||
// Proxy and per-resource bearer URLs may prefix a token key or suffix it with a random hex id.
|
||||
const SUFFIXED_OR_SCOPED_TOKEN_QUERY_PARAM_RE = /(?:^|_)token(?:_[a-f0-9]{16,})?$/u;
|
||||
|
||||
// Telegram bot credentials use `/bot<token>/...`; align this shape with logging/redact.ts.
|
||||
const TELEGRAM_BOT_TOKEN_PATH_RE = /\/bot\d{6,}(?::|%3[aA])[A-Za-z0-9_-]{20,}(?=\/|$)/giu;
|
||||
@@ -118,7 +120,11 @@ function looksLikeNestedUrlValue(value: string): boolean {
|
||||
/** True for auth-like URL query parameter names that should be redacted. */
|
||||
export function isSensitiveUrlQueryParamName(name: string): boolean {
|
||||
const normalized = normalizeUrlQueryParamName(name);
|
||||
return normalized.unresolvedEncoding || SENSITIVE_URL_QUERY_PARAM_NAMES.has(normalized.value);
|
||||
return (
|
||||
normalized.unresolvedEncoding ||
|
||||
SENSITIVE_URL_QUERY_PARAM_NAMES.has(normalized.value) ||
|
||||
SUFFIXED_OR_SCOPED_TOKEN_QUERY_PARAM_RE.test(normalized.value)
|
||||
);
|
||||
}
|
||||
|
||||
/** True for config paths whose URL values may contain credentials or secret query params. */
|
||||
|
||||
@@ -1917,7 +1917,7 @@
|
||||
{
|
||||
"name": "@openclaw/sms",
|
||||
"version": "2026.7.2",
|
||||
"description": "OpenClaw SMS channel plugin for Twilio text messages.",
|
||||
"description": "OpenClaw SMS/MMS channel plugin for Twilio messages.",
|
||||
"source": "official",
|
||||
"kind": "channel",
|
||||
"openclaw": {
|
||||
@@ -1939,10 +1939,10 @@
|
||||
},
|
||||
"label": "SMS",
|
||||
"selectionLabel": "SMS (Twilio)",
|
||||
"detailLabel": "Twilio SMS",
|
||||
"detailLabel": "Twilio SMS/MMS",
|
||||
"docsPath": "/channels/sms",
|
||||
"docsLabel": "sms",
|
||||
"blurb": "Twilio-backed SMS with inbound webhooks and outbound replies.",
|
||||
"blurb": "Twilio-backed SMS/MMS with inbound webhooks and outbound replies.",
|
||||
"order": 88,
|
||||
"quickstartAllowFrom": true,
|
||||
"setup": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -284,6 +284,35 @@ describe("createGatewayPluginRequestHandler", () => {
|
||||
expect(prefixGatewayHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps hosted-media bearer query strings out of route-auth logs", async () => {
|
||||
const warn = vi.fn();
|
||||
const log = { warn } as unknown as PluginHandlerLog;
|
||||
const handler = createGatewayPluginRequestHandler({
|
||||
registry: createTestRegistry({
|
||||
httpRoutes: [createRoute({ path: "/webhooks/sms", auth: "gateway" })],
|
||||
}),
|
||||
log,
|
||||
});
|
||||
const tokenParam = `__openclaw_mms_token_${"a".repeat(24)}`;
|
||||
const { res } = makeMockHttpResponse();
|
||||
|
||||
const handled = await handler(
|
||||
{
|
||||
url: `/webhooks/sms?upstream-token=proxy-secret&${tokenParam}=media-secret`,
|
||||
} as IncomingMessage,
|
||||
res,
|
||||
undefined,
|
||||
{ gatewayAuthSatisfied: false },
|
||||
);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"plugin http route blocked without gateway auth (/webhooks/sms)",
|
||||
);
|
||||
expect(JSON.stringify(warn.mock.calls)).not.toContain("proxy-secret");
|
||||
expect(JSON.stringify(warn.mock.calls)).not.toContain("media-secret");
|
||||
});
|
||||
|
||||
it("allows gateway route fallthrough only after gateway auth succeeds", async () => {
|
||||
const { handled, exactPluginHandler, prefixGatewayHandler } = await invokeSecureGatewayRoute({
|
||||
gatewayAuthSatisfied: true,
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
||||
export {
|
||||
assertNoSymlinkParents,
|
||||
assertNoSymlinkParentsSync,
|
||||
readFileHandleBounded,
|
||||
type FileIdentityStat,
|
||||
sameFileIdentity,
|
||||
} from "@openclaw/fs-safe/advanced";
|
||||
|
||||
@@ -246,6 +246,16 @@ describe("redactSensitiveText", () => {
|
||||
expect(output).toBe("cdp=https://browserless.example.com/?token=***");
|
||||
});
|
||||
|
||||
it("masks resource-scoped hosted-media bearer query tokens", () => {
|
||||
const id = "a".repeat(24);
|
||||
const token = "b".repeat(48);
|
||||
const input = `GET https://gateway.example.com/webhooks/sms?safe=value&__openclaw_mms_token_${id}=${token}`;
|
||||
const output = redactSensitiveText(input, { mode: "tools" });
|
||||
|
||||
expect(output).toContain(`safe=value&__openclaw_mms_token_${id}=`);
|
||||
expect(output).not.toContain(token);
|
||||
});
|
||||
|
||||
it("masks standalone lowercase token assignments in diagnostic output", () => {
|
||||
const input = "matrix access_token=abcdef1234567890ghij next";
|
||||
const output = redactSensitiveText(input, { mode: "tools" });
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { __setFsSafeTestHooksForTest } from "@openclaw/fs-safe/test-hooks";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { assertLocalMediaAllowed, LocalMediaAccessError } from "./local-media-access.js";
|
||||
import {
|
||||
assertLocalMediaAllowed,
|
||||
LocalMediaAccessError,
|
||||
readLocalMediaFile,
|
||||
} from "./local-media-access.js";
|
||||
|
||||
const { hoistedRoots } = vi.hoisted(() => ({ hoistedRoots: [] as string[] }));
|
||||
|
||||
@@ -13,6 +18,10 @@ vi.mock("./local-roots.js", () => ({
|
||||
}));
|
||||
|
||||
describe("assertLocalMediaAllowed", () => {
|
||||
afterEach(() => {
|
||||
__setFsSafeTestHooksForTest(undefined);
|
||||
});
|
||||
|
||||
it("allows managed inbound media paths before explicit root checks", async () => {
|
||||
const stateDir = resolveStateDir();
|
||||
const id = `managed-local-${Date.now()}-${Math.random().toString(36).slice(2)}.png`;
|
||||
@@ -110,4 +119,189 @@ describe("assertLocalMediaAllowed", () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"reads through an in-root directory symlink but rejects a final symlink",
|
||||
async () => {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-root-alias-"));
|
||||
const root = path.join(base, "root");
|
||||
const realDir = path.join(root, "real");
|
||||
const aliasDir = path.join(root, "alias");
|
||||
const realFile = path.join(realDir, "inside.bin");
|
||||
const aliasFile = path.join(aliasDir, "inside.bin");
|
||||
const finalLink = path.join(root, "final.bin");
|
||||
await fs.mkdir(realDir, { recursive: true });
|
||||
await fs.writeFile(realFile, "inside");
|
||||
await fs.symlink(realDir, aliasDir);
|
||||
await fs.symlink(realFile, finalLink);
|
||||
|
||||
try {
|
||||
await expect(readLocalMediaFile(aliasFile, [root], { maxBytes: 1024 })).resolves.toEqual(
|
||||
Buffer.from("inside"),
|
||||
);
|
||||
await expect(
|
||||
readLocalMediaFile(finalLink, [root], { maxBytes: 1024 }),
|
||||
).rejects.toMatchObject({ code: "symlink" });
|
||||
} finally {
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects inbound-root reads through a pre-existing directory symlink outside the root",
|
||||
async () => {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-inbound-root-alias-"));
|
||||
const inboundRoot = path.join(base, "inbound");
|
||||
const outsideDir = path.join(base, "outside");
|
||||
const aliasDir = path.join(inboundRoot, "alias");
|
||||
const filePath = path.join(aliasDir, "secret.bin");
|
||||
await fs.mkdir(inboundRoot, { recursive: true });
|
||||
await fs.mkdir(outsideDir, { recursive: true });
|
||||
await fs.writeFile(path.join(outsideDir, "secret.bin"), "outside-secret");
|
||||
await fs.symlink(outsideDir, aliasDir);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
readLocalMediaFile(filePath, [], {
|
||||
inboundRoots: [inboundRoot],
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "path-not-allowed" });
|
||||
} finally {
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects inbound wildcard reads when a nested directory symlink retargets before open",
|
||||
async () => {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-inbound-root-race-"));
|
||||
const inboundRoot = path.join(base, "alice", "Attachments");
|
||||
const insideDir = path.join(inboundRoot, "inside");
|
||||
const outsideDir = path.join(base, "outside");
|
||||
const aliasDir = path.join(inboundRoot, "slot");
|
||||
const filePath = path.join(aliasDir, "report.csv");
|
||||
await fs.mkdir(insideDir, { recursive: true });
|
||||
await fs.mkdir(outsideDir, { recursive: true });
|
||||
await fs.writeFile(path.join(insideDir, "report.csv"), "inside");
|
||||
await fs.writeFile(path.join(outsideDir, "report.csv"), "outside-secret");
|
||||
await fs.symlink(insideDir, aliasDir);
|
||||
__setFsSafeTestHooksForTest({
|
||||
afterPreOpenLstat: async (openedPath) => {
|
||||
if (openedPath !== filePath) {
|
||||
return;
|
||||
}
|
||||
await fs.rm(aliasDir);
|
||||
await fs.symlink(outsideDir, aliasDir);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
readLocalMediaFile(filePath, [], {
|
||||
inboundRoots: [path.join(base, "*", "Attachments")],
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "path-not-allowed" });
|
||||
} finally {
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects inbound wildcard roots whose wildcard segment already resolves outside the anchor",
|
||||
async () => {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-inbound-anchor-"));
|
||||
const outside = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-inbound-outside-"));
|
||||
const alias = path.join(base, "alice");
|
||||
const outsideAttachments = path.join(outside, "Attachments");
|
||||
const filePath = path.join(alias, "Attachments", "secret.bin");
|
||||
await fs.mkdir(outsideAttachments, { recursive: true });
|
||||
await fs.writeFile(path.join(outsideAttachments, "secret.bin"), "outside-secret");
|
||||
await fs.symlink(outside, alias);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
readLocalMediaFile(filePath, [], {
|
||||
inboundRoots: [path.join(base, "*", "Attachments")],
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "path-not-allowed" });
|
||||
} finally {
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
await fs.rm(outside, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects hardlink aliases inside channel inbound roots",
|
||||
async () => {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-inbound-hardlink-"));
|
||||
const inboundRoot = path.join(base, "inbound");
|
||||
const outsidePath = path.join(base, "outside.bin");
|
||||
const filePath = path.join(inboundRoot, "alias.bin");
|
||||
await fs.mkdir(inboundRoot, { recursive: true });
|
||||
await fs.writeFile(outsidePath, "outside-secret");
|
||||
await fs.link(outsidePath, filePath);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
readLocalMediaFile(filePath, [], {
|
||||
inboundRoots: [inboundRoot],
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "hardlink" });
|
||||
} finally {
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps missing managed paths available to authorized custom readers", async () => {
|
||||
const stateDir = resolveStateDir();
|
||||
const inboundRoot = path.join(stateDir, "media", "inbound");
|
||||
const filePath = path.join(
|
||||
inboundRoot,
|
||||
`virtual-${Date.now()}-${Math.random().toString(36).slice(2)}.bin`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
assertLocalMediaAllowed(filePath, [], { inboundRoots: [inboundRoot] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves valid root-level wildcard inbound patterns", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-inbound-root-wildcard-"));
|
||||
const filePath = path.join(root, "inside.bin");
|
||||
await fs.writeFile(filePath, "inside");
|
||||
|
||||
try {
|
||||
await expect(
|
||||
readLocalMediaFile(filePath, [], {
|
||||
inboundRoots: ["/*"],
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
).resolves.toEqual(Buffer.from("inside"));
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves not-found and not-file errors for root-bound reads", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-read-errors-"));
|
||||
try {
|
||||
await expect(
|
||||
readLocalMediaFile(path.join(root, "missing.bin"), [root], { maxBytes: 1024 }),
|
||||
).rejects.toMatchObject({ code: "not-found" });
|
||||
await expect(readLocalMediaFile(root, [root], { maxBytes: 1024 })).rejects.toMatchObject({
|
||||
code: "not-file",
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+126
-26
@@ -1,11 +1,13 @@
|
||||
// Local media access helpers validate workspace-local media path access.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isInboundPathAllowed } from "@openclaw/media-core/inbound-path-policy";
|
||||
import { resolveInboundPathRoot } from "@openclaw/media-core/inbound-path-policy";
|
||||
import { readFileHandleBounded } from "../infra/fs-safe-advanced.js";
|
||||
import { FsSafeError, openLocalFileSafely } from "../infra/fs-safe.js";
|
||||
import { assertNoWindowsNetworkPath } from "../infra/local-file-access.js";
|
||||
import { isPathInside } from "../infra/path-guards.js";
|
||||
import { getDefaultMediaLocalRoots } from "./local-roots.js";
|
||||
import { resolveInboundMediaReference } from "./media-reference.js";
|
||||
import { MediaReferenceError, resolveInboundMediaReference } from "./media-reference.js";
|
||||
|
||||
/** Machine-readable reasons local media path validation can fail. */
|
||||
export type LocalMediaAccessErrorCode =
|
||||
@@ -34,6 +36,15 @@ export function getDefaultLocalRoots(): readonly string[] {
|
||||
return getDefaultMediaLocalRoots();
|
||||
}
|
||||
|
||||
async function resolveCanonicalBoundaryPath(root: string): Promise<string> {
|
||||
const resolved = path.resolve(root);
|
||||
try {
|
||||
return await fs.realpath(resolved);
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves an allowlist once for callers that validate several media paths. */
|
||||
export async function resolveLocalMediaRoots(
|
||||
localRoots?: readonly string[],
|
||||
@@ -41,12 +52,7 @@ export async function resolveLocalMediaRoots(
|
||||
const roots = localRoots ?? getDefaultLocalRoots();
|
||||
return await Promise.all(
|
||||
roots.map(async (root) => {
|
||||
let resolvedRoot: string;
|
||||
try {
|
||||
resolvedRoot = await fs.realpath(root);
|
||||
} catch {
|
||||
resolvedRoot = path.resolve(root);
|
||||
}
|
||||
const resolvedRoot = await resolveCanonicalBoundaryPath(root);
|
||||
if (resolvedRoot === path.parse(resolvedRoot).root) {
|
||||
throw new LocalMediaAccessError(
|
||||
"invalid-root",
|
||||
@@ -58,22 +64,56 @@ export async function resolveLocalMediaRoots(
|
||||
);
|
||||
}
|
||||
|
||||
/** Verifies that a local media path is managed inbound media or lives under allowed roots. */
|
||||
export async function assertLocalMediaAllowed(
|
||||
async function resolveLocalMediaPathForContainment(mediaPath: string): Promise<string> {
|
||||
try {
|
||||
return await fs.realpath(mediaPath);
|
||||
} catch {
|
||||
// Missing files (for example, staged outbound media supplied by host-read
|
||||
// callbacks) still need symlink-aware parent containment.
|
||||
try {
|
||||
return path.join(await fs.realpath(path.dirname(mediaPath)), path.basename(mediaPath));
|
||||
} catch {
|
||||
return path.resolve(mediaPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ResolvedLocalMediaBoundary = {
|
||||
rejectHardlinks: boolean;
|
||||
roots: readonly string[] | "any";
|
||||
};
|
||||
|
||||
type ManagedReferenceErrorPolicy = "ignore" | "reject";
|
||||
|
||||
async function resolveLocalMediaBoundary(
|
||||
mediaPath: string,
|
||||
localRoots: readonly string[] | "any" | undefined,
|
||||
managedReferenceErrors: ManagedReferenceErrorPolicy,
|
||||
options?: {
|
||||
inboundRoots?: readonly string[];
|
||||
resolvedRoots?: readonly string[];
|
||||
resolveRoots?: () => Promise<readonly string[]>;
|
||||
},
|
||||
): Promise<void> {
|
||||
): Promise<ResolvedLocalMediaBoundary> {
|
||||
if (localRoots === "any") {
|
||||
return;
|
||||
return { rejectHardlinks: false, roots: "any" };
|
||||
}
|
||||
let inboundReference;
|
||||
try {
|
||||
inboundReference = await resolveInboundMediaReference(mediaPath);
|
||||
} catch (err) {
|
||||
if (managedReferenceErrors === "reject" && err instanceof MediaReferenceError) {
|
||||
throw new LocalMediaAccessError(err.code, err.message, { cause: err });
|
||||
}
|
||||
if (!(err instanceof MediaReferenceError)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const inboundReference = await resolveInboundMediaReference(mediaPath).catch(() => null);
|
||||
if (inboundReference) {
|
||||
return;
|
||||
return {
|
||||
rejectHardlinks: true,
|
||||
roots: await resolveLocalMediaRoots([path.dirname(inboundReference.physicalPath)]),
|
||||
};
|
||||
}
|
||||
try {
|
||||
assertNoWindowsNetworkPath(mediaPath, "Local media path");
|
||||
@@ -82,19 +122,28 @@ export async function assertLocalMediaAllowed(
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
if (
|
||||
options?.inboundRoots?.length &&
|
||||
isInboundPathAllowed({ filePath: mediaPath, roots: options.inboundRoots })
|
||||
) {
|
||||
return;
|
||||
const matchedInboundRoot = options?.inboundRoots?.length
|
||||
? resolveInboundPathRoot({ filePath: mediaPath, roots: options.inboundRoots })
|
||||
: undefined;
|
||||
if (matchedInboundRoot) {
|
||||
// Channel inbound roots may contain whole-segment wildcards. Freeze the
|
||||
// matched root under the stable pre-wildcard anchor so an alias cannot
|
||||
// promote an outside directory into the authorized boundary.
|
||||
const resolvedAnchor = await resolveCanonicalBoundaryPath(matchedInboundRoot.anchorRoot);
|
||||
const resolvedRoot = await resolveCanonicalBoundaryPath(matchedInboundRoot.matchedRoot);
|
||||
if (!isPathInside(resolvedAnchor, resolvedRoot)) {
|
||||
throw new LocalMediaAccessError(
|
||||
"path-not-allowed",
|
||||
`Local media path is not under an allowed directory: ${mediaPath}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
rejectHardlinks: true,
|
||||
roots: [resolvedRoot],
|
||||
};
|
||||
}
|
||||
const roots = localRoots ?? getDefaultLocalRoots();
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = await fs.realpath(mediaPath);
|
||||
} catch {
|
||||
resolved = path.resolve(mediaPath);
|
||||
}
|
||||
const resolved = await resolveLocalMediaPathForContainment(mediaPath);
|
||||
|
||||
if (localRoots === undefined) {
|
||||
// Unscoped default roots include workspace, but not sibling workspace-* agent sandboxes.
|
||||
@@ -127,7 +176,7 @@ export async function assertLocalMediaAllowed(
|
||||
);
|
||||
}
|
||||
if (isPathInside(resolvedRoot, resolved)) {
|
||||
return;
|
||||
return { rejectHardlinks: false, roots: resolvedRoots };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,3 +185,54 @@ export async function assertLocalMediaAllowed(
|
||||
`Local media path is not under an allowed directory: ${mediaPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Verifies that a local media path is managed inbound media or lives under allowed roots. */
|
||||
export async function assertLocalMediaAllowed(
|
||||
mediaPath: string,
|
||||
localRoots: readonly string[] | "any" | undefined,
|
||||
options?: {
|
||||
inboundRoots?: readonly string[];
|
||||
resolvedRoots?: readonly string[];
|
||||
resolveRoots?: () => Promise<readonly string[]>;
|
||||
},
|
||||
): Promise<void> {
|
||||
await resolveLocalMediaBoundary(mediaPath, localRoots, "ignore", options);
|
||||
}
|
||||
|
||||
/** Opens, revalidates, and bounded-reads local media against one frozen root boundary. */
|
||||
export async function readLocalMediaFile(
|
||||
mediaPath: string,
|
||||
localRoots: readonly string[] | "any" | undefined,
|
||||
options: {
|
||||
inboundRoots?: readonly string[];
|
||||
maxBytes: number;
|
||||
resolvedRoots?: readonly string[];
|
||||
resolveRoots?: () => Promise<readonly string[]>;
|
||||
},
|
||||
): Promise<Buffer> {
|
||||
const boundary = await resolveLocalMediaBoundary(mediaPath, localRoots, "reject", options);
|
||||
const opened = await openLocalFileSafely({ filePath: mediaPath });
|
||||
try {
|
||||
if (
|
||||
boundary.roots !== "any" &&
|
||||
!boundary.roots.some((resolvedRoot) => isPathInside(resolvedRoot, opened.realPath))
|
||||
) {
|
||||
throw new LocalMediaAccessError(
|
||||
"path-not-allowed",
|
||||
`Local media path is not under an allowed directory: ${mediaPath}`,
|
||||
);
|
||||
}
|
||||
if (boundary.rejectHardlinks && opened.stat.nlink > 1) {
|
||||
throw new FsSafeError("hardlink", "hardlinked path not allowed");
|
||||
}
|
||||
if (opened.stat.size > options.maxBytes) {
|
||||
throw new FsSafeError(
|
||||
"too-large",
|
||||
`file exceeds limit of ${options.maxBytes} bytes (got ${opened.stat.size})`,
|
||||
);
|
||||
}
|
||||
return await readFileHandleBounded(opened.handle, options.maxBytes);
|
||||
} finally {
|
||||
await opened.handle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { __setFsSafeTestHooksForTest } from "@openclaw/fs-safe/test-hooks";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import { readOutboundMediaFile } from "./bounded-read-file.js";
|
||||
@@ -27,6 +28,7 @@ vi.mock("../channels/plugins/index.js", () => ({
|
||||
|
||||
describe("resolveAgentScopedOutboundMediaAccess", () => {
|
||||
afterEach(() => {
|
||||
__setFsSafeTestHooksForTest(undefined);
|
||||
vi.unstubAllEnvs();
|
||||
channelPluginMocks.getLoadedChannelPlugin.mockReset();
|
||||
});
|
||||
@@ -210,6 +212,45 @@ describe("resolveAgentScopedOutboundMediaAccess", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects owned host reads when an allowed ancestor symlink retargets before open",
|
||||
async () => {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-host-media-race-"));
|
||||
const workspaceDir = path.join(base, "workspace");
|
||||
const insideDir = path.join(workspaceDir, "inside");
|
||||
const outsideDir = path.join(base, "outside");
|
||||
const aliasDir = path.join(workspaceDir, "slot");
|
||||
const filePath = path.join(aliasDir, "report.csv");
|
||||
await fs.mkdir(insideDir, { recursive: true });
|
||||
await fs.mkdir(outsideDir, { recursive: true });
|
||||
await fs.writeFile(path.join(insideDir, "report.csv"), "inside");
|
||||
await fs.writeFile(path.join(outsideDir, "report.csv"), "outside-secret");
|
||||
await fs.symlink(insideDir, aliasDir);
|
||||
const result = resolveAgentScopedOutboundMediaAccess({
|
||||
cfg: { tools: { allow: ["read"] } } as OpenClawConfig,
|
||||
workspaceDir,
|
||||
mediaSources: [filePath],
|
||||
});
|
||||
__setFsSafeTestHooksForTest({
|
||||
afterPreOpenLstat: async (openedPath) => {
|
||||
if (openedPath !== filePath) {
|
||||
return;
|
||||
}
|
||||
await fs.rm(aliasDir);
|
||||
await fs.symlink(outsideDir, aliasDir);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
readOutboundMediaFile(result.readFile!, filePath, { maxBytes: 1024 }),
|
||||
).rejects.toMatchObject({ code: "path-not-allowed" });
|
||||
} finally {
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps host reads enabled for DM sender when no group context exists", () => {
|
||||
const result = resolveAgentScopedOutboundMediaAccess({
|
||||
cfg: {
|
||||
|
||||
@@ -7,9 +7,9 @@ import { resolveEffectiveToolFsRootExpansionAllowed } from "../agents/tool-fs-po
|
||||
import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js";
|
||||
import { resolveWorkspaceRoot } from "../agents/workspace-dir.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { readLocalFileSafely } from "../infra/fs-safe.js";
|
||||
import { createBoundedOutboundMediaReadFile } from "./bounded-read-file.js";
|
||||
import type { OutboundMediaAccess, OutboundMediaReadFile } from "./load-options.js";
|
||||
import { readLocalMediaFile } from "./local-media-access.js";
|
||||
import {
|
||||
getAgentScopedMediaLocalRoots,
|
||||
getAgentScopedMediaLocalRootsForSources,
|
||||
@@ -67,6 +67,7 @@ function createAgentScopedHostMediaReadFile(
|
||||
params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId?: string;
|
||||
localRoots: readonly string[];
|
||||
workspaceDir?: string;
|
||||
} & OutboundHostMediaPolicyContext,
|
||||
): OutboundMediaReadFile | undefined {
|
||||
@@ -79,12 +80,9 @@ function createAgentScopedHostMediaReadFile(
|
||||
const workspaceRoot = resolveWorkspaceRoot(inferredWorkspaceDir);
|
||||
return createBoundedOutboundMediaReadFile(async (filePath, options) => {
|
||||
const resolvedPath = resolvePathFromInput(filePath, workspaceRoot);
|
||||
return (
|
||||
await readLocalFileSafely({
|
||||
filePath: resolvedPath,
|
||||
maxBytes: options?.maxBytes,
|
||||
})
|
||||
).buffer;
|
||||
return await readLocalMediaFile(resolvedPath, params.localRoots, {
|
||||
maxBytes: options?.maxBytes ?? Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,6 +137,7 @@ export function resolveAgentScopedOutboundMediaAccess(
|
||||
? createAgentScopedHostMediaReadFile({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
localRoots: localRoots ?? [],
|
||||
workspaceDir: resolvedWorkspaceDir,
|
||||
sessionKey: params.sessionKey,
|
||||
messageProvider: params.messageProvider,
|
||||
|
||||
@@ -602,6 +602,47 @@ describe("loadWebMedia", () => {
|
||||
expect(unboundedReadCalled).toBe(false);
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects local media when an allowed ancestor symlink retargets before open",
|
||||
async () => {
|
||||
const base = await fs.mkdtemp(path.join(fixtureRoot, "ancestor-race-"));
|
||||
const allowedRoot = path.join(base, "allowed");
|
||||
const insideDir = path.join(allowedRoot, "inside");
|
||||
const outsideDir = path.join(base, "outside");
|
||||
const aliasDir = path.join(allowedRoot, "slot");
|
||||
const mediaPath = path.join(aliasDir, "image.png");
|
||||
await fs.mkdir(insideDir, { recursive: true });
|
||||
await fs.mkdir(outsideDir, { recursive: true });
|
||||
await fs.writeFile(path.join(insideDir, "image.png"), TINY_PNG_BUFFER);
|
||||
await fs.writeFile(
|
||||
path.join(outsideDir, "image.png"),
|
||||
createSolidPngBuffer(1, 1, { r: 0, g: 0, b: 0 }),
|
||||
);
|
||||
await fs.symlink(insideDir, aliasDir);
|
||||
__setFsSafeTestHooksForTest({
|
||||
afterPreOpenLstat: async (filePath) => {
|
||||
if (filePath !== mediaPath) {
|
||||
return;
|
||||
}
|
||||
await fs.rm(aliasDir);
|
||||
await fs.symlink(outsideDir, aliasDir);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
loadWebMediaRaw(mediaPath, {
|
||||
maxBytes: 1024 * 1024,
|
||||
localRoots: [allowedRoot],
|
||||
optimizeImages: false,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "path-not-allowed" });
|
||||
} finally {
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the one-argument contract for custom local readers", async () => {
|
||||
const maxBytes = 1024 * 1024;
|
||||
const readFile = vi.fn(async (_filePath: string) => Buffer.from(TINY_PNG_BASE64, "base64"));
|
||||
@@ -1384,6 +1425,43 @@ describe("loadWebMedia", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32").each([2, 3] as const)(
|
||||
"rejects an inbound media store URI swapped to a hardlink on guarded open %s",
|
||||
async (swapOpen) => {
|
||||
const id = `signal-hardlink-race-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`;
|
||||
const filePath = path.join(stateDir, "media", "inbound", id);
|
||||
const outsidePath = path.join(fixtureRoot, `${id}.outside`);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, "inside");
|
||||
await fs.writeFile(outsidePath, "outside-secret");
|
||||
let matchingOpens = 0;
|
||||
__setFsSafeTestHooksForTest({
|
||||
afterPreOpenLstat: async (openedPath) => {
|
||||
if (path.basename(openedPath) !== id) {
|
||||
return;
|
||||
}
|
||||
matchingOpens += 1;
|
||||
if (matchingOpens !== swapOpen) {
|
||||
return;
|
||||
}
|
||||
await fs.rm(filePath);
|
||||
await fs.link(outsidePath, filePath);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await expectLoadWebMediaErrorCode(
|
||||
loadWebMediaRaw(`media://inbound/${id}`, { maxBytes: 1024 }),
|
||||
"invalid-path",
|
||||
);
|
||||
expect(matchingOpens).toBe(swapOpen);
|
||||
} finally {
|
||||
await fs.rm(filePath, { force: true });
|
||||
await fs.rm(outsidePath, { force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts legacy MEDIA prefixes around inbound media store URIs", async () => {
|
||||
const id = `signal-legacy-${Date.now()}-${Math.random().toString(36).slice(2)}.png`;
|
||||
const filePath = path.join(stateDir, "media", "inbound", id);
|
||||
|
||||
@@ -17,7 +17,7 @@ import { uniqueValues } from "@openclaw/normalization-core/string-normalization"
|
||||
import { resolveCanvasHttpPathToLocalPath } from "../canvas/documents.js";
|
||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { FsSafeError, readLocalFileSafely } from "../infra/fs-safe.js";
|
||||
import { FsSafeError } from "../infra/fs-safe.js";
|
||||
import {
|
||||
executeSqliteQuerySync,
|
||||
executeSqliteQueryTakeFirstSync,
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
assertLocalMediaAllowed,
|
||||
getDefaultLocalRoots,
|
||||
LocalMediaAccessError,
|
||||
readLocalMediaFile,
|
||||
type LocalMediaAccessErrorCode,
|
||||
} from "./local-media-access.js";
|
||||
import { MediaReferenceError, resolveInboundMediaReference } from "./media-reference.js";
|
||||
@@ -1185,7 +1186,7 @@ async function loadWebMediaInternal(
|
||||
}
|
||||
|
||||
// Guard local reads against allowed directory roots to prevent file exfiltration.
|
||||
if (!(sandboxValidated || localRoots === "any")) {
|
||||
if (readFileOverride && !(sandboxValidated || localRoots === "any")) {
|
||||
await assertLocalMediaAllowed(mediaUrl, localRoots, { inboundRoots });
|
||||
}
|
||||
|
||||
@@ -1206,12 +1207,10 @@ async function loadWebMediaInternal(
|
||||
data = await readOutboundMediaFile(readFileOverride, mediaUrl, { maxBytes: sourceReadCap });
|
||||
} else {
|
||||
try {
|
||||
data = (
|
||||
await readLocalFileSafely({
|
||||
filePath: mediaUrl,
|
||||
maxBytes: sourceReadCap,
|
||||
})
|
||||
).buffer;
|
||||
data = await readLocalMediaFile(mediaUrl, localRoots, {
|
||||
...(inboundRoots ? { inboundRoots } : {}),
|
||||
maxBytes: sourceReadCap,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof FsSafeError) {
|
||||
if (err.code === "too-large") {
|
||||
|
||||
@@ -169,6 +169,84 @@ describe("createHostedOutboundMediaStore", () => {
|
||||
expect(entry?.buffer.toString("utf8")).toBe("image-bytes");
|
||||
});
|
||||
|
||||
it("reads hosted metadata without hydrating chunk rows", async () => {
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("image-bytes"),
|
||||
kind: "image",
|
||||
contentType: "image/png",
|
||||
});
|
||||
const metadataStore = createPluginStateKeyedStoreForTests<HostedOutboundMediaMetaRecord>(
|
||||
"fixture-plugin",
|
||||
{
|
||||
namespace: "metadata-only-media",
|
||||
maxEntries: 10,
|
||||
},
|
||||
);
|
||||
const chunkStore = createPluginStateKeyedStoreForTests<HostedOutboundMediaChunkRecord>(
|
||||
"fixture-plugin",
|
||||
{
|
||||
namespace: "metadata-only-media-chunks",
|
||||
maxEntries: 100,
|
||||
},
|
||||
);
|
||||
const store = createHostedOutboundMediaStore({
|
||||
metadataStore,
|
||||
chunkStore,
|
||||
ttlMs: 120_000,
|
||||
resolveExpiresAtMs: () => Date.now() + 120_000,
|
||||
createId: () => "abc123abc123abc123abc123",
|
||||
createToken: () => "token123",
|
||||
rawChunkBytes: 4,
|
||||
maxEntries: 10,
|
||||
maxChunkRows: 100,
|
||||
});
|
||||
await store.prepareUrl({
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
routePath: "/hook/media/",
|
||||
publicBaseUrl: "https://gateway.example.com",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
const chunkLookup = vi.spyOn(chunkStore, "lookup");
|
||||
|
||||
await expect(store.readMetadata("abc123abc123abc123abc123")).resolves.toMatchObject({
|
||||
routePath: "/hook/media/",
|
||||
token: "token123",
|
||||
contentType: "image/png",
|
||||
byteLength: Buffer.byteLength("image-bytes"),
|
||||
});
|
||||
expect(chunkLookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards local media access into hosted media preparation", async () => {
|
||||
const mediaReadFile = vi.fn(async () => Buffer.from("image-bytes"));
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("image-bytes"),
|
||||
kind: "image",
|
||||
contentType: "image/png",
|
||||
});
|
||||
const store = createStore();
|
||||
|
||||
await store.prepareUrl({
|
||||
mediaUrl: "/workspace/photo.png",
|
||||
routePath: "/hook/media/",
|
||||
publicBaseUrl: "https://gateway.example.com",
|
||||
maxBytes: 1024,
|
||||
mediaAccess: {
|
||||
localRoots: ["/workspace"],
|
||||
readFile: mediaReadFile,
|
||||
workspaceDir: "/workspace",
|
||||
},
|
||||
});
|
||||
|
||||
expect(loadWebMediaMock).toHaveBeenCalledWith("/workspace/photo.png", {
|
||||
maxBytes: 1024,
|
||||
localRoots: ["/workspace"],
|
||||
readFile: mediaReadFile,
|
||||
hostReadCapability: true,
|
||||
workspaceDir: "/workspace",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps metadata long enough to clean up expired chunk rows", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
@@ -239,16 +317,159 @@ describe("createHostedOutboundMediaStore", () => {
|
||||
expect(await store.read("abc123abc123abc123abc123")).toBeNull();
|
||||
});
|
||||
|
||||
it("retains metadata until a failed chunk cleanup can be retried", async () => {
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("image-bytes"),
|
||||
kind: "image",
|
||||
contentType: "image/png",
|
||||
});
|
||||
const metadataStore = createPluginStateKeyedStoreForTests<HostedOutboundMediaMetaRecord>(
|
||||
"fixture-plugin",
|
||||
{
|
||||
namespace: "retry-delete-media",
|
||||
maxEntries: 10,
|
||||
},
|
||||
);
|
||||
const chunkStore = createPluginStateKeyedStoreForTests<HostedOutboundMediaChunkRecord>(
|
||||
"fixture-plugin",
|
||||
{
|
||||
namespace: "retry-delete-media-chunks",
|
||||
maxEntries: 100,
|
||||
},
|
||||
);
|
||||
const store = createHostedOutboundMediaStore({
|
||||
metadataStore,
|
||||
chunkStore,
|
||||
ttlMs: 120_000,
|
||||
resolveExpiresAtMs: () => Date.now() + 120_000,
|
||||
createId: () => "abc123abc123abc123abc123",
|
||||
createToken: () => "token123",
|
||||
rawChunkBytes: 4,
|
||||
maxEntries: 10,
|
||||
maxChunkRows: 100,
|
||||
});
|
||||
await store.prepareUrl({
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
routePath: "/hook/media/",
|
||||
publicBaseUrl: "https://gateway.example.com",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
const originalDelete = chunkStore.delete.bind(chunkStore);
|
||||
let deleteCalls = 0;
|
||||
vi.spyOn(chunkStore, "delete").mockImplementation(async (key) => {
|
||||
deleteCalls += 1;
|
||||
if (deleteCalls === 2) {
|
||||
throw new Error("chunk delete failed");
|
||||
}
|
||||
return await originalDelete(key);
|
||||
});
|
||||
|
||||
await expect(store.delete("abc123abc123abc123abc123")).rejects.toThrow("chunk delete failed");
|
||||
expect(await metadataStore.entries()).toHaveLength(1);
|
||||
|
||||
await expect(store.delete("abc123abc123abc123abc123")).resolves.toBeUndefined();
|
||||
expect(await metadataStore.entries()).toEqual([]);
|
||||
expect(await chunkStore.entries()).toEqual([]);
|
||||
});
|
||||
|
||||
it("serializes explicit deletion with reject-new capacity checks", async () => {
|
||||
let idCounter = 0;
|
||||
const metadataStore = createPluginStateKeyedStoreForTests<HostedOutboundMediaMetaRecord>(
|
||||
"fixture-plugin",
|
||||
{
|
||||
namespace: "serialized-delete-media",
|
||||
maxEntries: 1,
|
||||
overflowPolicy: "reject-new",
|
||||
},
|
||||
);
|
||||
const chunkStore = createPluginStateKeyedStoreForTests<HostedOutboundMediaChunkRecord>(
|
||||
"fixture-plugin",
|
||||
{
|
||||
namespace: "serialized-delete-media-chunks",
|
||||
maxEntries: 1,
|
||||
overflowPolicy: "reject-new",
|
||||
},
|
||||
);
|
||||
const store = createHostedOutboundMediaStore({
|
||||
metadataStore,
|
||||
chunkStore,
|
||||
ttlMs: 120_000,
|
||||
resolveExpiresAtMs: () => Date.now() + 120_000,
|
||||
createId: () => {
|
||||
idCounter += 1;
|
||||
return idCounter === 1 ? "111111111111111111111111" : "222222222222222222222222";
|
||||
},
|
||||
createToken: () => "token123",
|
||||
rawChunkBytes: 64,
|
||||
maxEntries: 1,
|
||||
maxChunkRows: 1,
|
||||
overflowPolicy: "reject-new",
|
||||
});
|
||||
loadWebMediaMock.mockResolvedValue({
|
||||
buffer: Buffer.from("image-bytes"),
|
||||
kind: "image",
|
||||
contentType: "image/png",
|
||||
});
|
||||
await store.prepareUrl({
|
||||
mediaUrl: "https://example.com/first.png",
|
||||
routePath: "/hook/media/",
|
||||
publicBaseUrl: "https://gateway.example.com",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
let releaseDelete: (() => void) | undefined;
|
||||
let markDeleteStarted: (() => void) | undefined;
|
||||
const deleteStarted = new Promise<void>((resolve) => {
|
||||
markDeleteStarted = resolve;
|
||||
});
|
||||
const deleteReleased = new Promise<void>((resolve) => {
|
||||
releaseDelete = resolve;
|
||||
});
|
||||
const originalDelete = chunkStore.delete.bind(chunkStore);
|
||||
vi.spyOn(chunkStore, "delete").mockImplementationOnce(async (key) => {
|
||||
markDeleteStarted?.();
|
||||
await deleteReleased;
|
||||
return await originalDelete(key);
|
||||
});
|
||||
|
||||
const deletion = store.delete("111111111111111111111111");
|
||||
await deleteStarted;
|
||||
const replacement = store.prepareUrl({
|
||||
mediaUrl: "https://example.com/second.png",
|
||||
routePath: "/hook/media/",
|
||||
publicBaseUrl: "https://gateway.example.com",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
let replacementSettled = false;
|
||||
void replacement.then(
|
||||
() => {
|
||||
replacementSettled = true;
|
||||
},
|
||||
() => {
|
||||
replacementSettled = true;
|
||||
},
|
||||
);
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
expect(replacementSettled).toBe(false);
|
||||
releaseDelete?.();
|
||||
|
||||
await expect(deletion).resolves.toBeUndefined();
|
||||
await expect(replacement).resolves.toContain("222222222222222222222222");
|
||||
});
|
||||
|
||||
it("prunes oldest complete entries before chunk rows evict independently", async () => {
|
||||
let idCounter = 0;
|
||||
const store = createHostedOutboundMediaStore({
|
||||
metadataStore: createPluginStateKeyedStoreForTests("fixture-plugin", {
|
||||
namespace: "capacity-media",
|
||||
maxEntries: 4,
|
||||
overflowPolicy: "reject-new",
|
||||
}),
|
||||
chunkStore: createPluginStateKeyedStoreForTests("fixture-plugin", {
|
||||
namespace: "capacity-media-chunks",
|
||||
maxEntries: 4,
|
||||
overflowPolicy: "reject-new",
|
||||
}),
|
||||
ttlMs: 120_000,
|
||||
resolveExpiresAtMs: () => Date.now() + 120_000,
|
||||
@@ -284,6 +505,303 @@ describe("createHostedOutboundMediaStore", () => {
|
||||
expect(await store.read("222222222222222222222222")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("deletes corrupt metadata by its stored key without revoking a live URL", async () => {
|
||||
const liveId = "111111111111111111111111";
|
||||
const corruptId = "222222222222222222222222";
|
||||
const metadataStore = createPluginStateKeyedStoreForTests<HostedOutboundMediaMetaRecord>(
|
||||
"fixture-plugin",
|
||||
{
|
||||
namespace: "corrupt-capacity-media",
|
||||
maxEntries: 2,
|
||||
overflowPolicy: "reject-new",
|
||||
},
|
||||
);
|
||||
const chunkStore = createPluginStateKeyedStoreForTests<HostedOutboundMediaChunkRecord>(
|
||||
"fixture-plugin",
|
||||
{
|
||||
namespace: "corrupt-capacity-media-chunks",
|
||||
maxEntries: 1,
|
||||
overflowPolicy: "reject-new",
|
||||
},
|
||||
);
|
||||
const store = createHostedOutboundMediaStore({
|
||||
metadataStore,
|
||||
chunkStore,
|
||||
ttlMs: 120_000,
|
||||
resolveExpiresAtMs: () => Date.now() + 120_000,
|
||||
createId: () => liveId,
|
||||
createToken: () => "token123",
|
||||
rawChunkBytes: 4,
|
||||
maxEntries: 1,
|
||||
maxChunkRows: 1,
|
||||
overflowPolicy: "reject-new",
|
||||
});
|
||||
loadWebMediaMock.mockResolvedValue({
|
||||
buffer: Buffer.from("x"),
|
||||
kind: "image",
|
||||
contentType: "image/png",
|
||||
});
|
||||
await store.prepareUrl({
|
||||
mediaUrl: "https://example.com/live.png",
|
||||
routePath: "/hook/media/",
|
||||
publicBaseUrl: "https://gateway.example.com",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
await metadataStore.register(`media:${corruptId}:meta`, {
|
||||
id: liveId,
|
||||
routePath: "/hook/media/",
|
||||
token: "corrupt-token",
|
||||
contentType: "image/png",
|
||||
expiresAt: Date.now() + 120_000,
|
||||
chunkCount: 0,
|
||||
byteLength: 1,
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.prepareUrl({
|
||||
mediaUrl: "https://example.com/rejected.png",
|
||||
routePath: "/hook/media/",
|
||||
publicBaseUrl: "https://gateway.example.com",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
).rejects.toThrow("hosted outbound media capacity is full");
|
||||
expect(await store.read(liveId)).not.toBeNull();
|
||||
expect(await metadataStore.lookup(`media:${corruptId}:meta`)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects overflow without revoking existing live URLs", async () => {
|
||||
let idCounter = 0;
|
||||
const ids = [
|
||||
"111111111111111111111111",
|
||||
"222222222222222222222222",
|
||||
"333333333333333333333333",
|
||||
];
|
||||
const store = createHostedOutboundMediaStore({
|
||||
metadataStore: createPluginStateKeyedStoreForTests("fixture-plugin", {
|
||||
namespace: "reject-capacity-media",
|
||||
maxEntries: 2,
|
||||
overflowPolicy: "reject-new",
|
||||
}),
|
||||
chunkStore: createPluginStateKeyedStoreForTests("fixture-plugin", {
|
||||
namespace: "reject-capacity-media-chunks",
|
||||
maxEntries: 2,
|
||||
overflowPolicy: "reject-new",
|
||||
}),
|
||||
ttlMs: 120_000,
|
||||
resolveExpiresAtMs: () => Date.now() + 120_000,
|
||||
createId: () => ids[idCounter++] ?? "ffffffffffffffffffffffff",
|
||||
createToken: () => "token123",
|
||||
rawChunkBytes: 4,
|
||||
maxEntries: 2,
|
||||
maxChunkRows: 2,
|
||||
overflowPolicy: "reject-new",
|
||||
});
|
||||
loadWebMediaMock.mockResolvedValue({
|
||||
buffer: Buffer.from("x"),
|
||||
kind: "image",
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await store.prepareUrl({
|
||||
mediaUrl: "https://example.com/first.png",
|
||||
routePath: "/hook/media/",
|
||||
publicBaseUrl: "https://gateway.example.com",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
await store.prepareUrl({
|
||||
mediaUrl: "https://example.com/second.png",
|
||||
routePath: "/hook/media/",
|
||||
publicBaseUrl: "https://gateway.example.com",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.prepareUrl({
|
||||
mediaUrl: "https://example.com/third.png",
|
||||
routePath: "/hook/media/",
|
||||
publicBaseUrl: "https://gateway.example.com",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
).rejects.toThrow("hosted outbound media capacity is full");
|
||||
expect(await store.read(ids[0] ?? "")).not.toBeNull();
|
||||
expect(await store.read(ids[1] ?? "")).not.toBeNull();
|
||||
expect(await store.read(ids[2] ?? "")).toBeNull();
|
||||
});
|
||||
|
||||
it("serializes concurrent reject-new preparations without evicting live URLs", async () => {
|
||||
let idCounter = 0;
|
||||
const ids = [
|
||||
"111111111111111111111111",
|
||||
"222222222222222222222222",
|
||||
"333333333333333333333333",
|
||||
];
|
||||
const store = createHostedOutboundMediaStore({
|
||||
metadataStore: createPluginStateKeyedStoreForTests("fixture-plugin", {
|
||||
namespace: "concurrent-reject-capacity-media",
|
||||
maxEntries: 2,
|
||||
overflowPolicy: "reject-new",
|
||||
}),
|
||||
chunkStore: createPluginStateKeyedStoreForTests("fixture-plugin", {
|
||||
namespace: "concurrent-reject-capacity-media-chunks",
|
||||
maxEntries: 2,
|
||||
overflowPolicy: "reject-new",
|
||||
}),
|
||||
ttlMs: 120_000,
|
||||
resolveExpiresAtMs: () => Date.now() + 120_000,
|
||||
createId: () => ids[idCounter++] ?? "ffffffffffffffffffffffff",
|
||||
createToken: () => "token123",
|
||||
rawChunkBytes: 4,
|
||||
maxEntries: 2,
|
||||
maxChunkRows: 2,
|
||||
overflowPolicy: "reject-new",
|
||||
});
|
||||
loadWebMediaMock.mockResolvedValue({
|
||||
buffer: Buffer.from("x"),
|
||||
kind: "image",
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await store.prepareUrl({
|
||||
mediaUrl: "https://example.com/existing.png",
|
||||
routePath: "/hook/media/",
|
||||
publicBaseUrl: "https://gateway.example.com",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
const results = await Promise.allSettled([
|
||||
store.prepareUrl({
|
||||
mediaUrl: "https://example.com/second.png",
|
||||
routePath: "/hook/media/",
|
||||
publicBaseUrl: "https://gateway.example.com",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
store.prepareUrl({
|
||||
mediaUrl: "https://example.com/third.png",
|
||||
routePath: "/hook/media/",
|
||||
publicBaseUrl: "https://gateway.example.com",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
|
||||
expect(results.filter((result) => result.status === "rejected")).toHaveLength(1);
|
||||
expect(await store.read(ids[0] ?? "")).not.toBeNull();
|
||||
const liveNewEntries = await Promise.all(ids.slice(1).map(async (id) => await store.read(id)));
|
||||
expect(liveNewEntries.filter(Boolean)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects chunk-only overflow without revoking existing live URLs", async () => {
|
||||
let idCounter = 0;
|
||||
const ids = ["111111111111111111111111", "222222222222222222222222"];
|
||||
const store = createHostedOutboundMediaStore({
|
||||
metadataStore: createPluginStateKeyedStoreForTests("fixture-plugin", {
|
||||
namespace: "reject-chunk-capacity-media",
|
||||
maxEntries: 3,
|
||||
overflowPolicy: "reject-new",
|
||||
}),
|
||||
chunkStore: createPluginStateKeyedStoreForTests("fixture-plugin", {
|
||||
namespace: "reject-chunk-capacity-media-chunks",
|
||||
maxEntries: 2,
|
||||
overflowPolicy: "reject-new",
|
||||
}),
|
||||
ttlMs: 120_000,
|
||||
resolveExpiresAtMs: () => Date.now() + 120_000,
|
||||
createId: () => ids[idCounter++] ?? "ffffffffffffffffffffffff",
|
||||
createToken: () => "token123",
|
||||
rawChunkBytes: 4,
|
||||
maxEntries: 3,
|
||||
maxChunkRows: 2,
|
||||
overflowPolicy: "reject-new",
|
||||
});
|
||||
loadWebMediaMock
|
||||
.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("x"),
|
||||
kind: "image",
|
||||
contentType: "image/png",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("12345"),
|
||||
kind: "image",
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await store.prepareUrl({
|
||||
mediaUrl: "https://example.com/existing.png",
|
||||
routePath: "/hook/media/",
|
||||
publicBaseUrl: "https://gateway.example.com",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
await expect(
|
||||
store.prepareUrl({
|
||||
mediaUrl: "https://example.com/rejected.png",
|
||||
routePath: "/hook/media/",
|
||||
publicBaseUrl: "https://gateway.example.com",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
).rejects.toThrow("hosted outbound media capacity is full");
|
||||
|
||||
expect(await store.read(ids[0] ?? "")).not.toBeNull();
|
||||
expect(await store.read(ids[1] ?? "")).toBeNull();
|
||||
});
|
||||
|
||||
it("rolls back only new chunks when reject-new backing capacity races", async () => {
|
||||
let idCounter = 0;
|
||||
const ids = ["111111111111111111111111", "222222222222222222222222"];
|
||||
const chunkStore = createPluginStateKeyedStoreForTests<HostedOutboundMediaChunkRecord>(
|
||||
"fixture-plugin",
|
||||
{
|
||||
namespace: "backing-race-media-chunks",
|
||||
maxEntries: 2,
|
||||
overflowPolicy: "reject-new",
|
||||
},
|
||||
);
|
||||
const store = createHostedOutboundMediaStore({
|
||||
metadataStore: createPluginStateKeyedStoreForTests("fixture-plugin", {
|
||||
namespace: "backing-race-media",
|
||||
maxEntries: 2,
|
||||
overflowPolicy: "reject-new",
|
||||
}),
|
||||
chunkStore,
|
||||
ttlMs: 120_000,
|
||||
resolveExpiresAtMs: () => Date.now() + 120_000,
|
||||
createId: () => ids[idCounter++] ?? "ffffffffffffffffffffffff",
|
||||
createToken: () => "token123",
|
||||
rawChunkBytes: 4,
|
||||
maxEntries: 2,
|
||||
maxChunkRows: 3,
|
||||
overflowPolicy: "reject-new",
|
||||
});
|
||||
loadWebMediaMock
|
||||
.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("x"),
|
||||
kind: "image",
|
||||
contentType: "image/png",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("12345"),
|
||||
kind: "image",
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await store.prepareUrl({
|
||||
mediaUrl: "https://example.com/existing.png",
|
||||
routePath: "/hook/media/",
|
||||
publicBaseUrl: "https://gateway.example.com",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
await expect(
|
||||
store.prepareUrl({
|
||||
mediaUrl: "https://example.com/racing.png",
|
||||
routePath: "/hook/media/",
|
||||
publicBaseUrl: "https://gateway.example.com",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
).rejects.toThrow("reached its 2-row limit");
|
||||
|
||||
expect(await store.read(ids[0] ?? "")).not.toBeNull();
|
||||
expect(await store.read(ids[1] ?? "")).toBeNull();
|
||||
expect(await chunkStore.entries()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("removes written chunks when metadata registration fails", async () => {
|
||||
const metadataStore = createPluginStateKeyedStoreForTests<HostedOutboundMediaMetaRecord>(
|
||||
"fixture-plugin",
|
||||
|
||||
@@ -74,14 +74,23 @@ export type HostedOutboundMediaChunkRecord = {
|
||||
dataBase64: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Capacity handling for hosted media.
|
||||
* `"evict-oldest"` is the compatibility default; `"reject-new"` preserves live issued URLs.
|
||||
*/
|
||||
export type HostedOutboundMediaOverflowPolicy = "evict-oldest" | "reject-new";
|
||||
|
||||
export type HostedOutboundMediaStore = {
|
||||
prepareUrl: (params: {
|
||||
mediaUrl: string;
|
||||
routePath: string;
|
||||
publicBaseUrl: string;
|
||||
maxBytes: number;
|
||||
/** Host-authorized local media access forwarded to the shared outbound loader. */
|
||||
mediaAccess?: OutboundMediaAccess;
|
||||
proxyUrl?: string;
|
||||
}) => Promise<string>;
|
||||
readMetadata: (id: string, nowMs?: number) => Promise<HostedOutboundMediaMetadata | null>;
|
||||
read: (id: string, nowMs?: number) => Promise<HostedOutboundMediaEntry | null>;
|
||||
delete: (id: string) => Promise<void>;
|
||||
cleanupExpired: (nowMs?: number) => Promise<void>;
|
||||
@@ -99,6 +108,11 @@ export type CreateHostedOutboundMediaStoreOptions = {
|
||||
maxEntries?: number;
|
||||
maxChunkRows?: number;
|
||||
chunkRowsPerEntryBudget?: number;
|
||||
/**
|
||||
* Capacity action before storing a new entry. Defaults to `"evict-oldest"`.
|
||||
* With `"reject-new"`, configure both backing stores to reject overflow too.
|
||||
*/
|
||||
overflowPolicy?: HostedOutboundMediaOverflowPolicy;
|
||||
};
|
||||
|
||||
const DEFAULT_HOSTED_OUTBOUND_MEDIA_RAW_CHUNK_BYTES = 36 * 1024;
|
||||
@@ -122,6 +136,16 @@ function buildHostedOutboundMediaChunkKey(id: string, index: number): string {
|
||||
return `media:${id}:chunk:${String(index).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
function parseHostedOutboundMediaMetaKey(key: string): string | undefined {
|
||||
const prefix = "media:";
|
||||
const suffix = ":meta";
|
||||
if (!key.startsWith(prefix) || !key.endsWith(suffix)) {
|
||||
return undefined;
|
||||
}
|
||||
const id = key.slice(prefix.length, -suffix.length);
|
||||
return id || undefined;
|
||||
}
|
||||
|
||||
function resolveHostedOutboundMediaMetadataTtlMs(ttlMs: number): number {
|
||||
return ttlMs + Math.min(ttlMs, HOSTED_OUTBOUND_MEDIA_METADATA_TTL_GRACE_MS);
|
||||
}
|
||||
@@ -168,15 +192,17 @@ async function deleteHostedOutboundMediaRows(
|
||||
chunkStore: PluginStateKeyedStore<HostedOutboundMediaChunkRecord>,
|
||||
knownChunkCount?: number,
|
||||
): Promise<void> {
|
||||
const meta = await metadataStore.lookup(buildHostedOutboundMediaMetaKey(id));
|
||||
await metadataStore.delete(buildHostedOutboundMediaMetaKey(id));
|
||||
const metaKey = buildHostedOutboundMediaMetaKey(id);
|
||||
const meta = await metadataStore.lookup(metaKey);
|
||||
const chunkCount = meta?.chunkCount ?? knownChunkCount;
|
||||
if (chunkCount == null) {
|
||||
return;
|
||||
}
|
||||
for (let index = 0; index < chunkCount; index += 1) {
|
||||
await chunkStore.delete(buildHostedOutboundMediaChunkKey(id, index));
|
||||
if (chunkCount != null) {
|
||||
for (let index = 0; index < chunkCount; index += 1) {
|
||||
await chunkStore.delete(buildHostedOutboundMediaChunkKey(id, index));
|
||||
}
|
||||
}
|
||||
// Metadata owns chunk cardinality. Delete it last so a failed cleanup can
|
||||
// retry remaining rows instead of orphaning capacity with no recovery fact.
|
||||
await metadataStore.delete(metaKey);
|
||||
}
|
||||
|
||||
export function createHostedOutboundMediaStore(
|
||||
@@ -187,14 +213,28 @@ export function createHostedOutboundMediaStore(
|
||||
const chunkRowsPerEntryBudget =
|
||||
options.chunkRowsPerEntryBudget ?? DEFAULT_HOSTED_OUTBOUND_MEDIA_CHUNK_ROWS_PER_ENTRY_BUDGET;
|
||||
const maxChunkRows = options.maxChunkRows ?? maxEntries * chunkRowsPerEntryBudget;
|
||||
const overflowPolicy = options.overflowPolicy ?? "evict-oldest";
|
||||
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
||||
throw new Error("hosted outbound media maxEntries must be a positive integer");
|
||||
}
|
||||
if (!Number.isSafeInteger(maxChunkRows) || maxChunkRows < 1) {
|
||||
throw new Error("hosted outbound media maxChunkRows must be a positive integer");
|
||||
}
|
||||
if (overflowPolicy !== "evict-oldest" && overflowPolicy !== "reject-new") {
|
||||
throw new Error("hosted outbound media overflowPolicy must be evict-oldest or reject-new");
|
||||
}
|
||||
const createId = options.createId ?? createHostedOutboundMediaId;
|
||||
const createToken = options.createToken ?? createHostedOutboundMediaToken;
|
||||
let capacityMutation = Promise.resolve();
|
||||
|
||||
async function withCapacityMutation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = capacityMutation.then(operation, operation);
|
||||
capacityMutation = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return await result;
|
||||
}
|
||||
|
||||
async function deleteEntry(id: string): Promise<void> {
|
||||
await deleteHostedOutboundMediaRows(id, options.metadataStore, options.chunkStore);
|
||||
@@ -204,35 +244,93 @@ export function createHostedOutboundMediaStore(
|
||||
await deleteHostedOutboundMediaRows(id, options.metadataStore, options.chunkStore, chunkCount);
|
||||
}
|
||||
|
||||
async function cleanupExpired(nowMs = Date.now()): Promise<void> {
|
||||
for (const row of await options.metadataStore.entries()) {
|
||||
if (!isFutureHostedOutboundMediaExpiry(row.value.expiresAt, nowMs)) {
|
||||
await deleteEntry(row.value.id);
|
||||
}
|
||||
async function readMetadataRecord(
|
||||
id: string,
|
||||
nowMs: number,
|
||||
): Promise<HostedOutboundMediaMetaRecord | null> {
|
||||
const meta = await options.metadataStore.lookup(buildHostedOutboundMediaMetaKey(id));
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
if (!isFutureHostedOutboundMediaExpiry(meta.expiresAt, nowMs)) {
|
||||
await withCapacityMutation(async () => await deleteEntry(id));
|
||||
return null;
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
async function pruneForCapacity(incomingChunkCount: number): Promise<void> {
|
||||
async function deleteStoredRow(
|
||||
row: Awaited<ReturnType<typeof options.metadataStore.entries>>[number],
|
||||
): Promise<void> {
|
||||
const id = parseHostedOutboundMediaMetaKey(row.key);
|
||||
if (
|
||||
!id ||
|
||||
!Number.isSafeInteger(row.value.chunkCount) ||
|
||||
row.value.chunkCount < 1 ||
|
||||
row.value.chunkCount > maxChunkRows
|
||||
) {
|
||||
await options.metadataStore.delete(row.key);
|
||||
return;
|
||||
}
|
||||
for (let index = 0; index < row.value.chunkCount; index += 1) {
|
||||
await options.chunkStore.delete(buildHostedOutboundMediaChunkKey(id, index));
|
||||
}
|
||||
await options.metadataStore.delete(row.key);
|
||||
}
|
||||
|
||||
async function cleanupExpired(nowMs = Date.now()): Promise<void> {
|
||||
await withCapacityMutation(async () => {
|
||||
for (const row of await options.metadataStore.entries()) {
|
||||
if (!isFutureHostedOutboundMediaExpiry(row.value.expiresAt, nowMs)) {
|
||||
await deleteStoredRow(row);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function pruneForCapacity(incomingChunkCount: number, nowMs = Date.now()): Promise<void> {
|
||||
const rows = await options.metadataStore.entries();
|
||||
const validRows = rows.filter(
|
||||
(row) => Number.isSafeInteger(row.value.chunkCount) && row.value.chunkCount > 0,
|
||||
);
|
||||
const validRows = rows.filter((row) => {
|
||||
const id = parseHostedOutboundMediaMetaKey(row.key);
|
||||
return (
|
||||
id !== undefined &&
|
||||
row.value.id === id &&
|
||||
Number.isSafeInteger(row.value.chunkCount) &&
|
||||
row.value.chunkCount > 0 &&
|
||||
row.value.chunkCount <= maxChunkRows &&
|
||||
isFutureHostedOutboundMediaExpiry(row.value.expiresAt, nowMs)
|
||||
);
|
||||
});
|
||||
const validKeys = new Set(validRows.map((row) => row.key));
|
||||
const orderedRows = validRows.toSorted(
|
||||
(a, b) => a.createdAt - b.createdAt || a.key.localeCompare(b.key),
|
||||
);
|
||||
const invalidRows = rows.filter((row) => !validKeys.has(row.key));
|
||||
for (const row of invalidRows) {
|
||||
await deleteEntry(row.value.id);
|
||||
await deleteStoredRow(row);
|
||||
}
|
||||
|
||||
let entryCount = orderedRows.length;
|
||||
let chunkCount = orderedRows.reduce((total, row) => total + row.value.chunkCount, 0);
|
||||
if (
|
||||
overflowPolicy === "reject-new" &&
|
||||
(entryCount >= maxEntries || chunkCount + incomingChunkCount > maxChunkRows)
|
||||
) {
|
||||
throw new Error(
|
||||
`hosted outbound media capacity is full (${entryCount}/${maxEntries} entries, ${
|
||||
chunkCount + incomingChunkCount
|
||||
}/${maxChunkRows} chunk rows)`,
|
||||
);
|
||||
}
|
||||
for (const row of orderedRows) {
|
||||
if (entryCount < maxEntries && chunkCount + incomingChunkCount <= maxChunkRows) {
|
||||
break;
|
||||
}
|
||||
await deleteEntry(row.value.id);
|
||||
const id = parseHostedOutboundMediaMetaKey(row.key);
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
await deleteEntry(id);
|
||||
entryCount -= 1;
|
||||
chunkCount -= row.value.chunkCount;
|
||||
}
|
||||
@@ -240,13 +338,13 @@ export function createHostedOutboundMediaStore(
|
||||
|
||||
return {
|
||||
async prepareUrl(params) {
|
||||
await cleanupExpired();
|
||||
const expiresAt = options.resolveExpiresAtMs(options.ttlMs);
|
||||
if (expiresAt === undefined) {
|
||||
throw new Error("hosted outbound media expiry could not be resolved");
|
||||
}
|
||||
const media = await loadOutboundMediaFromUrl(params.mediaUrl, {
|
||||
maxBytes: params.maxBytes,
|
||||
mediaAccess: params.mediaAccess,
|
||||
...(params.proxyUrl ? { proxyUrl: params.proxyUrl } : {}),
|
||||
});
|
||||
const id = createId();
|
||||
@@ -258,53 +356,57 @@ export function createHostedOutboundMediaStore(
|
||||
`hosted outbound media exceeds SQLite chunk row limit (${chunkCount}/${maxChunkRows})`,
|
||||
);
|
||||
}
|
||||
await pruneForCapacity(chunkCount);
|
||||
try {
|
||||
for (let index = 0; index < chunkCount; index += 1) {
|
||||
const chunk = media.buffer.subarray(index * rawChunkBytes, (index + 1) * rawChunkBytes);
|
||||
await options.chunkStore.register(
|
||||
buildHostedOutboundMediaChunkKey(id, index),
|
||||
{
|
||||
// Capacity check and writes stay serialized per helper instance. Cross-process
|
||||
// callers rely on reject-new backing stores so a race cannot evict live URLs.
|
||||
return await withCapacityMutation(async () => {
|
||||
await pruneForCapacity(chunkCount);
|
||||
try {
|
||||
for (let index = 0; index < chunkCount; index += 1) {
|
||||
const chunk = media.buffer.subarray(index * rawChunkBytes, (index + 1) * rawChunkBytes);
|
||||
await options.chunkStore.register(
|
||||
buildHostedOutboundMediaChunkKey(id, index),
|
||||
{
|
||||
id,
|
||||
index,
|
||||
dataBase64: chunk.toString("base64"),
|
||||
},
|
||||
{ ttlMs: options.ttlMs },
|
||||
);
|
||||
}
|
||||
await options.metadataStore.register(
|
||||
buildHostedOutboundMediaMetaKey(id),
|
||||
createHostedOutboundMediaMetaRecord({
|
||||
id,
|
||||
index,
|
||||
dataBase64: chunk.toString("base64"),
|
||||
},
|
||||
{ ttlMs: options.ttlMs },
|
||||
routePath: params.routePath,
|
||||
token,
|
||||
contentType: media.contentType,
|
||||
expiresAt,
|
||||
chunkCount,
|
||||
byteLength: media.buffer.byteLength,
|
||||
}),
|
||||
{ ttlMs: metadataTtlMs },
|
||||
);
|
||||
} catch (error) {
|
||||
await deleteEntryRows(id, chunkCount);
|
||||
throw error;
|
||||
}
|
||||
await options.metadataStore.register(
|
||||
buildHostedOutboundMediaMetaKey(id),
|
||||
createHostedOutboundMediaMetaRecord({
|
||||
id,
|
||||
routePath: params.routePath,
|
||||
token,
|
||||
contentType: media.contentType,
|
||||
expiresAt,
|
||||
chunkCount,
|
||||
byteLength: media.buffer.byteLength,
|
||||
}),
|
||||
{ ttlMs: metadataTtlMs },
|
||||
);
|
||||
} catch (error) {
|
||||
await deleteEntryRows(id, chunkCount);
|
||||
throw error;
|
||||
}
|
||||
return `${params.publicBaseUrl}${params.routePath}${id}?token=${token}`;
|
||||
return `${params.publicBaseUrl}${params.routePath}${id}?token=${token}`;
|
||||
});
|
||||
},
|
||||
async readMetadata(id, nowMs = Date.now()) {
|
||||
const meta = await readMetadataRecord(id, nowMs);
|
||||
return meta ? createHostedOutboundMediaMetadata(meta) : null;
|
||||
},
|
||||
async read(id, nowMs = Date.now()) {
|
||||
const meta = await options.metadataStore.lookup(buildHostedOutboundMediaMetaKey(id));
|
||||
const meta = await readMetadataRecord(id, nowMs);
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
if (!isFutureHostedOutboundMediaExpiry(meta.expiresAt, nowMs)) {
|
||||
await deleteEntry(id);
|
||||
return null;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
for (let index = 0; index < meta.chunkCount; index += 1) {
|
||||
const chunk = await options.chunkStore.lookup(buildHostedOutboundMediaChunkKey(id, index));
|
||||
if (!chunk || chunk.id !== id || chunk.index !== index) {
|
||||
await deleteEntry(id);
|
||||
await withCapacityMutation(async () => await deleteEntry(id));
|
||||
return null;
|
||||
}
|
||||
chunks.push(Buffer.from(chunk.dataBase64, "base64"));
|
||||
@@ -314,10 +416,14 @@ export function createHostedOutboundMediaStore(
|
||||
buffer: Buffer.concat(chunks, meta.byteLength),
|
||||
};
|
||||
},
|
||||
delete: deleteEntry,
|
||||
async delete(id) {
|
||||
await withCapacityMutation(async () => await deleteEntry(id));
|
||||
},
|
||||
cleanupExpired,
|
||||
async clear() {
|
||||
await Promise.all([options.metadataStore.clear(), options.chunkStore.clear()]);
|
||||
await withCapacityMutation(
|
||||
async () => await Promise.all([options.metadataStore.clear(), options.chunkStore.clear()]),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user