@kuralle-syrinx/server-websocket 4.2.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -5
- package/src/carrier-commands.ts +332 -0
- package/src/edge-telnyx.ts +389 -0
- package/src/edge.ts +23 -2
- package/src/index.ts +25 -2
- package/src/telnyx-codec.ts +163 -0
- package/src/telnyx.ts +54 -59
- package/src/turn-metrics.ts +107 -5
- package/src/twilio.ts +23 -0
- package/src/wire-carrier-control.ts +179 -0
- package/src/admission-control.test.ts +0 -270
- package/src/background-audio.test.ts +0 -224
- package/src/browser-opus.test.ts +0 -41
- package/src/browser-pacing.test.ts +0 -440
- package/src/edge-twilio.test.ts +0 -293
- package/src/edge.test.ts +0 -591
- package/src/graceful-drain.test.ts +0 -306
- package/src/inbound-audio.test.ts +0 -58
- package/src/index.test.ts +0 -2074
- package/src/outbound-playout-pipeline.test.ts +0 -314
- package/src/paced-playout.test.ts +0 -247
- package/src/playout-progress.test.ts +0 -56
- package/src/session-store.test.ts +0 -274
- package/src/smartpbx.test.ts +0 -967
- package/src/telnyx.test.ts +0 -1457
- package/src/transport-host.test.ts +0 -51
- package/src/turn-metrics.test.ts +0 -327
- package/src/twilio-auth.test.ts +0 -36
- package/src/twilio.test.ts +0 -1275
- package/src/websocket-close.test.ts +0 -63
- package/src/websocket-lifecycle.test.ts +0 -49
- package/tsconfig.json +0 -22
- package/vitest.config.ts +0 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/server-websocket",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Node WebSocket voice host for Syrinx — browser transport plus Twilio/Telnyx/SmartPBX telephony adapters, background audio, admission control",
|
|
6
6
|
"keywords": [
|
|
@@ -29,19 +29,20 @@
|
|
|
29
29
|
".": "./src/index.ts",
|
|
30
30
|
"./edge": "./src/edge.ts",
|
|
31
31
|
"./edge-twilio": "./src/edge-twilio.ts",
|
|
32
|
+
"./edge-telnyx": "./src/edge-telnyx.ts",
|
|
32
33
|
"./session-store": "./src/session-store.ts"
|
|
33
34
|
},
|
|
34
35
|
"dependencies": {
|
|
35
36
|
"@evan/opus": "1.0.3",
|
|
36
|
-
"ws": "^8.
|
|
37
|
-
"@kuralle-syrinx/core": "4.
|
|
38
|
-
"@kuralle-syrinx/ws": "4.
|
|
37
|
+
"ws": "^8.21.0",
|
|
38
|
+
"@kuralle-syrinx/core": "4.4.0",
|
|
39
|
+
"@kuralle-syrinx/ws": "4.4.0"
|
|
39
40
|
},
|
|
40
41
|
"devDependencies": {
|
|
41
42
|
"@types/node": "^22.0.0",
|
|
42
43
|
"@types/ws": "^8.5.0",
|
|
43
44
|
"typescript": "^5.7.0",
|
|
44
|
-
"vitest": "^2.
|
|
45
|
+
"vitest": "^3.2.6"
|
|
45
46
|
},
|
|
46
47
|
"scripts": {
|
|
47
48
|
"typecheck": "tsc --noEmit",
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Pure carrier out-of-band command constructors + injectable fetch dispatchers.
|
|
4
|
+
// Workers-safe: TypedArray-free pure objects + global `fetch()` only.
|
|
5
|
+
// No Node APIs, no process.env — credentials and call handles are injected.
|
|
6
|
+
//
|
|
7
|
+
// HONESTY: payload shapes unit-tested. Live HTTP dispatch to Twilio/Telnyx is
|
|
8
|
+
// unverified against a live carrier (no credentials in this build). Real IVR
|
|
9
|
+
// DTMF decode, trunk G.722 negotiation, and live transfer bridge remain
|
|
10
|
+
// carrier-gated.
|
|
11
|
+
|
|
12
|
+
import type { CallTransferPacket, DtmfSendPacket } from "@kuralle-syrinx/core";
|
|
13
|
+
|
|
14
|
+
// ── DTMF pause mapping ──────────────────────────────────────────────────────
|
|
15
|
+
// Syrinx uses w=0.5s / W=1s (common telephony convention).
|
|
16
|
+
// Twilio <Play digits> / sendDigits: w=0.5s, W=1s (same).
|
|
17
|
+
// Telnyx send_dtmf: duration_millis per tone; pauses are separate `w`/`W` chars
|
|
18
|
+
// in the digits string (Call Control accepts the same pause letters).
|
|
19
|
+
|
|
20
|
+
export interface TwilioSendDigitsCommand {
|
|
21
|
+
readonly carrier: "twilio";
|
|
22
|
+
readonly kind: "send_digits";
|
|
23
|
+
/** REST Calls API path segment after /2010-04-01/Accounts/{AccountSid} */
|
|
24
|
+
readonly path: string;
|
|
25
|
+
readonly method: "POST";
|
|
26
|
+
readonly form: {
|
|
27
|
+
readonly SendDigits: string;
|
|
28
|
+
};
|
|
29
|
+
/** Equivalent TwiML fragment for a voice response webhook. */
|
|
30
|
+
readonly twiml: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TelnyxSendDtmfCommand {
|
|
34
|
+
readonly carrier: "telnyx";
|
|
35
|
+
readonly kind: "send_dtmf";
|
|
36
|
+
readonly path: string;
|
|
37
|
+
readonly method: "POST";
|
|
38
|
+
readonly json: {
|
|
39
|
+
readonly digits: string;
|
|
40
|
+
readonly duration_millis?: number;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type SendDtmfCommand = TwilioSendDigitsCommand | TelnyxSendDtmfCommand;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Twilio DTMF via Calls API `SendDigits` / TwiML `<Play digits="...">`.
|
|
48
|
+
* Pause letters `w`/`W` pass through unchanged (Twilio's native pause syntax).
|
|
49
|
+
* Live dispatch unverified.
|
|
50
|
+
*/
|
|
51
|
+
export function buildTwilioSendDigits(
|
|
52
|
+
callSid: string,
|
|
53
|
+
packet: Pick<DtmfSendPacket, "digits">,
|
|
54
|
+
): TwilioSendDigitsCommand {
|
|
55
|
+
const digits = packet.digits;
|
|
56
|
+
return {
|
|
57
|
+
carrier: "twilio",
|
|
58
|
+
kind: "send_digits",
|
|
59
|
+
path: `/Calls/${encodeURIComponent(callSid)}.json`,
|
|
60
|
+
method: "POST",
|
|
61
|
+
form: { SendDigits: digits },
|
|
62
|
+
twiml: `<Response><Play digits="${escapeXml(digits)}"/></Response>`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Telnyx Call Control `send_dtmf`. Pause letters retained in the digits string.
|
|
68
|
+
* Live dispatch unverified.
|
|
69
|
+
*/
|
|
70
|
+
export function buildTelnyxSendDtmf(
|
|
71
|
+
callControlId: string,
|
|
72
|
+
packet: Pick<DtmfSendPacket, "digits">,
|
|
73
|
+
opts?: { readonly durationMillis?: number },
|
|
74
|
+
): TelnyxSendDtmfCommand {
|
|
75
|
+
return {
|
|
76
|
+
carrier: "telnyx",
|
|
77
|
+
kind: "send_dtmf",
|
|
78
|
+
path: `/v2/calls/${encodeURIComponent(callControlId)}/actions/send_dtmf`,
|
|
79
|
+
method: "POST",
|
|
80
|
+
json: {
|
|
81
|
+
digits: packet.digits,
|
|
82
|
+
...(opts?.durationMillis !== undefined ? { duration_millis: opts.durationMillis } : {}),
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Transfer ────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
export interface TwilioTransferCommand {
|
|
90
|
+
readonly carrier: "twilio";
|
|
91
|
+
readonly kind: "transfer";
|
|
92
|
+
readonly mode: CallTransferPacket["mode"];
|
|
93
|
+
readonly path: string;
|
|
94
|
+
readonly method: "POST";
|
|
95
|
+
/**
|
|
96
|
+
* Calls API redirect: set Url to a TwiML Bin / webhook that dials the target.
|
|
97
|
+
* For cold/warm we emit the Dial TwiML the webhook would return.
|
|
98
|
+
*/
|
|
99
|
+
readonly form: {
|
|
100
|
+
readonly Url?: string;
|
|
101
|
+
readonly Method?: "POST" | "GET";
|
|
102
|
+
readonly Twiml?: string;
|
|
103
|
+
};
|
|
104
|
+
readonly twiml: string;
|
|
105
|
+
/** Warm-handoff summary when mode is warm (not on the wire — for the receiving app). */
|
|
106
|
+
readonly summary?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface TelnyxTransferCommand {
|
|
110
|
+
readonly carrier: "telnyx";
|
|
111
|
+
readonly kind: "transfer";
|
|
112
|
+
readonly mode: CallTransferPacket["mode"];
|
|
113
|
+
readonly path: string;
|
|
114
|
+
readonly method: "POST";
|
|
115
|
+
readonly json: {
|
|
116
|
+
readonly to: string;
|
|
117
|
+
/** Prefer Call-Control transfer over SIP REFER (attestation B penalty). */
|
|
118
|
+
readonly command_id?: string;
|
|
119
|
+
readonly client_state?: string;
|
|
120
|
+
readonly webhook_url?: string;
|
|
121
|
+
};
|
|
122
|
+
readonly summary?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type TransferCommand = TwilioTransferCommand | TelnyxTransferCommand;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Twilio transfer via Calls API Twiml/Url redirect.
|
|
129
|
+
* Prefer `<Dial>` (Call-Control style) over SIP REFER — REFER drops STIR/SHAKEN
|
|
130
|
+
* attestation to B and hurts answer rate.
|
|
131
|
+
* Live dispatch unverified.
|
|
132
|
+
*/
|
|
133
|
+
export function buildTwilioTransfer(
|
|
134
|
+
callSid: string,
|
|
135
|
+
packet: Pick<CallTransferPacket, "mode" | "target" | "summary">,
|
|
136
|
+
opts?: { readonly redirectUrl?: string },
|
|
137
|
+
): TwilioTransferCommand {
|
|
138
|
+
const twiml = twilioTransferTwiml(packet);
|
|
139
|
+
return {
|
|
140
|
+
carrier: "twilio",
|
|
141
|
+
kind: "transfer",
|
|
142
|
+
mode: packet.mode,
|
|
143
|
+
path: `/Calls/${encodeURIComponent(callSid)}.json`,
|
|
144
|
+
method: "POST",
|
|
145
|
+
form: opts?.redirectUrl
|
|
146
|
+
? { Url: opts.redirectUrl, Method: "POST" }
|
|
147
|
+
: { Twiml: twiml },
|
|
148
|
+
twiml,
|
|
149
|
+
...(packet.summary !== undefined ? { summary: packet.summary } : {}),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Telnyx Call Control `transfer`. Prefer this over SIP REFER (mode sip_refer still
|
|
155
|
+
* uses the transfer action with the SIP URI as `to` — Call Control, not raw REFER).
|
|
156
|
+
* Live dispatch unverified.
|
|
157
|
+
*/
|
|
158
|
+
export function buildTelnyxTransfer(
|
|
159
|
+
callControlId: string,
|
|
160
|
+
packet: Pick<CallTransferPacket, "mode" | "target" | "summary">,
|
|
161
|
+
opts?: { readonly webhookUrl?: string; readonly commandId?: string },
|
|
162
|
+
): TelnyxTransferCommand {
|
|
163
|
+
// Warm summary is not a Telnyx wire field — surface via client_state so a
|
|
164
|
+
// receiving reasoner-seam can recover it after the bridge.
|
|
165
|
+
const clientState =
|
|
166
|
+
packet.mode === "warm" && packet.summary
|
|
167
|
+
? base64UrlEncode(JSON.stringify({ summary: packet.summary, mode: "warm" }))
|
|
168
|
+
: undefined;
|
|
169
|
+
return {
|
|
170
|
+
carrier: "telnyx",
|
|
171
|
+
kind: "transfer",
|
|
172
|
+
mode: packet.mode,
|
|
173
|
+
path: `/v2/calls/${encodeURIComponent(callControlId)}/actions/transfer`,
|
|
174
|
+
method: "POST",
|
|
175
|
+
json: {
|
|
176
|
+
to: packet.target,
|
|
177
|
+
...(opts?.commandId ? { command_id: opts.commandId } : {}),
|
|
178
|
+
...(clientState ? { client_state: clientState } : {}),
|
|
179
|
+
...(opts?.webhookUrl ? { webhook_url: opts.webhookUrl } : {}),
|
|
180
|
+
},
|
|
181
|
+
...(packet.summary !== undefined ? { summary: packet.summary } : {}),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function twilioTransferTwiml(packet: Pick<CallTransferPacket, "mode" | "target" | "summary">): string {
|
|
186
|
+
// sip_refer still uses Dial — we deliberately avoid raw SIP REFER for answer-rate.
|
|
187
|
+
const target = escapeXml(packet.target);
|
|
188
|
+
if (packet.target.startsWith("sip:")) {
|
|
189
|
+
return `<Response><Dial><Sip>${target}</Sip></Dial></Response>`;
|
|
190
|
+
}
|
|
191
|
+
const warmNote =
|
|
192
|
+
packet.mode === "warm" && packet.summary
|
|
193
|
+
? `<!-- warm-handoff: ${escapeXml(packet.summary)} -->`
|
|
194
|
+
: "";
|
|
195
|
+
return `<Response>${warmNote}<Dial>${target}</Dial></Response>`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Injectable HTTP dispatch (fetch + injected creds) ───────────────────────
|
|
199
|
+
|
|
200
|
+
export interface TwilioRestCredentials {
|
|
201
|
+
readonly accountSid: string;
|
|
202
|
+
readonly authToken: string;
|
|
203
|
+
/** Default https://api.twilio.com/2010-04-01/Accounts/{AccountSid} */
|
|
204
|
+
readonly baseUrl?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface TelnyxRestCredentials {
|
|
208
|
+
readonly apiKey: string;
|
|
209
|
+
/** Default https://api.telnyx.com */
|
|
210
|
+
readonly baseUrl?: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Dispatch a Twilio form POST via global `fetch()`. Credentials injected —
|
|
217
|
+
* never reads process.env (Workers-safe). Live call unverified.
|
|
218
|
+
*/
|
|
219
|
+
export async function dispatchTwilioCommand(
|
|
220
|
+
creds: TwilioRestCredentials,
|
|
221
|
+
command: TwilioSendDigitsCommand | TwilioTransferCommand,
|
|
222
|
+
fetchImpl: FetchLike = globalThis.fetch.bind(globalThis),
|
|
223
|
+
): Promise<Response> {
|
|
224
|
+
const base =
|
|
225
|
+
creds.baseUrl ??
|
|
226
|
+
`https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(creds.accountSid)}`;
|
|
227
|
+
const url = `${base.replace(/\/$/, "")}${command.path}`;
|
|
228
|
+
const body = new URLSearchParams(command.form as Record<string, string>).toString();
|
|
229
|
+
const auth = basicAuth(creds.accountSid, creds.authToken);
|
|
230
|
+
return fetchImpl(url, {
|
|
231
|
+
method: command.method,
|
|
232
|
+
headers: {
|
|
233
|
+
Authorization: auth,
|
|
234
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
235
|
+
},
|
|
236
|
+
body,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Dispatch a Telnyx Call Control JSON POST via global `fetch()`. Credentials
|
|
242
|
+
* injected — never reads process.env (Workers-safe). Live call unverified.
|
|
243
|
+
*/
|
|
244
|
+
export async function dispatchTelnyxCommand(
|
|
245
|
+
creds: TelnyxRestCredentials,
|
|
246
|
+
command: TelnyxSendDtmfCommand | TelnyxTransferCommand,
|
|
247
|
+
fetchImpl: FetchLike = globalThis.fetch.bind(globalThis),
|
|
248
|
+
): Promise<Response> {
|
|
249
|
+
const base = (creds.baseUrl ?? "https://api.telnyx.com").replace(/\/$/, "");
|
|
250
|
+
const url = `${base}${command.path}`;
|
|
251
|
+
return fetchImpl(url, {
|
|
252
|
+
method: command.method,
|
|
253
|
+
headers: {
|
|
254
|
+
Authorization: `Bearer ${creds.apiKey}`,
|
|
255
|
+
"Content-Type": "application/json",
|
|
256
|
+
Accept: "application/json",
|
|
257
|
+
},
|
|
258
|
+
body: JSON.stringify(command.json),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── Warm-handoff summary seam ───────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Injectable summarizer for warm transfer. The transport calls this when a
|
|
266
|
+
* `call.transfer` packet has mode `"warm"` and no `summary` yet. Do NOT build
|
|
267
|
+
* a bespoke LLM here — inject your reasoner/app hook.
|
|
268
|
+
*/
|
|
269
|
+
export type WarmTransferSummarizer = (args: {
|
|
270
|
+
readonly contextId: string;
|
|
271
|
+
readonly target: string;
|
|
272
|
+
}) => string | Promise<string | undefined> | undefined;
|
|
273
|
+
|
|
274
|
+
/** Resolve summary: packet.summary wins; else optional summarizer; else undefined. */
|
|
275
|
+
export async function resolveTransferSummary(
|
|
276
|
+
packet: CallTransferPacket,
|
|
277
|
+
summarizer?: WarmTransferSummarizer,
|
|
278
|
+
): Promise<string | undefined> {
|
|
279
|
+
if (packet.summary !== undefined && packet.summary !== "") return packet.summary;
|
|
280
|
+
if (packet.mode !== "warm" || !summarizer) return packet.summary;
|
|
281
|
+
return summarizer({ contextId: packet.contextId, target: packet.target });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
function escapeXml(value: string): string {
|
|
287
|
+
return value
|
|
288
|
+
.replace(/&/g, "&")
|
|
289
|
+
.replace(/</g, "<")
|
|
290
|
+
.replace(/>/g, ">")
|
|
291
|
+
.replace(/"/g, """)
|
|
292
|
+
.replace(/'/g, "'");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function basicAuth(user: string, pass: string): string {
|
|
296
|
+
// btoa works on Workers + Node 18+; fall back to Buffer only if btoa missing
|
|
297
|
+
// (should not happen on our targets — kept pure when btoa exists).
|
|
298
|
+
const token = `${user}:${pass}`;
|
|
299
|
+
if (typeof globalThis.btoa === "function") {
|
|
300
|
+
return `Basic ${globalThis.btoa(token)}`;
|
|
301
|
+
}
|
|
302
|
+
const bytes = new TextEncoder().encode(token);
|
|
303
|
+
let bin = "";
|
|
304
|
+
for (let i = 0; i < bytes.length; i += 1) bin += String.fromCharCode(bytes[i]!);
|
|
305
|
+
// Minimal base64 without Buffer (Workers-safe path if btoa absent)
|
|
306
|
+
return `Basic ${base64Encode(bin)}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function base64UrlEncode(text: string): string {
|
|
310
|
+
const bytes = new TextEncoder().encode(text);
|
|
311
|
+
let bin = "";
|
|
312
|
+
for (let i = 0; i < bytes.length; i += 1) bin += String.fromCharCode(bytes[i]!);
|
|
313
|
+
return base64Encode(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function base64Encode(binary: string): string {
|
|
317
|
+
if (typeof globalThis.btoa === "function") return globalThis.btoa(binary);
|
|
318
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
319
|
+
let out = "";
|
|
320
|
+
let i = 0;
|
|
321
|
+
while (i < binary.length) {
|
|
322
|
+
const a = binary.charCodeAt(i++);
|
|
323
|
+
const b = i < binary.length ? binary.charCodeAt(i++) : NaN;
|
|
324
|
+
const c = i < binary.length ? binary.charCodeAt(i++) : NaN;
|
|
325
|
+
const triplet = (a << 16) | ((Number.isNaN(b) ? 0 : b) << 8) | (Number.isNaN(c) ? 0 : c);
|
|
326
|
+
out += chars[(triplet >> 18) & 63];
|
|
327
|
+
out += chars[(triplet >> 12) & 63];
|
|
328
|
+
out += Number.isNaN(b) ? "=" : chars[(triplet >> 6) & 63];
|
|
329
|
+
out += Number.isNaN(c) ? "=" : chars[triplet & 63];
|
|
330
|
+
}
|
|
331
|
+
return out;
|
|
332
|
+
}
|