Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions builds/typescript/adapters/gateway-twilio-sms.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";

import {
normalizeTwilioSmsWebhookPayload,
parseTwilioSmsWebhookFormPayload,
} from "./gateway-twilio-sms.js";

describe("gateway twilio sms adapter", () => {
it("normalizes Twilio form payloads into canonical client message input plus metadata", () => {
const payload =
"MessageSid=SM1234567890abcdef1234567890abcd&AccountSid=AC1234567890abcdef1234567890abcd&From=%2B14155551234&To=%2B14155550000&Body=Hello%20from%20Twilio&NumMedia=0";

const normalized = normalizeTwilioSmsWebhookPayload(payload, {
receivedAt: "2026-04-15T16:00:00.000Z",
});

expect(normalized.ok).toBe(true);
if (!normalized.ok) {
return;
}

expect(normalized.webhook.client_message).toEqual({
content: "Hello from Twilio",
});
expect(normalized.webhook.metadata).toEqual({
transport: "twilio_sms",
trigger: "twilio_sms_webhook",
account_sid: "AC1234567890abcdef1234567890abcd",
message_sid: "SM1234567890abcdef1234567890abcd",
from_number: "+14155551234",
to_number: "+14155550000",
received_at: "2026-04-15T16:00:00.000Z",
});
expect(normalized.webhook.form_parameters).toMatchObject({
MessageSid: ["SM1234567890abcdef1234567890abcd"],
AccountSid: ["AC1234567890abcdef1234567890abcd"],
From: ["+14155551234"],
To: ["+14155550000"],
Body: ["Hello from Twilio"],
NumMedia: ["0"],
});
});

it("retains repeated form parameters for full signature validation", () => {
const parsed = parseTwilioSmsWebhookFormPayload("MediaUrl=https%3A%2F%2Fa&MediaUrl=https%3A%2F%2Fb");

expect(parsed.ok).toBe(true);
if (!parsed.ok) {
return;
}

expect(parsed.form_parameters.MediaUrl).toEqual(["https://a", "https://b"]);
});

it("fails validation when required Twilio webhook fields are missing", () => {
const normalized = normalizeTwilioSmsWebhookPayload(
"AccountSid=AC1234567890abcdef1234567890abcd&From=%2B14155551234&To=%2B14155550000"
);

expect(normalized.ok).toBe(false);
if (!normalized.ok) {
expect(normalized.failure.reason).toBe("invalid_request");
expect(normalized.failure.issueCount).toBeGreaterThan(0);
}
});
});
213 changes: 213 additions & 0 deletions builds/typescript/adapters/gateway-twilio-sms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { z } from "zod";

import type { ClientMessageRequest } from "../contracts.js";

export type TwilioSmsWebhookFormParameters = Record<string, string[]>;

export type TwilioSmsWebhookCanonicalInput = {
client_message: ClientMessageRequest;
metadata: {
transport: "twilio_sms";
trigger: "twilio_sms_webhook";
account_sid: string;
message_sid: string;
from_number: string;
to_number: string;
received_at: string;
};
form_parameters: TwilioSmsWebhookFormParameters;
};

export type TwilioSmsWebhookNormalizationFailure = {
reason: "invalid_request";
issueCount: number;
};

export type TwilioSmsWebhookNormalizationResult =
| {
ok: true;
webhook: TwilioSmsWebhookCanonicalInput;
}
| {
ok: false;
failure: TwilioSmsWebhookNormalizationFailure;
};

const requiredWebhookFieldsSchema = z.object({
account_sid: z.string().trim().min(1),
message_sid: z.string().trim().min(1),
from_number: z.string().trim().min(1),
to_number: z.string().trim().min(1),
body: z.string().trim().min(1),
});

export function normalizeTwilioSmsWebhookPayload(
payload: unknown,
options: { receivedAt?: string } = {}
): TwilioSmsWebhookNormalizationResult {
const parsedForm = parseTwilioSmsWebhookFormPayload(payload);
if (!parsedForm.ok) {
return {
ok: false,
failure: {
reason: "invalid_request",
issueCount: parsedForm.issueCount,
},
};
}

const requiredFields = {
account_sid: readFirstFormValue(parsedForm.form_parameters, "AccountSid"),
message_sid: readFirstFormValue(parsedForm.form_parameters, "MessageSid"),
from_number: readFirstFormValue(parsedForm.form_parameters, "From"),
to_number: readFirstFormValue(parsedForm.form_parameters, "To"),
body: readFirstFormValue(parsedForm.form_parameters, "Body"),
};
const parsedRequired = requiredWebhookFieldsSchema.safeParse(requiredFields);
if (!parsedRequired.success) {
return {
ok: false,
failure: {
reason: "invalid_request",
issueCount: parsedRequired.error.issues.length,
},
};
}

const receivedAt = normalizeReceivedAt(options.receivedAt);
const body = parsedRequired.data.body.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();

return {
ok: true,
webhook: {
client_message: {
content: body,
},
metadata: {
transport: "twilio_sms",
trigger: "twilio_sms_webhook",
account_sid: parsedRequired.data.account_sid,
message_sid: parsedRequired.data.message_sid,
from_number: parsedRequired.data.from_number,
to_number: parsedRequired.data.to_number,
received_at: receivedAt,
},
form_parameters: parsedForm.form_parameters,
},
};
}

export function parseTwilioSmsWebhookFormPayload(payload: unknown):
| { ok: true; form_parameters: TwilioSmsWebhookFormParameters }
| { ok: false; issueCount: number } {
if (typeof payload === "string") {
return {
ok: true,
form_parameters: formParametersFromUrlEncodedBody(payload),
};
}

if (payload instanceof URLSearchParams) {
return {
ok: true,
form_parameters: formParametersFromUrlSearchParams(payload),
};
}

if (!isRecord(payload)) {
return {
ok: false,
issueCount: 1,
};
}

const formParameters: TwilioSmsWebhookFormParameters = {};
let issueCount = 0;

for (const [rawKey, rawValue] of Object.entries(payload)) {
if (typeof rawKey !== "string") {
issueCount += 1;
continue;
}

const key = rawKey.trim();
if (!key) {
issueCount += 1;
continue;
}

const values = toStringArray(rawValue);
if (!values) {
issueCount += 1;
continue;
}

formParameters[key] = values;
}

if (issueCount > 0) {
return {
ok: false,
issueCount,
};
}

return {
ok: true,
form_parameters: formParameters,
};
}

function formParametersFromUrlEncodedBody(rawBody: string): TwilioSmsWebhookFormParameters {
const params = new URLSearchParams(rawBody);
return formParametersFromUrlSearchParams(params);
}

function formParametersFromUrlSearchParams(params: URLSearchParams): TwilioSmsWebhookFormParameters {
const formParameters: TwilioSmsWebhookFormParameters = {};

for (const [key, value] of params) {
if (!(key in formParameters)) {
formParameters[key] = [];
}
formParameters[key].push(value);
}

return formParameters;
}

function readFirstFormValue(formParameters: TwilioSmsWebhookFormParameters, key: string): string {
return formParameters[key]?.[0] ?? "";
}

function normalizeReceivedAt(value: string | undefined): string {
const parsed = value ? Date.parse(value) : Date.now();
if (!Number.isFinite(parsed)) {
return new Date().toISOString();
}
return new Date(parsed).toISOString();
}

function toStringArray(value: unknown): string[] | null {
if (typeof value === "string") {
return [value];
}

if (!Array.isArray(value)) {
return null;
}

const values: string[] = [];
for (const entry of value) {
if (typeof entry !== "string") {
return null;
}
values.push(entry);
}

return values;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
2 changes: 1 addition & 1 deletion builds/typescript/client_web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ type AppScreen = "loading" | "auth" | "main";
export default function App() {
const [screen, setScreen] = useState<AppScreen>("loading");
const [deploymentMode, setDeploymentMode] = useState<"local" | "managed">("local");
const [installMode, setInstallMode] = useState<"local" | "quickstart" | "prod" | "unknown">(
const [installMode, setInstallMode] = useState<"dev" | "local" | "quickstart" | "prod" | "unknown">(
"unknown"
);
const [appVersion, setAppVersion] = useState<string>("unknown");
Expand Down
6 changes: 6 additions & 0 deletions builds/typescript/client_web/src/api/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
> - `PUT /api/settings`
> - `GET /api/settings/onboarding-status`
> - `PUT /api/settings/credentials`
> - `PUT /api/settings/twilio-sms`
> - `POST /api/settings/twilio-sms/test-send`
> Twilio ingress (external, no `/api` prefix):
> - `POST /twilio/sms/webhook` (public Twilio webhook route; signature-validated)
> - Twilio webhook intake now reuses the same canonical Gateway message-processing path as `POST /api/message`.
> - Auto-reply uses the shared Gateway processing flow, applies per-sender sequential processing, and enforces per-sender round-trip rate limits with one cap notice SMS per active window.
> - `GET /api/export`
> Streaming canonical event fields are `text-delta.delta` and `tool-call.input`.

Expand Down
5 changes: 4 additions & 1 deletion builds/typescript/client_web/src/api/config-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { buildLocalOwnerHeaders } from "./local-auth";

export type GatewayInstallMode = "local" | "quickstart" | "prod" | "unknown";
export type GatewayInstallMode = "dev" | "local" | "quickstart" | "prod" | "unknown";

type GatewayConfig = {
mode?: string;
Expand Down Expand Up @@ -45,6 +45,9 @@ function toDeploymentMode(value: unknown): "local" | "managed" {
}

function toInstallMode(value: unknown): GatewayInstallMode {
if (value === "dev") {
return "dev";
}
if (value === "quickstart") {
return "local";
}
Expand Down
Loading