Skip to content
Open
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
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,11 @@ Translation is a fixed rulebook (`src/matrix/twilio-voice.json`), not a guess.
TwiML omits one. `ConversationRelay` and `VirtualAgent` are unsupported
(separate IoV).
- `Stream` — Twilio's WS message schema is emulated by the translator's stream
bridge; live Bandwidth-side binding requires fixture capture.
bridge; live Bandwidth-side binding requires fixture capture. `<Parameter>`
children map to nested `<StreamParam/>` elements in order (Bandwidth allows
at most 12; extras are dropped with a warning). Bandwidth echoes them in its
`start` event as `streamParams`, and the bridge forwards them to the bot as
Twilio `customParameters`.
- `Conference` — basic named conferences work, but `waitUrl` hold music has
no Bandwidth equivalent, `beep` is only partially supported, and
`startConferenceOnEnter`/`endConferenceOnExit`/`maxParticipants` have no
Expand Down
2 changes: 1 addition & 1 deletion src/matrix/twilio-voice.json
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@
"Stream": {
"bxml": "StartStream",
"status": "partial",
"notes": "Twilio WS message schema is emulated by the translator's stream bridge; live Bandwidth-side binding requires fixture capture.",
"notes": "Twilio WS message schema is emulated by the translator's stream bridge; live Bandwidth-side binding requires fixture capture. <Parameter> children become nested <StreamParam/> elements (Bandwidth allows at most 12) and reach the bot as customParameters.",
"docsUrl": "https://dev.bandwidth.com/docs/voice/bxml/startStream",
"attributes": {}
},
Expand Down
26 changes: 25 additions & 1 deletion src/streams/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,35 @@ export interface BridgeOpts {
botUrl: string;
callSid: string;
accountSid: string;
/** Optional key/value pairs forwarded verbatim in the TwiML <Stream> start message. */
/** Key/value pairs forwarded verbatim as `customParameters` in the Twilio
* "start" message. These are the TwiML <Stream><Parameter> values, which the
* translator emits as <StreamParam/> and Bandwidth echoes back in its own
* "start" event as `streamParams`; build them with customParametersFromBwStart. */
customParameters?: Record<string, string>;
source: BwStreamSource;
}

/**
* Map Bandwidth's StartStream WebSocket "start" event to Twilio `customParameters`.
*
* Bandwidth copies every <StreamParam name value/> under the <StartStream> into
* the start event as `streamParams: { name: value, ... }` (a flat map, per the
* StartStream docs). Twilio delivers the same data as `start.customParameters`,
* also a flat string map, so the mapping is a copy with values coerced to
* strings. Anything that is not a plain object yields an empty map; a bot
* always receives a `customParameters` object, never undefined.
*/
export function customParametersFromBwStart(event: unknown): Record<string, string> {
const params = (event as { streamParams?: unknown } | null)?.streamParams;
if (params === null || typeof params !== "object" || Array.isArray(params)) return {};
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(params as Record<string, unknown>)) {
if (v === undefined || v === null) continue;
out[k] = typeof v === "string" ? v : String(v);
}
return out;
}

export class TwilioStreamBridge {
readonly streamSid: string;
private ws: WebSocket;
Expand Down
50 changes: 48 additions & 2 deletions src/translator/translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,8 +543,53 @@ const TWILIO_STREAM_TRACK_TO_BW: Record<string, string> = {
both_tracks: "both",
};

// Bandwidth's documented ceiling on <StreamParam/> children per <StartStream>.
// Twilio sets no count limit on <Parameter>, so anything past this is dropped
// with a warning rather than emitting BXML Bandwidth would reject outright.
const MAX_STREAM_PARAMS = 12;

/** Twilio <Stream><Parameter name value/> children → BW <StreamParam name value/>.
* Bandwidth copies these into the WebSocket "start" event as a `streamParams`
* map, which the stream bridge forwards to the bot as Twilio `customParameters`
* (see customParametersFromBwStart in streams/bridge.ts). Order is preserved.
* Twilio caps name+value at 500 chars combined, so Bandwidth's per-attribute
* limits (256 / 2048) cannot be exceeded by valid TwiML and are not re-checked. */
function streamParams(stream: TwimlNode, findings: Finding[]): XmlEl[] {
const out: XmlEl[] = [];
let dropped = 0;
for (const child of stream.children) {
if (child.name !== "Parameter") {
warn("Stream", `Stream child <${child.name}> has no Bandwidth equivalent and was dropped.`, findings);
continue;
}
const { name, value } = child.attrs;
if (name === undefined || value === undefined) {
warn(
"Stream",
`Stream <Parameter> requires both name and value; dropped <Parameter name="${name ?? ""}">.`,
findings,
);
continue;
}
if (out.length >= MAX_STREAM_PARAMS) {
dropped++;
continue;
}
out.push({ name: "StreamParam", attrs: { name, value } });
}
if (dropped > 0)
warn(
"Stream",
`Bandwidth allows at most ${MAX_STREAM_PARAMS} StreamParam per StartStream; ` +
`${dropped} Stream <Parameter> element(s) beyond that were dropped.`,
findings,
);
return out;
}

/** Twilio <Stream> noun → BW <StartStream>. mode is bidirectional under
* <Connect> (audio flows both ways) and unidirectional under <Start> (a fork). */
* <Connect> (audio flows both ways) and unidirectional under <Start> (a fork).
* <Parameter> children become nested <StreamParam/> elements. */
function streamToStartStream(
stream: TwimlNode,
mode: "bidirectional" | "unidirectional",
Expand All @@ -559,7 +604,8 @@ function streamToStartStream(
mode,
tracks: stream.attrs.track ? TWILIO_STREAM_TRACK_TO_BW[stream.attrs.track] ?? "inbound" : "inbound",
};
return [{ name: "StartStream", attrs }];
const params = streamParams(stream, findings);
return [params.length ? { name: "StartStream", attrs, children: params } : { name: "StartStream", attrs }];
}

// Per-document counter for generated Connect/Stream names. Twilio's <Stream name>
Expand Down
44 changes: 43 additions & 1 deletion test/streams-wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
import { describe, it, expect } from "vitest";
import { WebSocketServer, WebSocket } from "ws";
import { EventEmitter } from "node:events";
import { TwilioStreamBridge, type BwStreamSource } from "../src/streams/bridge.js";
import {
TwilioStreamBridge,
customParametersFromBwStart,
type BwStreamSource,
} from "../src/streams/bridge.js";

// ─── helpers ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -107,6 +111,44 @@ describe("start message", () => {
});
});

// VAPI-3989: Bandwidth echoes <StreamParam/> values in its "start" event as a
// flat `streamParams` map; the bot must see them as Twilio customParameters.
it("forwards Bandwidth streamParams to the bot as customParameters", async () => {
// Shape per the StartStream docs' start-event example.
const bwStart = {
eventType: "start",
metadata: { accountId: "9900778", callId: "c-abc", to: "+15550001111", from: "+15550002222" },
streamParams: { callSid: "CA123", tenant: "acme" },
};
const port = nextPort();
const { messages, close } = await botServer(port);
const source = new FakeBwSource();
const bridge = new TwilioStreamBridge({
botUrl: `ws://127.0.0.1:${port}`,
callSid: "CA123",
accountSid: "AC222",
customParameters: customParametersFromBwStart(bwStart),
source,
});
await bridge.ready();
await waitFor(() => messages.length >= 2);
bridge.close();
close();

const startMsg = messages.find((m: any) => m.event === "start") as any;
expect(startMsg.start.customParameters).toEqual({ callSid: "CA123", tenant: "acme" });
});

it("customParametersFromBwStart tolerates missing or malformed streamParams", () => {
expect(customParametersFromBwStart({ eventType: "start" })).toEqual({});
expect(customParametersFromBwStart({ streamParams: null })).toEqual({});
expect(customParametersFromBwStart({ streamParams: [1, 2] })).toEqual({});
expect(customParametersFromBwStart(undefined)).toEqual({});
expect(customParametersFromBwStart("start")).toEqual({});
// Values are always strings on the Twilio side, even if Bandwidth ever sent a number.
expect(customParametersFromBwStart({ streamParams: { n: 42, s: "x", nil: null } })).toEqual({ n: "42", s: "x" });
});

it("customParameters defaults to empty object when omitted", async () => {
const port = nextPort();
const { messages, close } = await botServer(port);
Expand Down
80 changes: 80 additions & 0 deletions test/translate-stream-conference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,86 @@ describe("Stream lifecycle", () => {
expect(r.bxml).not.toMatch(/<StartStream [^>]*\/><\/Response>/);
});

// VAPI-3989: <Parameter> children used to be dropped silently, so bots got an
// empty customParameters map and could not identify the call or tenant.
describe("Stream <Parameter> → StreamParam (VAPI-3989)", () => {
it("emits one nested StreamParam per Parameter, in order, inside StartStream", () => {
const r = translateTwiml(
`<Response><Connect><Stream name="agent" url="wss://bot.test/ws">
<Parameter name="callSid" value="CA123"/>
<Parameter name="tenant" value="acme"/>
</Stream></Connect></Response>`,
{ rewriteUrl: rw },
);
expect(r.hasErrors).toBe(false);
expect(r.bxml).toMatch(
/<StartStream [^>]*name="agent"[^>]*><StreamParam name="callSid" value="CA123"\/><StreamParam name="tenant" value="acme"\/><\/StartStream><StopStream name="agent" wait="true"\/>/,
);
// No drop warnings when every Parameter is valid and within the limit.
expect(r.findings.some((f) => /dropped/.test(f.message))).toBe(false);
});

it("output differs from the same Stream without Parameters", () => {
const a = translateTwiml(`<Response><Connect><Stream url="wss://bot.test/ws"/></Connect></Response>`);
const b = translateTwiml(
`<Response><Connect><Stream url="wss://bot.test/ws"><Parameter name="k" value="v"/></Stream></Connect></Response>`,
);
expect(b.bxml).not.toBe(a.bxml);
expect(a.bxml).not.toContain("StreamParam");
});

it("also applies to Start>Stream forks", () => {
const r = translateTwiml(
`<Response><Start><Stream name="fork1" url="wss://bot.test/ws"><Parameter name="k" value="v"/></Stream></Start></Response>`,
);
expect(r.hasErrors).toBe(false);
expect(r.bxml).toContain(`<StreamParam name="k" value="v"/></StartStream>`);
expect(r.bxml).not.toContain("<StopStream");
});

it("XML-escapes parameter values", () => {
const r = translateTwiml(
`<Response><Connect><Stream url="wss://bot.test/ws"><Parameter name="q" value="a &amp; b &lt; &quot;c&quot;"/></Stream></Connect></Response>`,
);
expect(r.bxml).toContain(`<StreamParam name="q" value="a &amp; b &lt; &quot;c&quot;"/>`);
});

it("keeps the first 12 Parameters and warns about the rest (Bandwidth limit)", () => {
const params = Array.from({ length: 14 }, (_, i) => `<Parameter name="p${i}" value="v${i}"/>`).join("");
const r = translateTwiml(
`<Response><Connect><Stream url="wss://bot.test/ws">${params}</Stream></Connect></Response>`,
);
expect(r.hasErrors).toBe(false);
expect(r.bxml.match(/<StreamParam /g)).toHaveLength(12);
expect(r.bxml).toContain(`name="p11"`);
expect(r.bxml).not.toContain(`name="p12"`);
expect(r.findings.some((f) => f.verb === "Stream" && /at most 12/.test(f.message) && /2 /.test(f.message))).toBe(true);
});

it("drops a Parameter missing name or value with a warning instead of emitting invalid BXML", () => {
const r = translateTwiml(
`<Response><Connect><Stream url="wss://bot.test/ws">
<Parameter name="ok" value="1"/>
<Parameter name="novalue"/>
<Parameter value="noname"/>
</Stream></Connect></Response>`,
);
expect(r.hasErrors).toBe(false);
expect(r.bxml.match(/<StreamParam /g)).toHaveLength(1);
expect(r.bxml).toContain(`<StreamParam name="ok" value="1"/>`);
expect(r.findings.filter((f) => f.verb === "Stream" && /requires both name and value/.test(f.message))).toHaveLength(2);
});

it("warns about non-Parameter children of Stream", () => {
const r = translateTwiml(
`<Response><Connect><Stream url="wss://bot.test/ws"><Bogus/></Stream></Connect></Response>`,
);
expect(r.hasErrors).toBe(false);
expect(r.bxml).not.toContain("Bogus");
expect(r.findings.some((f) => f.verb === "Stream" && /<Bogus>/.test(f.message))).toBe(true);
});
});

it("Start>Stream → StartStream mode=unidirectional (fork)", () => {
const r = translateTwiml(
`<Response><Start><Stream name="fork1" url="wss://bot.test/ws"/></Start></Response>`,
Expand Down
Loading