Skip to content
Merged
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
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,12 @@ Translation is a fixed rulebook (`src/matrix/twilio-voice.json`), not a guess.
(`Enqueue`/`Leave`/`Queue` fail loudly), no WebRTC client endpoint
(`Client`), and PCI payment capture (`Pay`) is out of scope for the adapter.
- **Dynamic SDK-built TwiML:** if the customer app generates TwiML at runtime,
capture live responses and re-run `generate`, or port the logic by hand.
there is no static markup to transpile. Run the adapter with
`ADAPTER_CAPTURE_DIR=<dir>` and place a few test calls; each customer TwiML
response is written there verbatim (content-addressed, deduped). Then
`npm run generate -- <dir> <out>` produces standalone BXML for the paths those
calls exercised. Capture only covers exercised paths — branches you never dial
won't appear, so drive every flow you care about (or port the rest by hand).

## Errors

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ npm run doctor # readiness check — see AGENTS.md Phase 5
| `CUSTOMER_VOICE_URL` | The customer's Twilio voice webhook (inbound calls) |
| `BW_ACCOUNT_ID` / `BW_CLIENT_ID` / `BW_CLIENT_SECRET` / `BW_APPLICATION_ID` | Bandwidth credentials (OAuth2 client-credentials), provisioned via `band` — see [`AGENTS.md`](AGENTS.md) |
| `BW_ENVIRONMENT` | Optional — `test` targets BW's test hosts; defaults to `prod` |
| `ADAPTER_CAPTURE_DIR` | Optional — dir to persist each customer TwiML response (verbatim, content-addressed) so `generate` can turn the paths a test call exercised into standalone BXML. Essential for SDK-built apps with no static TwiML to transpile. Off by default |
| `ADAPTER_LOG=1` | Optional — enable request logging |

Run `npm run doctor` (or `GET /readyz?deep=1` once the server is up) to confirm
Expand Down
13 changes: 13 additions & 0 deletions src/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { CallStore, type CallRecord } from "./call-store.js";
import { isSafeBwId, type BwClient } from "../bw/client.js";
import { checkReadiness } from "./readiness.js";
import { safeEqual } from "./safe-equal.js";
import { captureTwiml } from "./capture.js";

export interface AdapterConfig {
accountSid: string;
Expand All @@ -36,6 +37,8 @@ export interface AdapterConfig {
/** Basic-auth credentials Bandwidth presents on inbound webhooks (must match the app's CallbackCreds). */
webhookUser: string;
webhookPassword: string;
/** Opt-in dir to persist each customer TwiML response for later `generate`. Undefined → no capture. */
captureDir?: string;
}

export interface AdapterDeps {
Expand Down Expand Up @@ -155,6 +158,16 @@ export function buildApp(config: AdapterConfig, deps: AdapterDeps): FastifyInsta
}
throw err;
}
// Capture the raw customer TwiML (URLs verbatim, pre-rewrite) before we
// translate it — this is exactly what `generate` ingests to produce
// standalone BXML for the paths a test call exercised.
if (config.captureDir) {
try {
captureTwiml(config.captureDir, twiml);
} catch (err) {
app.log.error({ err }, "twiml capture failed");
}
}
const translateStart = performance.now();
const result = translateTwiml(twiml, {
rewriteUrl: rewriter(customerUrl),
Expand Down
30 changes: 30 additions & 0 deletions src/server/capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";

/**
* Persist a raw TwiML response the adapter fetched from the customer app, so a
* later `npm run generate` over the capture dir can turn the paths a test call
* actually exercised into standalone BXML.
*
* The filename is a content hash — never request input — so identical responses
* dedupe to one file and untrusted input never participates in filesystem
* addressing (same guardrail as scripts/capture-server.mjs). We store the raw
* TwiML verbatim (customer URLs intact, no proxy rewrite), which is exactly what
* `generate` ingests.
*
* Written private-by-default (dir 0700, file 0600): raw TwiML can carry callback
* URLs with embedded tokens, so captures are not world-readable. Synchronous I/O
* is deliberate — this is an opt-in, test-call-volume eval feature on local disk,
* and serial writes keep the dedup check (existsSync → writeFileSync) race-free
* within the process; we accept the event-loop cost over async write races.
*
* Returns the path written, or the existing path if this TwiML was already seen.
*/
export function captureTwiml(dir: string, twiml: string): string {
mkdirSync(dir, { recursive: true, mode: 0o700 });
const hash = createHash("sha256").update(twiml).digest("hex").slice(0, 16);
const file = join(dir, `${hash}.xml`);
if (!existsSync(file)) writeFileSync(file, twiml, { mode: 0o600 });
return file;
}
1 change: 1 addition & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const app = buildApp(
webhookPassword: env("WEBHOOK_PASSWORD"),
allowPrivateEgress: process.env.EGRESS_ALLOW_PRIVATE === "1",
egressAllowHosts: process.env.EGRESS_ALLOW_HOSTS?.split(",").map((s) => s.trim()).filter(Boolean),
captureDir: process.env.ADAPTER_CAPTURE_DIR,
},
{
fetchImpl: fetch,
Expand Down
53 changes: 53 additions & 0 deletions test/capture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, readdirSync, readFileSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { captureTwiml } from "../src/server/capture.js";

const TWIML = `<Response><Say>Hello</Say><Hangup/></Response>`;

let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "capture-test-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});

describe("captureTwiml", () => {
it("writes the raw TwiML verbatim (no added bytes) to a file under the capture dir", () => {
const file = captureTwiml(dir, TWIML);
expect(readFileSync(file, "utf8")).toBe(TWIML);
});

it("writes captures private (0600 file, 0700 dir)", () => {
const file = captureTwiml(dir, TWIML);
expect(statSync(file).mode & 0o777).toBe(0o600);
expect(statSync(dir).mode & 0o777).toBe(0o700);
});

it("names the file from the content hash only, never request input", () => {
const file = captureTwiml(dir, TWIML);
// A content-addressed hex name — no path, params, or caller-supplied string.
expect(file.split("/").pop()).toMatch(/^[0-9a-f]{16}\.xml$/);
});

it("dedupes identical TwiML to a single file", () => {
captureTwiml(dir, TWIML);
captureTwiml(dir, TWIML);
captureTwiml(dir, TWIML);
expect(readdirSync(dir)).toHaveLength(1);
});

it("writes distinct files for distinct TwiML", () => {
captureTwiml(dir, TWIML);
captureTwiml(dir, `<Response><Say>Goodbye</Say></Response>`);
expect(readdirSync(dir)).toHaveLength(2);
});

it("creates the capture dir if it does not exist", () => {
const nested = join(dir, "does", "not", "exist");
const file = captureTwiml(nested, TWIML);
expect(readFileSync(file, "utf8")).toContain(TWIML);
});
});
91 changes: 91 additions & 0 deletions test/server-capture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildApp } from "../src/server/app.js";

const webhookAuth = "Basic " + Buffer.from("u:p").toString("base64");

function baseConfig(captureDir?: string) {
return {
accountSid: "AC123",
authToken: "tok",
publicBaseUrl: "https://adapter.test",
voiceUrl: "https://customer.test/voice",
allowPrivateEgress: true,
webhookUser: "u",
webhookPassword: "p",
captureDir,
};
}

function appWithTwiml(config: ReturnType<typeof baseConfig>, twimlByUrl: Record<string, string>) {
const fetchImpl = vi.fn(async (url: any) => {
const twiml = twimlByUrl[String(url)];
if (!twiml) return new Response("not found", { status: 404 });
return new Response(twiml, { status: 200 });
}) as unknown as typeof fetch;
const bwClient = { createCall: vi.fn(), modifyCall: vi.fn(), getCall: vi.fn(), listRecordings: vi.fn(), getRecording: vi.fn(), getRecordingMedia: vi.fn(), updateRecording: vi.fn() };
return buildApp(config, { fetchImpl, bwClient });
}

async function initiate(app: ReturnType<typeof appWithTwiml>, callId: string) {
return app.inject({
method: "POST",
url: "/bw/initiate",
headers: { authorization: webhookAuth },
payload: { eventType: "initiate", callId, from: "+1", to: "+2", direction: "inbound" },
});
}

let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "server-capture-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});

describe("live capture", () => {
it("writes the customer's raw TwiML (not the translated BXML) when captureDir is set", async () => {
const raw = `<Response><Say>Hello</Say><Hangup/></Response>`;
const app = appWithTwiml(baseConfig(dir), { "https://customer.test/voice": raw });
await initiate(app, "c-1");

const files = readdirSync(dir);
expect(files).toHaveLength(1);
const content = readFileSync(join(dir, files[0]), "utf8");
expect(content).toContain(raw);
// The captured artifact is the source TwiML, not the proxy's BXML output.
expect(content).not.toContain("SpeakSentence");
});

it("preserves the customer's URLs verbatim (no proxy rewrite) so generate can reuse them", async () => {
const raw = `<Response><Gather numDigits="1" action="/menu"><Say>Press 1</Say></Gather></Response>`;
const app = appWithTwiml(baseConfig(dir), { "https://customer.test/voice": raw });
await initiate(app, "c-2");

const content = readFileSync(join(dir, readdirSync(dir)[0]), "utf8");
expect(content).toContain(`action="/menu"`);
expect(content).not.toContain("/bw/continue");
});

it("still serves the call when capture fails (unwritable target)", async () => {
// Parent path is a file, so mkdir under it throws — capture must not fail the call.
writeFileSync(join(dir, "blocker"), "x");
const app = appWithTwiml(baseConfig(join(dir, "blocker", "sub")), {
"https://customer.test/voice": `<Response><Say>Hello</Say><Hangup/></Response>`,
});
const res = await initiate(app, "c-4");
expect(res.statusCode).toBe(200);
expect(res.body).toContain("<SpeakSentence>Hello</SpeakSentence>");
});

it("writes nothing when captureDir is unset", async () => {
const app = appWithTwiml(baseConfig(undefined), {
"https://customer.test/voice": `<Response><Say>Hello</Say></Response>`,
});
await initiate(app, "c-3");
expect(readdirSync(dir)).toHaveLength(0);
});
});