diff --git a/builds/typescript/adapters/gateway-twilio-sms.test.ts b/builds/typescript/adapters/gateway-twilio-sms.test.ts new file mode 100644 index 0000000..768dc0a --- /dev/null +++ b/builds/typescript/adapters/gateway-twilio-sms.test.ts @@ -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); + } + }); +}); diff --git a/builds/typescript/adapters/gateway-twilio-sms.ts b/builds/typescript/adapters/gateway-twilio-sms.ts new file mode 100644 index 0000000..e9688e8 --- /dev/null +++ b/builds/typescript/adapters/gateway-twilio-sms.ts @@ -0,0 +1,213 @@ +import { z } from "zod"; + +import type { ClientMessageRequest } from "../contracts.js"; + +export type TwilioSmsWebhookFormParameters = Record; + +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 { + return typeof value === "object" && value !== null; +} diff --git a/builds/typescript/client_web/src/App.tsx b/builds/typescript/client_web/src/App.tsx index bb8e597..70c29f6 100644 --- a/builds/typescript/client_web/src/App.tsx +++ b/builds/typescript/client_web/src/App.tsx @@ -10,7 +10,7 @@ type AppScreen = "loading" | "auth" | "main"; export default function App() { const [screen, setScreen] = useState("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("unknown"); diff --git a/builds/typescript/client_web/src/api/CONTRACT.md b/builds/typescript/client_web/src/api/CONTRACT.md index 06fb24a..20c20cd 100644 --- a/builds/typescript/client_web/src/api/CONTRACT.md +++ b/builds/typescript/client_web/src/api/CONTRACT.md @@ -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`. diff --git a/builds/typescript/client_web/src/api/config-adapter.ts b/builds/typescript/client_web/src/api/config-adapter.ts index 91326df..eff6d99 100644 --- a/builds/typescript/client_web/src/api/config-adapter.ts +++ b/builds/typescript/client_web/src/api/config-adapter.ts @@ -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; @@ -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"; } diff --git a/builds/typescript/client_web/src/api/gateway-adapter.test.ts b/builds/typescript/client_web/src/api/gateway-adapter.test.ts index 06602d6..b17e34f 100644 --- a/builds/typescript/client_web/src/api/gateway-adapter.test.ts +++ b/builds/typescript/client_web/src/api/gateway-adapter.test.ts @@ -8,7 +8,9 @@ import { importLibraryArchive, restoreMemoryBackup, runMemoryBackupNow, + sendTwilioTestSms, sendMessage, + updateTwilioSmsSettings, updateMemoryBackupSettings, updateProviderCredential, type ChatEvent, @@ -422,3 +424,101 @@ describe("gateway-adapter onboarding settings", () => { ); }); }); + +describe("gateway-adapter twilio sms settings", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("updates Twilio SMS settings through the dedicated endpoint", async () => { + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + default_model: "openai/gpt-4o-mini", + approval_mode: "ask-on-write", + active_provider_profile: "openrouter", + default_provider_profile: "openrouter", + available_models: ["openai/gpt-4o-mini"], + provider_profiles: [], + memory_backup: null, + twilio_sms: { + enabled: true, + account_sid: "AC1234567890abcdef1234567890abcd", + from_number: "+14155552671", + public_base_url: "https://example.com", + auto_reply: true, + strict_owner_mode: false, + owner_phone_number: null, + rate_limit_period: 60, + rate_limit_cap_round_trips: 5, + rate_limit_current_count: 0, + token_configured: true, + test_recipient: "+14155553333", + last_error: null, + webhook_url: "https://example.com/twilio/sms/webhook", + }, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ) + ); + vi.stubGlobal("fetch", fetchMock); + + await updateTwilioSmsSettings({ + enabled: true, + account_sid: "AC1234567890abcdef1234567890abcd", + from_number: "+14155552671", + public_base_url: "https://example.com", + auto_reply: true, + strict_owner_mode: false, + rate_limit_period: 60, + rate_limit_cap_round_trips: 5, + auth_token: "twilio_secret_v1", + }); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/settings/twilio-sms", + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ "Content-Type": "application/json" }), + }) + ); + }); + + it("triggers Twilio SMS test send", async () => { + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + result: "success", + recipient: "+14155553333", + message: "hello", + sent_at: "2026-04-07T12:10:03.000Z", + provider: { + message_sid: "SM11111111111111111111111111111111", + status: "queued", + }, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ) + ); + vi.stubGlobal("fetch", fetchMock); + + const payload = await sendTwilioTestSms({ + recipient: "+14155553333", + message: "hello", + }); + expect(payload.result).toBe("success"); + expect(fetchMock).toHaveBeenCalledWith( + "/api/settings/twilio-sms/test-send", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ "Content-Type": "application/json" }), + }) + ); + }); +}); diff --git a/builds/typescript/client_web/src/api/gateway-adapter.ts b/builds/typescript/client_web/src/api/gateway-adapter.ts index a188438..37f40a2 100644 --- a/builds/typescript/client_web/src/api/gateway-adapter.ts +++ b/builds/typescript/client_web/src/api/gateway-adapter.ts @@ -19,10 +19,13 @@ import { type GatewayMigrationImportResult, type GatewayModelCatalog, type GatewayOnboardingStatus, - type GatewaySkillBinding, - type GatewaySkillSummary, - type GatewaySettings, -} from "./types"; + type GatewayTwilioSmsSettingsUpdateRequest, + type GatewayTwilioSmsTestSendRequest, + type GatewayTwilioSmsTestSendResponse, + type GatewaySkillBinding, + type GatewaySkillSummary, + type GatewaySettings, +} from "./types"; export const GATEWAY_BASE_URL = "/api"; @@ -626,11 +629,43 @@ export async function restoreMemoryBackup( return (await response.json()) as GatewayMemoryBackupRestoreResponse; } - -export async function getOwnerProfile(): Promise { - const response = await authenticatedFetch(`${GATEWAY_BASE_URL}/profile`, { - headers: withLocalOwnerHeaders(), - }); + +export async function updateTwilioSmsSettings( + payload: GatewayTwilioSmsSettingsUpdateRequest +): Promise { + const response = await authenticatedFetch(`${GATEWAY_BASE_URL}/settings/twilio-sms`, { + method: "PUT", + headers: withLocalOwnerHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw await toGatewayError(response); + } + + return (await response.json()) as GatewaySettings; +} + +export async function sendTwilioTestSms( + payload: GatewayTwilioSmsTestSendRequest +): Promise { + const response = await authenticatedFetch(`${GATEWAY_BASE_URL}/settings/twilio-sms/test-send`, { + method: "POST", + headers: withLocalOwnerHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw await toGatewayError(response); + } + + return (await response.json()) as GatewayTwilioSmsTestSendResponse; +} + +export async function getOwnerProfile(): Promise { + const response = await authenticatedFetch(`${GATEWAY_BASE_URL}/profile`, { + headers: withLocalOwnerHeaders(), + }); if (response.status === 404) { return null; diff --git a/builds/typescript/client_web/src/api/types.ts b/builds/typescript/client_web/src/api/types.ts index 47eae1c..68373eb 100644 --- a/builds/typescript/client_web/src/api/types.ts +++ b/builds/typescript/client_web/src/api/types.ts @@ -160,6 +160,67 @@ export type GatewayMemoryBackupSettingsUpdateRequest = { token_secret_ref?: string; }; +export type GatewayTwilioSmsSettings = { + enabled: boolean; + account_sid: string; + from_number: string; + public_base_url: string; + auto_reply: boolean; + strict_owner_mode: boolean; + owner_phone_number: string | null; + rate_limit_period: number; + rate_limit_cap_round_trips: number; + rate_limit_current_count: number; + rate_limit_period_started_at?: string; + rate_limit_last_notified_at?: string; + token_configured: boolean; + test_recipient: string | null; + last_inbound_at?: string; + last_outbound_at?: string; + last_result?: string; + last_error: string | null; + webhook_url: string | null; +}; + +export type GatewayTwilioSmsSettingsUpdateRequest = { + enabled: boolean; + account_sid: string; + from_number: string; + public_base_url: string; + auto_reply: boolean; + strict_owner_mode: boolean; + owner_phone_number?: string | null; + rate_limit_period: number; + rate_limit_cap_round_trips: number; + rate_limit_current_count?: number; + rate_limit_period_started_at?: string | null; + rate_limit_last_notified_at?: string | null; + auth_token?: string; + auth_token_secret_ref?: string; + test_recipient?: string | null; + last_inbound_at?: string | null; + last_outbound_at?: string | null; + last_result?: string | null; + last_error?: string | null; +}; + +export type GatewayTwilioSmsTestSendRequest = { + recipient?: string | null; + message: string; +}; + +export type GatewayTwilioSmsTestSendResponse = { + result: "success" | "failed"; + recipient: string; + message: string; + sent_at: string; + error?: string; + provider?: { + message_sid: string | null; + status: string | null; + }; +}; + export type GatewayMemoryBackupRunResult = { attempted_at: string; saved_at?: string; @@ -197,6 +258,7 @@ export type GatewaySettings = { available_models: string[]; provider_profiles: GatewayProviderProfile[]; memory_backup: GatewayMemoryBackupSettings | null; + twilio_sms?: GatewayTwilioSmsSettings | null; }; export type GatewayOnboardingProvider = { diff --git a/builds/typescript/client_web/src/components/layout/AppShell.tsx b/builds/typescript/client_web/src/components/layout/AppShell.tsx index 6d10a89..87c5833 100644 --- a/builds/typescript/client_web/src/components/layout/AppShell.tsx +++ b/builds/typescript/client_web/src/components/layout/AppShell.tsx @@ -14,7 +14,7 @@ import Sidebar from "./Sidebar"; type AppShellProps = { children?: ReactNode; deploymentMode?: "local" | "managed"; - installMode?: "local" | "quickstart" | "prod" | "unknown"; + installMode?: "dev" | "local" | "quickstart" | "prod" | "unknown"; appVersion?: string; onLogout?: () => void; }; diff --git a/builds/typescript/client_web/src/components/settings/SettingsModal.test.tsx b/builds/typescript/client_web/src/components/settings/SettingsModal.test.tsx index 75640b7..f41d83d 100644 --- a/builds/typescript/client_web/src/components/settings/SettingsModal.test.tsx +++ b/builds/typescript/client_web/src/components/settings/SettingsModal.test.tsx @@ -5,7 +5,10 @@ import type { GatewayMemoryBackupRestoreRequest, GatewayMemoryBackupSettingsUpdateRequest, GatewayModelCatalog, - GatewaySettings + GatewaySettings, + GatewayTwilioSmsSettingsUpdateRequest, + GatewayTwilioSmsTestSendRequest, + GatewayTwilioSmsTestSendResponse, } from "@/api/types"; import SettingsModal from "./SettingsModal"; @@ -33,6 +36,12 @@ const runMemoryBackupNowMock = vi.fn< const restoreMemoryBackupMock = vi.fn< (payload?: GatewayMemoryBackupRestoreRequest) => Promise<{ result: { commit: string }; settings: GatewaySettings }> >(); +const updateTwilioSmsSettingsMock = vi.fn< + (payload: GatewayTwilioSmsSettingsUpdateRequest) => Promise +>(); +const sendTwilioTestSmsMock = vi.fn< + (payload: GatewayTwilioSmsTestSendRequest) => Promise +>(); vi.mock("@/api/gateway-adapter", () => ({ getSettings: () => getSettingsMock(), @@ -44,6 +53,9 @@ vi.mock("@/api/gateway-adapter", () => ({ updateMemoryBackupSettingsMock(payload), runMemoryBackupNow: () => runMemoryBackupNowMock(), restoreMemoryBackup: (payload?: GatewayMemoryBackupRestoreRequest) => restoreMemoryBackupMock(payload), + updateTwilioSmsSettings: (payload: GatewayTwilioSmsSettingsUpdateRequest) => + updateTwilioSmsSettingsMock(payload), + sendTwilioTestSms: (payload: GatewayTwilioSmsTestSendRequest) => sendTwilioTestSmsMock(payload), getProviderModels: (providerProfile?: string) => getProviderModelsMock(providerProfile), downloadLibraryExport: () => downloadLibraryExportMock(), importLibraryArchive: (file: Blob) => importLibraryArchiveMock(file), @@ -56,6 +68,25 @@ const baseSettings: GatewaySettings = { default_provider_profile: "openrouter", available_models: ["openai/gpt-4o-mini", "llama3.1"], memory_backup: null, + twilio_sms: { + enabled: true, + account_sid: "AC1234567890abcdef1234567890abcd", + from_number: "+14155552671", + public_base_url: "https://example.com", + auto_reply: true, + strict_owner_mode: false, + owner_phone_number: null, + rate_limit_period: 60, + rate_limit_cap_round_trips: 5, + rate_limit_current_count: 1, + token_configured: true, + test_recipient: "+14155553333", + last_inbound_at: "2026-04-07T12:00:00.000Z", + last_outbound_at: "2026-04-07T12:05:00.000Z", + last_result: "success", + last_error: null, + webhook_url: "https://example.com/twilio/sms/webhook", + }, provider_profiles: [ { id: "openrouter", @@ -120,6 +151,9 @@ describe("SettingsModal", () => { updateMemoryBackupSettingsMock.mockReset(); runMemoryBackupNowMock.mockReset(); restoreMemoryBackupMock.mockReset(); + updateTwilioSmsSettingsMock.mockReset(); + sendTwilioTestSmsMock.mockReset(); + getSettingsMock.mockResolvedValue(baseSettings); updateSettingsMock.mockResolvedValue(baseSettings); updateProviderCredentialMock.mockResolvedValue({ settings: baseSettings }); @@ -132,6 +166,17 @@ describe("SettingsModal", () => { result: { commit: "abc123def456" }, settings: settingsWithBackup, }); + updateTwilioSmsSettingsMock.mockResolvedValue(baseSettings); + sendTwilioTestSmsMock.mockResolvedValue({ + result: "success", + recipient: "+14155553333", + message: "hello", + sent_at: "2026-04-07T12:10:03.000Z", + provider: { + message_sid: "SM11111111111111111111111111111111", + status: "queued", + }, + }); getProviderModelsMock.mockResolvedValue(providerCatalog); downloadLibraryExportMock.mockResolvedValue({ fileName: "memory-export-123.tar.gz", @@ -172,7 +217,7 @@ describe("SettingsModal", () => { }); }); - it("downloads export from the export tab", async () => { + it("downloads export from the migrate tab", async () => { const user = userEvent.setup(); render( {}} />); @@ -180,15 +225,15 @@ describe("SettingsModal", () => { expect(getSettingsMock).toHaveBeenCalledTimes(1); }); - await user.click(screen.getAllByRole("button", { name: "Migrate Library" })[0]!); - await user.click(screen.getAllByRole("button", { name: "Download Library (.tar.gz)" })[0]!); + await user.click(screen.getAllByRole("button", { name: "Migrate" })[0]!); + await user.click(screen.getAllByRole("button", { name: "Download" })[0]!); await waitFor(() => { expect(downloadLibraryExportMock).toHaveBeenCalledTimes(1); }); }); - it("imports a migration archive from the export tab", async () => { + it("imports a migration archive from the migrate tab", async () => { const user = userEvent.setup(); render( {}} />); @@ -196,12 +241,12 @@ describe("SettingsModal", () => { expect(getSettingsMock).toHaveBeenCalledTimes(1); }); - await user.click(screen.getAllByRole("button", { name: "Migrate Library" })[0]!); + await user.click(screen.getAllByRole("button", { name: "Migrate" })[0]!); - const importInput = screen.getByLabelText("Migration Archive (.tar.gz)") as HTMLInputElement; + const importInput = screen.getAllByLabelText("Choose file")[0] as HTMLInputElement; const file = new File(["archive"], "memory-migration.tar.gz", { type: "application/gzip" }); await user.upload(importInput, file); - await user.click(screen.getAllByRole("button", { name: "Import Library (.tar.gz)" })[0]!); + await user.click(screen.getAllByRole("button", { name: "Import" })[0]!); await waitFor(() => { expect(importLibraryArchiveMock).toHaveBeenCalledTimes(1); @@ -216,14 +261,14 @@ describe("SettingsModal", () => { expect(getSettingsMock).toHaveBeenCalledTimes(1); }); - await user.click(screen.getAllByRole("button", { name: "Migrate Library" })[0]!); + await user.click(screen.getAllByRole("button", { name: "Migrate" })[0]!); - const importButton = screen.getAllByRole("button", { name: "Import Library (.tar.gz)" })[0] as HTMLButtonElement; + const importButton = screen.getAllByRole("button", { name: "Import" })[0] as HTMLButtonElement; expect(importButton).toBeDisabled(); await user.click(importButton); expect(importLibraryArchiveMock).not.toHaveBeenCalled(); - const importInput = screen.getByLabelText("Migration Archive (.tar.gz)") as HTMLInputElement; + const importInput = screen.getAllByLabelText("Choose file")[0] as HTMLInputElement; const file = new File(["archive"], "memory-migration.tar.gz", { type: "application/gzip" }); await user.upload(importInput, file); @@ -238,7 +283,7 @@ describe("SettingsModal", () => { expect(getSettingsMock).toHaveBeenCalledTimes(1); }); - await user.click(screen.getAllByRole("button", { name: "Default Model" })[0]!); + await user.click(screen.getAllByRole("button", { name: "AI Model" })[0]!); await waitFor(() => { expect(getProviderModelsMock).toHaveBeenCalled(); }); @@ -263,22 +308,114 @@ describe("SettingsModal", () => { }); }); - it("renders memory backup tab below migrate library in local mode", async () => { - render( {}} />); + it("renders SMS (Twilio) tab only for dev install mode", async () => { + const { rerender } = render( {}} />); + + await waitFor(() => { + expect(getSettingsMock).toHaveBeenCalledTimes(1); + }); + + expect(screen.getAllByRole("button", { name: "SMS (Twilio)" }).length).toBeGreaterThan(0); + + rerender( {}} />); + expect(screen.queryAllByRole("button", { name: "SMS (Twilio)" }).length).toBe(0); + }); + + it("keeps auth token field write-only after loading twilio settings", async () => { + const user = userEvent.setup(); + render( {}} />); + + await waitFor(() => { + expect(getSettingsMock).toHaveBeenCalledTimes(1); + }); + + await user.click(screen.getAllByRole("button", { name: "SMS (Twilio)" })[0]!); + const authTokenInput = screen.getAllByLabelText("Auth Token")[0] as HTMLInputElement; + expect(authTokenInput.value).toBe(""); + expect(authTokenInput.placeholder).toContain("Leave blank"); + }); + + it("renders Twilio webhook/runtime status values and copies webhook URL", async () => { + const user = userEvent.setup(); + const writeTextMock = vi.fn<(text: string) => Promise>(async () => {}); + Object.defineProperty(window.navigator, "clipboard", { + configurable: true, + value: { + writeText: writeTextMock, + }, + }); + + render( {}} />); + + await waitFor(() => { + expect(getSettingsMock).toHaveBeenCalledTimes(1); + }); + + await user.click(screen.getAllByRole("button", { name: "SMS (Twilio)" })[0]!); + + expect(screen.getAllByDisplayValue("https://example.com/twilio/sms/webhook").length).toBeGreaterThan(0); + expect(screen.getAllByText("1/5").length).toBeGreaterThan(0); + expect(screen.getAllByText("Success").length).toBeGreaterThan(0); + + await user.click(screen.getAllByRole("button", { name: "Copy Webhook URL" })[0]!); + await waitFor(() => { + expect(writeTextMock).toHaveBeenCalledWith("https://example.com/twilio/sms/webhook"); + }); + expect(screen.getAllByText("Webhook URL copied.").length).toBeGreaterThan(0); + }); + + it("saves twilio sms settings", async () => { + const user = userEvent.setup(); + render( {}} />); await waitFor(() => { expect(getSettingsMock).toHaveBeenCalledTimes(1); }); - const tabLabels = screen - .getAllByRole("button") - .map((button) => button.textContent?.trim() ?? "") - .filter(Boolean); + await user.click(screen.getAllByRole("button", { name: "SMS (Twilio)" })[0]!); + await user.clear(screen.getAllByLabelText("Account SID")[0]!); + await user.type(screen.getAllByLabelText("Account SID")[0]!, "AC99999999999999999999999999999999"); + await user.clear(screen.getAllByLabelText("From Number")[0]!); + await user.type(screen.getAllByLabelText("From Number")[0]!, "+14155550000"); + await user.clear(screen.getAllByLabelText("Public Base URL")[0]!); + await user.type(screen.getAllByLabelText("Public Base URL")[0]!, "https://new.example.com"); + await user.type(screen.getAllByLabelText("Auth Token")[0]!, "twilio_secret_v2"); + await user.click(screen.getAllByRole("button", { name: "Save SMS Settings" })[0]!); - const migrateIndex = tabLabels.indexOf("Migrate Library"); - const backupIndex = tabLabels.indexOf("Memory Backup"); - expect(migrateIndex).toBeGreaterThanOrEqual(0); - expect(backupIndex).toBeGreaterThan(migrateIndex); + await waitFor(() => { + expect(updateTwilioSmsSettingsMock).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + account_sid: "AC99999999999999999999999999999999", + from_number: "+14155550000", + public_base_url: "https://new.example.com", + auth_token: "twilio_secret_v2", + }) + ); + }); + }); + + it("sends twilio test sms", async () => { + const user = userEvent.setup(); + render( {}} />); + + await waitFor(() => { + expect(getSettingsMock).toHaveBeenCalledTimes(1); + }); + + await user.click(screen.getAllByRole("button", { name: "SMS (Twilio)" })[0]!); + await user.clear(screen.getAllByLabelText("Test Recipient")[0]!); + await user.type(screen.getAllByLabelText("Test Recipient")[0]!, "+14155558888"); + await user.clear(screen.getAllByLabelText("Test Message")[0]!); + await user.type(screen.getAllByLabelText("Test Message")[0]!, "hello from test"); + await user.click(screen.getAllByRole("button", { name: "Send Test SMS" })[0]!); + + await waitFor(() => { + expect(sendTwilioTestSmsMock).toHaveBeenCalledWith({ + recipient: "+14155558888", + message: "hello from test", + }); + }); }); it("saves memory backup settings", async () => { @@ -290,15 +427,15 @@ describe("SettingsModal", () => { expect(getSettingsMock).toHaveBeenCalledTimes(1); }); - await user.click(screen.getAllByRole("button", { name: "Memory Backup" })[0]!); + await user.click(screen.getAllByRole("button", { name: "Backup" })[0]!); await user.clear(screen.getAllByLabelText("Repository URL")[0]!); await user.type( screen.getAllByLabelText("Repository URL")[0]!, "https://github.com/BrainDriveAI/braindrive-memory.git" ); - await user.type(screen.getAllByLabelText("Git Token (PAT/Classic)")[0]!, "ghp_test"); - await user.selectOptions(screen.getAllByLabelText("Frequency")[0]!, "daily"); - await user.click(screen.getAllByRole("button", { name: "Save Backup Settings" })[0]!); + await user.type(screen.getAllByLabelText("Token")[0]!, "ghp_test"); + await user.click(screen.getAllByRole("button", { name: "Every day" })[0]!); + await user.click(screen.getAllByRole("button", { name: "Save Settings" })[0]!); await waitFor(() => { expect(updateMemoryBackupSettingsMock).toHaveBeenCalledWith({ @@ -309,7 +446,7 @@ describe("SettingsModal", () => { }); }); - it("runs manual save from memory backup tab", async () => { + it("runs manual save from backup tab", async () => { const user = userEvent.setup(); getSettingsMock.mockResolvedValueOnce(settingsWithBackup); render( {}} />); @@ -318,15 +455,15 @@ describe("SettingsModal", () => { expect(getSettingsMock).toHaveBeenCalledTimes(1); }); - await user.click(screen.getAllByRole("button", { name: "Memory Backup" })[0]!); - await user.click(screen.getAllByRole("button", { name: "Save Now" })[0]!); + await user.click(screen.getAllByRole("button", { name: "Backup" })[0]!); + await user.click(screen.getAllByRole("button", { name: "Back Up Now" })[0]!); await waitFor(() => { expect(runMemoryBackupNowMock).toHaveBeenCalledTimes(1); }); }); - it("runs restore from memory backup tab after confirmation", async () => { + it("runs restore from backup tab after confirmation", async () => { const user = userEvent.setup(); getSettingsMock.mockResolvedValueOnce(settingsWithBackup); const confirmMock = vi.spyOn(window, "confirm").mockReturnValue(true); @@ -336,8 +473,8 @@ describe("SettingsModal", () => { expect(getSettingsMock).toHaveBeenCalledTimes(1); }); - await user.click(screen.getAllByRole("button", { name: "Memory Backup" })[0]!); - await user.click(screen.getAllByRole("button", { name: "Restore from Backup Repo" })[0]!); + await user.click(screen.getAllByRole("button", { name: "Backup" })[0]!); + await user.click(screen.getAllByRole("button", { name: "Restore from Backup" })[0]!); await waitFor(() => { expect(restoreMemoryBackupMock).toHaveBeenCalledTimes(1); diff --git a/builds/typescript/client_web/src/components/settings/SettingsModal.tsx b/builds/typescript/client_web/src/components/settings/SettingsModal.tsx index 62c8cf1..dd53e03 100644 --- a/builds/typescript/client_web/src/components/settings/SettingsModal.tsx +++ b/builds/typescript/client_web/src/components/settings/SettingsModal.tsx @@ -1,7 +1,9 @@ import { useEffect, useId, useMemo, useRef, useState } from "react"; import { + Copy, Download, Key, + MessageSquare, Cpu, LoaderCircle, PencilLine, @@ -29,10 +31,12 @@ import { restoreMemoryBackup as restoreGatewayMemoryBackup, runMemoryBackupNow, pullProviderModel, + sendTwilioTestSms as sendGatewayTwilioTestSms, updateMemoryBackupSettings as updateGatewayMemoryBackupSettings, updateOwnerProfile, updateProviderCredential as updateGatewayProviderCredential, updateSettings as updateGatewaySettings, + updateTwilioSmsSettings as updateGatewayTwilioSmsSettings, getAccount, changePassword as apiChangePassword, changeEmail as apiChangeEmail, @@ -53,6 +57,9 @@ import type { GatewayModelCatalogEntry, GatewayMigrationImportResult, GatewaySettings, + GatewayTwilioSmsSettingsUpdateRequest, + GatewayTwilioSmsTestSendRequest, + GatewayTwilioSmsTestSendResponse, } from "@/api/types"; import type { UserProfile } from "@/types/ui"; @@ -62,14 +69,28 @@ type SettingsPatch = Partial void; }; -type SettingsTab = "provider" | "model" | "profile" | "account" | "export" | "memory-backup"; - -type TabDef = { id: SettingsTab; label: string; icon: typeof Key; managedOnly?: boolean; localOnly?: boolean }; +type SettingsTab = + | "provider" + | "model" + | "profile" + | "account" + | "export" + | "memory-backup" + | "twilio-sms"; + +type TabDef = { + id: SettingsTab; + label: string; + icon: typeof Key; + managedOnly?: boolean; + localOnly?: boolean; + devOnly?: boolean; +}; // Managed hosting shows: Account, Owner Profile, Export (D93). // Local shows: Default Model, Model Providers, Owner Profile, Export, Memory Backup. @@ -79,6 +100,7 @@ const allTabs: TabDef[] = [ { id: "provider", label: "Model Providers", icon: Key, localOnly: true }, { id: "profile", label: "Your Profile", icon: User }, { id: "memory-backup", label: "Backup", icon: Save, localOnly: true }, + { id: "twilio-sms", label: "SMS (Twilio)", icon: MessageSquare, localOnly: true, devOnly: true }, { id: "export", label: "Migrate", icon: Download }, ]; @@ -91,6 +113,7 @@ export default function SettingsModal({ const tabs = allTabs.filter((tab) => { if (tab.managedOnly && mode !== "managed") return false; if (tab.localOnly && mode !== "local") return false; + if (tab.devOnly && installMode !== "dev") return false; return true; }); const [activeTab, setActiveTab] = useState(mode === "managed" ? "account" : "model"); @@ -260,6 +283,39 @@ export default function SettingsModal({ return updated.result; } + async function saveTwilioSmsSettings( + payload: GatewayTwilioSmsSettingsUpdateRequest + ): Promise { + const updated = await updateGatewayTwilioSmsSettings(payload); + setSettings(updated); + setSettingsError(null); + return updated; + } + + async function triggerTwilioTestSms( + payload: GatewayTwilioSmsTestSendRequest + ): Promise { + try { + const result = await sendGatewayTwilioTestSms(payload); + try { + const refreshed = await getGatewaySettings(); + setSettings(refreshed); + setSettingsError(null); + } catch { + // best-effort refresh; the send itself already succeeded + } + return result; + } catch (error) { + try { + const refreshed = await getGatewaySettings(); + setSettings(refreshed); + } catch { + // preserve original request error if refresh fails + } + throw error; + } + } + async function handleDownloadExport(): Promise { setIsExporting(true); setExportError(null); @@ -362,6 +418,8 @@ export default function SettingsModal({ onSaveMemoryBackupSettings={saveMemoryBackupSettings} onRunMemoryBackupNow={triggerMemoryBackupNow} onRestoreMemoryBackup={triggerMemoryBackupRestore} + onSaveTwilioSmsSettings={saveTwilioSmsSettings} + onSendTwilioTestSms={triggerTwilioTestSms} onDownloadExport={handleDownloadExport} isExporting={isExporting} exportError={exportError} @@ -432,6 +490,8 @@ export default function SettingsModal({ onSaveMemoryBackupSettings={saveMemoryBackupSettings} onRunMemoryBackupNow={triggerMemoryBackupNow} onRestoreMemoryBackup={triggerMemoryBackupRestore} + onSaveTwilioSmsSettings={saveTwilioSmsSettings} + onSendTwilioTestSms={triggerTwilioTestSms} onDownloadExport={handleDownloadExport} isExporting={isExporting} exportError={exportError} @@ -519,6 +579,8 @@ function TabContent({ onSaveMemoryBackupSettings, onRunMemoryBackupNow, onRestoreMemoryBackup, + onSaveTwilioSmsSettings, + onSendTwilioTestSms, onDownloadExport, isExporting, exportError, @@ -550,6 +612,12 @@ function TabContent({ onRestoreMemoryBackup: ( payload?: GatewayMemoryBackupRestoreRequest ) => Promise; + onSaveTwilioSmsSettings: ( + payload: GatewayTwilioSmsSettingsUpdateRequest + ) => Promise; + onSendTwilioTestSms: ( + payload: GatewayTwilioSmsTestSendRequest + ) => Promise; onDownloadExport: () => Promise; isExporting: boolean; exportError: string | null; @@ -557,7 +625,7 @@ function TabContent({ isImporting: boolean; importError: string | null; importResult: GatewayMigrationImportResult | null; - installMode: "local" | "quickstart" | "prod" | "unknown"; + installMode: "dev" | "local" | "quickstart" | "prod" | "unknown"; appVersion: string; onRefreshCatalog: () => void; onNavigateToTab: (tab: SettingsTab) => void; @@ -608,6 +676,17 @@ function TabContent({ onRestoreMemoryBackup={onRestoreMemoryBackup} /> ); + case "twilio-sms": + return ( + + ); case "profile": return ; case "account": @@ -699,12 +778,6 @@ function MemoryBackupSection({ ? new Date(backupSettings.last_save_at).toLocaleString() : "No backups yet"; const lastResult = backupSettings?.last_result ?? "never"; - const statusText = - lastResult === "success" - ? "Success" - : lastResult === "failed" - ? "Failed" - : "No backups yet"; const frequencyOptions: Array<{ value: GatewayMemoryBackupFrequency; label: string }> = [ { value: "after_changes", label: "After changes" }, { value: "hourly", label: "Every hour" }, @@ -791,7 +864,7 @@ function MemoryBackupSection({ { @@ -974,7 +1047,7 @@ function MemoryBackupSection({ setRestoreError(null); setRestoreMessage(null); void onRestoreMemoryBackup() - .then((result) => { + .then(() => { setRestoreMessage(`Restored from backup successfully.`); }) .catch((error) => { @@ -1003,6 +1076,517 @@ function MemoryBackupSection({ ); } +function TwilioSmsSection({ + mode, + settings, + isLoadingSettings, + settingsError, + onSaveTwilioSmsSettings, + onSendTwilioTestSms, +}: { + mode: "local" | "managed"; + settings: GatewaySettings | null; + isLoadingSettings: boolean; + settingsError: string | null; + onSaveTwilioSmsSettings: ( + payload: GatewayTwilioSmsSettingsUpdateRequest + ) => Promise; + onSendTwilioTestSms: ( + payload: GatewayTwilioSmsTestSendRequest + ) => Promise; +}) { + const twilioSettings = settings?.twilio_sms ?? null; + + const [enabled, setEnabled] = useState(false); + const [accountSid, setAccountSid] = useState(""); + const [fromNumber, setFromNumber] = useState(""); + const [publicBaseUrl, setPublicBaseUrl] = useState(""); + const [autoReply, setAutoReply] = useState(false); + const [strictOwnerMode, setStrictOwnerMode] = useState(false); + const [ownerPhoneNumber, setOwnerPhoneNumber] = useState(""); + const [rateLimitPeriod, setRateLimitPeriod] = useState("60"); + const [rateLimitCap, setRateLimitCap] = useState("5"); + const [authToken, setAuthToken] = useState(""); + const [testRecipient, setTestRecipient] = useState(""); + const [testMessage, setTestMessage] = useState("Test message from BrainDrive"); + const [isSaving, setIsSaving] = useState(false); + const [isSendingTest, setIsSendingTest] = useState(false); + const [saveError, setSaveError] = useState(null); + const [saveSuccess, setSaveSuccess] = useState(null); + const [testError, setTestError] = useState(null); + const [testSuccess, setTestSuccess] = useState(null); + const [copyStatus, setCopyStatus] = useState(null); + + useEffect(() => { + setEnabled(twilioSettings?.enabled ?? false); + setAccountSid(twilioSettings?.account_sid ?? ""); + setFromNumber(twilioSettings?.from_number ?? ""); + setPublicBaseUrl(twilioSettings?.public_base_url ?? ""); + setAutoReply(twilioSettings?.auto_reply ?? false); + setStrictOwnerMode(twilioSettings?.strict_owner_mode ?? false); + setOwnerPhoneNumber(twilioSettings?.owner_phone_number ?? ""); + setRateLimitPeriod(String(twilioSettings?.rate_limit_period ?? 60)); + setRateLimitCap(String(twilioSettings?.rate_limit_cap_round_trips ?? 5)); + setAuthToken(""); + setTestRecipient(twilioSettings?.test_recipient ?? ""); + }, [twilioSettings]); + + if (mode !== "local") { + return null; + } + + if (isLoadingSettings) { + return ( +
+

SMS (Twilio)

+

Loading Twilio SMS settings...

+
+ ); + } + + if (settingsError) { + return ( +
+

SMS (Twilio)

+
+ {settingsError} +
+
+ ); + } + + const lastInbound = twilioSettings?.last_inbound_at + ? new Date(twilioSettings.last_inbound_at).toLocaleString() + : "Never"; + const lastOutbound = twilioSettings?.last_outbound_at + ? new Date(twilioSettings.last_outbound_at).toLocaleString() + : "Never"; + const currentUsage = twilioSettings + ? `${twilioSettings.rate_limit_current_count}/${twilioSettings.rate_limit_cap_round_trips}` + : `0/${rateLimitCap}`; + const status = twilioSettings?.last_error + ? `Failed: ${twilioSettings.last_error}` + : twilioSettings?.last_result === "success" + ? "Success" + : twilioSettings?.last_result === "failed" + ? "Failed" + : "Not sent yet"; + const webhookUrl = twilioSettings?.webhook_url ?? ""; + + return ( +
+
+

SMS (Twilio)

+

+ Configure local-only Twilio SMS webhook and test send behavior. +

+
+ +
+ + + +
+ +
+
+ + { + setAccountSid(event.target.value); + setSaveError(null); + setSaveSuccess(null); + }} + placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + className="mt-1 h-10 w-full rounded-lg border border-bd-border bg-bd-bg-secondary px-3 text-sm text-bd-text-primary outline-none focus:border-bd-amber" + /> +
+
+ + { + setFromNumber(event.target.value); + setSaveError(null); + setSaveSuccess(null); + }} + placeholder="+14155552671" + className="mt-1 h-10 w-full rounded-lg border border-bd-border bg-bd-bg-secondary px-3 text-sm text-bd-text-primary outline-none focus:border-bd-amber" + /> +
+
+ +
+ + { + setPublicBaseUrl(event.target.value); + setSaveError(null); + setSaveSuccess(null); + }} + placeholder="https://example.com" + className="mt-1 h-10 w-full rounded-lg border border-bd-border bg-bd-bg-secondary px-3 text-sm text-bd-text-primary outline-none focus:border-bd-amber" + /> +
+ +
+ + +
+ + { + setOwnerPhoneNumber(event.target.value); + setSaveError(null); + setSaveSuccess(null); + }} + placeholder="+14155550000" + className="mt-1 h-10 w-full rounded-lg border border-bd-border bg-bd-bg-secondary px-3 text-sm text-bd-text-primary outline-none focus:border-bd-amber" + /> +
+
+ +
+
+ + { + setRateLimitPeriod(event.target.value); + setSaveError(null); + setSaveSuccess(null); + }} + className="mt-1 h-10 w-full rounded-lg border border-bd-border bg-bd-bg-secondary px-3 text-sm text-bd-text-primary outline-none focus:border-bd-amber" + /> +
+
+ + { + setRateLimitCap(event.target.value); + setSaveError(null); + setSaveSuccess(null); + }} + className="mt-1 h-10 w-full rounded-lg border border-bd-border bg-bd-bg-secondary px-3 text-sm text-bd-text-primary outline-none focus:border-bd-amber" + /> +
+
+ +
+ + {twilioSettings?.token_configured && ( +

+ Auth token is configured. Enter a value only to rotate it. +

+ )} + { + setAuthToken(event.target.value); + setSaveError(null); + setSaveSuccess(null); + }} + placeholder={twilioSettings?.token_configured ? "Leave blank to keep current token" : "Enter auth token"} + className="mt-1 h-10 w-full rounded-lg border border-bd-border bg-bd-bg-secondary px-3 text-sm text-bd-text-primary outline-none focus:border-bd-amber" + /> +
+ +
+
+ + { + setTestRecipient(event.target.value); + setTestError(null); + setTestSuccess(null); + }} + placeholder="+14155553333" + className="mt-1 h-10 w-full rounded-lg border border-bd-border bg-bd-bg-secondary px-3 text-sm text-bd-text-primary outline-none focus:border-bd-amber" + /> +
+
+ +
+ + +
+
+
+ +
+ +