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
59 changes: 52 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,12 @@ by ear. There is no scripted end-to-end call check.
Translation is a fixed rulebook (`src/matrix/twilio-voice.json`), not a guess.

- **Supported:** most core voice verbs — Say, Play, Gather, Pause, Hangup,
Redirect, Record, Number, Sip (translate cleanly to BXML). A few of these
still drop attributes safely rather than silently: `Say`/`Play`'s
`loop="0"` (infinite) can't be expressed inline in BXML, so it emits once and
warns; `Record`'s `transcribe` and `playBeep` attributes have no direct BXML
equivalent.
Redirect, Record, Number, Sip (each maps to a matching BXML verb). A few of
these still drop or adjust attributes safely rather than silently:
`Say`/`Play`'s `loop="0"` (infinite) can't be expressed inline in BXML, so it
emits once and warns; `Record`'s `transcribe`, `playBeep`, and `trim`
attributes have no direct BXML equivalent, and its `finishOnKey` can only
hold one character where Twilio allows a set (see below).
- **Lossy / partial (feature loss — needs a human decision):**
- `Reject` — maps to `Hangup`, but Bandwidth answers before hanging up, so the
caller may be billed for a short call.
Expand All @@ -122,10 +123,54 @@ Translation is a fixed rulebook (`src/matrix/twilio-voice.json`), not a guess.
TwiML document and re-translates the verbs after that Gather. Set
`actionOnEmptyResult="true"` to request the action with `Digits=""` instead.
On a translator instance that has not seen the document (restart, second
replica) the action is requested as a fallback.
replica) a Gather **with** an `action` falls back to requesting it anyway.
A Gather with **no** `action` attribute re-requests the current document,
as on Twilio — but the same no-document fallback can't do that for a
document it never cached, so it ends the call instead, rather than
re-fetching and replaying that same document forever. **In a multi-replica
deployment with no session affinity routing a call's `/bw/continue`
requests back to the replica that served its last document, this means a
silent caller on a bare Gather can have their call ended instead of
continuing to the next verb — a real behavior difference from Twilio,
worth fixing with a shared (not per-process) document cache if it matters
for your deployment.** `finishOnKey`'s
Twilio default (`"#"`) is emitted explicitly, since Bandwidth's own default
for this attribute is `""` (disabled) — a real difference. `timeout`
(default 5) sets both `firstDigitTimeout` and `interDigitTimeout`; the
*omitted* case is a no-op either way, since Bandwidth already defaults both
to 5s on its own, but an *explicit* `timeout` previously only reached
`firstDigitTimeout`, silently leaving `interDigitTimeout` at Bandwidth's
default regardless of what was set. `numDigits`, `timeout`, and `maxLength`
(on `Record`, below) are kept within the ranges Bandwidth documents, with a
warning naming the original value if it had to change (checked, and
warned about, once per Twilio attribute even where it maps to more than
one BXML attribute); a value that isn't a plain number at all falls back
to Twilio's own default instead of being passed through for Bandwidth to
reject, and is never emitted in scientific notation. `numDigits`
specifically is also rounded to a whole number, since there's no such
thing as collecting half a digit — `timeout`/`maxLength` keep a
fractional value as written, since a fractional duration is a real,
normal concept Bandwidth's own docs allow.
- `Record` — the `action` receives `RecordingUrl`, `RecordingSid`, and
`RecordingDuration` from Bandwidth's `recordComplete`; Bandwidth's event has
no equivalent of Twilio's `Digits` (the key that stopped the recording).
no equivalent of Twilio's `Digits` (the key that stopped the recording). A
Record with no `action` attribute re-requests the current document, as on
Twilio. `maxLength` (default 3600s) and `timeout` (Twilio: silence ends the
recording, default 5s, mapped to Bandwidth's `silenceTimeout`, which
previously had no BXML equivalent at all) are emitted explicitly rather
than left to Bandwidth's own (lower or disabled) defaults, and kept within
Bandwidth's documented ranges like Gather's `numDigits`/`timeout` above.
`finishOnKey` is narrowed to Bandwidth's single terminating digit
(preferring `#`, then `*`, then the first valid character present), since
Bandwidth cannot represent Twilio's default set of any key — a value made
of nothing but one valid character repeated (e.g. `"##"`) isn't actually
narrowed to anything different, so that doesn't warn. A value with no
digit, `*`, or `#` at all turns the stop key off (`terminatingDigits=""`)
rather than falling back to Bandwidth's own default (`"#"`), since that
would give the customer a stop key they never wrote into their TwiML.
`playBeep` and `trim` warn unless set to the one value (`"false"` /
`"do-not-trim"`) where Bandwidth's fixed behavior (no beep, never trims)
happens to match Twilio's.
- `Start` — the `Transcription` noun maps to `StartTranscription`; the
`Siprec` and `VirtualAgent` nouns are unsupported.
- `Refer` — Bandwidth only honors `Refer` on inbound SIP URI calls, so a PSTN
Expand Down
5 changes: 2 additions & 3 deletions src/compatibility-check/analyze.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { loadMatrix } from "../matrix/load.js";
import { translateTwiml, type Finding } from "../translator/translate.js";
import { translateTwiml, dedupeFindings, type Finding } from "../translator/translate.js";

const matrix = loadMatrix();

Expand Down Expand Up @@ -68,6 +68,5 @@ export function analyzeSource(file: string, source: string): FileAnalysis {
}
}

const dedup = new Map(findings.map((f) => [`${f.severity}:${f.verb}:${f.message}`, f]));
return { file, verbs: [...verbs], findings: [...dedup.values()], sdkDetected };
return { file, verbs: [...verbs], findings: dedupeFindings(findings), sdkDetected };
}
18 changes: 10 additions & 8 deletions src/matrix/twilio-voice.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@
"action": {
"bxml": "gatherUrl",
"status": "supported",
"notes": "No digits or speech does not request the action; the verbs after the Gather run instead (Twilio semantics). actionOnEmptyResult=\"true\" requests the action with Digits=\"\"."
"notes": "No digits or speech does not request the action; the verbs after the Gather run instead (Twilio semantics). actionOnEmptyResult=\"true\" requests the action with Digits=\"\". A Gather with no action attribute re-requests the current document, matching Twilio, when the server knows the document's URL (standalone BXML generation keeps the original warning instead)."
},
"numDigits": { "bxml": "maxDigits", "status": "supported" },
"timeout": { "bxml": "firstDigitTimeout", "status": "supported" },
"finishOnKey": { "bxml": "terminatingDigits", "status": "supported" },
"numDigits": { "bxml": "maxDigits", "status": "supported", "notes": "Kept within Bandwidth's 1-50 range, with a warning naming the original value if it had to change; left unset (Bandwidth's own default of 50) when omitted." },
"timeout": { "bxml": "firstDigitTimeout / interDigitTimeout", "status": "supported", "notes": "Twilio's single timeout (default 5s) sets both Bandwidth attributes, since Bandwidth splits the pause-before-first-digit and gap-between-digits behavior Twilio combines into one value. Each is kept within its own range (firstDigitTimeout 0-60, interDigitTimeout 1-60), with a warning naming the original value if it had to change." },
"finishOnKey": { "bxml": "terminatingDigits", "status": "supported", "notes": "Twilio's default (\"#\") is emitted explicitly when omitted or whitespace-only, since Bandwidth's own default for this attribute is \"\" (disabled). finishOnKey=\"\" passes through unchanged (both systems treat it as disabled). A character Bandwidth doesn't recognize as a key (only 0-9, *, and # work) is dropped from the set, with a warning." },
"input": { "bxml": "input", "status": "supported", "notes": "Twilio dtmf / speech / 'dtmf speech' map to BW input dtmf / speech / dtmf_speech. Speech results arrive as the BW gather event's text field, surfaced to the customer app as Twilio's SpeechResult param." }
}
},
Expand Down Expand Up @@ -79,13 +79,15 @@
"action": {
"bxml": "recordCompleteUrl",
"status": "supported",
"notes": "The callback carries RecordingUrl, RecordingSid, RecordingDuration. Bandwidth's event has no equivalent of Twilio's Digits."
"notes": "The callback carries RecordingUrl, RecordingSid, RecordingDuration. Bandwidth's event has no equivalent of Twilio's Digits. A Record with no action attribute re-requests the current document, matching Twilio, when the server knows the document's URL (standalone BXML generation keeps the original warning instead)."
},
"recordingStatusCallback": { "bxml": "recordingAvailableUrl", "status": "supported", "notes": "Async 'recording ready' webhook. Translator receives the BW recordingAvailableUrl event, reshapes it into Twilio recordingStatusCallback params, and forwards it; RecordingUrl points back at the translator's recording facade. recordingStatusCallbackMethod=GET is not honored (forwarded via POST)." },
"maxLength": { "bxml": "maxDuration", "status": "supported" },
"finishOnKey": { "bxml": "terminatingDigits", "status": "supported" },
"maxLength": { "bxml": "maxDuration", "status": "supported", "notes": "Twilio's default (3600s) is emitted explicitly when omitted, since Bandwidth's own default is 60s. Limited to Bandwidth's 10800s (3h) maximum, with a warning naming the original value if it was higher." },
"timeout": { "bxml": "silenceTimeout", "status": "supported", "notes": "Not previously mapped at all, so a recording never stopped on silence; now defaults to Twilio's 5 seconds when omitted and is limited to Bandwidth's 10800s maximum. timeout=\"0\" (Twilio: disable) and silenceTimeout=\"0\" (Bandwidth: no timeout) agree, so it passes through unchanged." },
"finishOnKey": { "bxml": "terminatingDigits", "status": "partial", "notes": "Bandwidth's Record takes one terminating digit (0-9, *, or #); Twilio's finishOnKey is a set (default \"1234567890*#\", any key). A single valid character passes through unchanged; a value made of several distinct valid characters, or any invalid ones mixed in (including the omitted or whitespace-only default), is narrowed to one digit, preferring '#' then '*' then the first valid character present, with a warning. A value with no valid character at all (e.g. a letter) turns the stop key off (terminatingDigits=\"\") rather than falling back to Bandwidth's own default (\"#\"), since that would give the customer a stop key they never wrote into their TwiML." },
"transcribe": { "bxml": "transcribe", "status": "partial", "notes": "Engine and callback shape differ." },
"playBeep": { "bxml": null, "status": "partial", "notes": "No direct equivalent; translator can prepend a beep PlayAudio later." }
"playBeep": { "bxml": null, "status": "partial", "notes": "No direct equivalent; translator can prepend a beep PlayAudio later. Twilio's default is true (plays a beep), so a warning is emitted unless playBeep is explicitly \"false\" (the one value Bandwidth's no-beep behavior matches)." },
"trim": { "bxml": null, "status": "partial", "notes": "No equivalent; Bandwidth never trims silence. Twilio's default is trim-silence, so a warning is emitted unless trim is explicitly \"do-not-trim\" (the one value Bandwidth's never-trims behavior matches)." }
}
},
"Dial": {
Expand Down
35 changes: 31 additions & 4 deletions src/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,13 @@ export function buildApp(config: ServerConfig, deps: ServerDeps): FastifyInstanc
// plus the flag that says the customer wants the action even then.
if (kind === "action" && ctx?.gatherIndex !== undefined) {
const onEmpty = ctx.actionOnEmptyResult ? "&onEmpty=1" : "";
return `${config.publicBaseUrl}/bw/continue?next=${encodeURIComponent(absolute)}&gather=${ctx.gatherIndex}${onEmpty}`;
// Marks a Gather with no action (this URL points back at the document
// that served it, not a real customer action) so /bw/continue can tell
// the two apart if it ever needs to resume this document and can't:
// falling back to "request the action anyway" is reasonable for a real
// action, but would just replay this same prompt forever for a self-reference.
const self = ctx.selfReference ? "&self=1" : "";
return `${config.publicBaseUrl}/bw/continue?next=${encodeURIComponent(absolute)}&gather=${ctx.gatherIndex}${onEmpty}${self}`;
}
// A Dial's action names the Dial (matching its legs' transferDisconnectUrl)
// and how many legs it rings, so /bw/continue joins only that Dial's legs.
Expand Down Expand Up @@ -331,6 +337,7 @@ export function buildApp(config: ServerConfig, deps: ServerDeps): FastifyInstanc
rewriteUrl: rewriter(documentUrl, doc),
callbackAuth: { username: config.webhookUser, password: config.webhookPassword },
transferLegUrl: (dialIndex) => `${config.publicBaseUrl}/bw/transfer-leg?dial=${doc}.${dialIndex}`,
documentUrl,
...extra,
});
if (result.hasErrors) {
Expand Down Expand Up @@ -451,7 +458,14 @@ export function buildApp(config: ServerConfig, deps: ServerDeps): FastifyInstanc
app.post("/bw/continue", async (req, reply) => {
const event = req.body as BwEvent;
if (!event || !isSafeBwId(event.callId)) return reply.code(400).send(serverErrors.invalidParam("callId"));
const query = req.query as { next?: string; gather?: string; onEmpty?: string; dial?: string; legs?: string };
const query = req.query as {
next?: string;
gather?: string;
onEmpty?: string;
dial?: string;
legs?: string;
self?: string;
};
if (!query.next) return reply.code(400).send(serverErrors.missingParam("next"));
const record =
store.get(event.callId) ??
Expand All @@ -477,8 +491,21 @@ export function buildApp(config: ServerConfig, deps: ServerDeps): FastifyInstanc
resumeAfterGather: gatherIndex,
});
}
// No document to resume (fresh instance, or an old-style URL): the only
// way to keep the call alive is to ask the action anyway.
// No document to resume (fresh instance, second replica, or an
// old-style URL). For a real customer action, asking it anyway is a
// reasonable fallback. For a Gather with no action -- this URL
// points back at the document that served it, not a real action --
// asking "the action" would re-fetch and replay that exact same
// document, and since nothing about the situation changes, it would
// keep replaying forever. End the call instead, the way Twilio ends
// a call that runs out of TwiML to execute.
if (query.self === "1") {
app.log.warn(
{ callId: event.callId },
"empty gather on a Gather with no action, with no document to resume; ending the call rather than replaying it",
);
return reply.type("application/xml").send(bxmlDocument([]));
}
app.log.warn({ callId: event.callId }, "empty gather with no document to resume; requesting action");
}
params = gatherParams(record, config.accountSid, {
Expand Down
Loading
Loading