@stacksjs/telephony 0.0.0-stage → 0.75.580
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/LICENSE.md +21 -0
- package/README.md +118 -2
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +1 -0
- package/dist/journal.d.ts +19 -0
- package/dist/journal.js +1 -0
- package/dist/media.d.ts +43 -0
- package/dist/media.js +1 -0
- package/dist/testing.d.ts +14 -0
- package/dist/testing.js +1 -0
- package/dist/tsconfig.json +5 -0
- package/dist/twilio.d.ts +33 -0
- package/dist/twilio.js +3 -0
- package/dist/twiml.d.ts +7 -0
- package/dist/twiml.js +1 -0
- package/dist/types.d.ts +64 -0
- package/dist/types.js +0 -0
- package/dist/validation.d.ts +8 -0
- package/dist/validation.js +1 -0
- package/dist/webhooks.d.ts +20 -0
- package/dist/webhooks.js +1 -0
- package/package.json +39 -4
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Open Web Foundation
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,119 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @stacksjs/telephony
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Server-side Bun/TypeScript telephony with a real Twilio REST adapter, explicit write outcomes, authenticated voice callback parsing, escaped TwiML, and bounded Media Streams helpers. The `testing` entry contains a deterministic provider that never uses the network.
|
|
4
|
+
|
|
5
|
+
This package is a transport boundary. It does not provide a voice AI agent, speech recognition, text-to-speech, consent collection, billing, a WebSocket server or production provider approval. Tests use injected HTTP and the fake, not live numbers or calls.
|
|
6
|
+
|
|
7
|
+
## Provider and writes
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { TwilioTelephony, voiceResponse } from '@stacksjs/telephony'
|
|
11
|
+
import type { OperationJournal } from '@stacksjs/telephony'
|
|
12
|
+
|
|
13
|
+
// Supply a persistent implementation with an atomic unique-key claim.
|
|
14
|
+
declare const journal: OperationJournal
|
|
15
|
+
const telephony = new TwilioTelephony({
|
|
16
|
+
accountSid: process.env.TWILIO_ACCOUNT_SID!,
|
|
17
|
+
authToken: process.env.TWILIO_AUTH_TOKEN!,
|
|
18
|
+
journal,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const candidates = await telephony.searchNumbers({ country: 'CA', areaCode: '613', limit: 5 })
|
|
22
|
+
// These subsequent methods incur provider costs. Call only after application authorization.
|
|
23
|
+
const number = await telephony.provisionNumber({
|
|
24
|
+
phoneNumber: candidates[0]!.phoneNumber,
|
|
25
|
+
voiceUrl: 'https://app.example/provider/voice',
|
|
26
|
+
}, { operationKey: 'workspace:123:number:purchase:456' })
|
|
27
|
+
|
|
28
|
+
const call = await telephony.createCall({
|
|
29
|
+
from: number.phoneNumber,
|
|
30
|
+
to: '+16135550101',
|
|
31
|
+
twiml: voiceResponse([{ type: 'say', text: 'Hello' }, { type: 'hangup' }]),
|
|
32
|
+
statusCallback: 'https://app.example/provider/voice-status',
|
|
33
|
+
}, { operationKey: 'workspace:123:call:789' })
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Search supports voice-capable US and Canadian **local** numbers. Search results determine the country; `+1` alone includes other NANP countries. The write APIs validate NANP E.164 syntax, not ownership or country. Provision only a country-verified search result, scope credentials/account resources to the correct workspace, and enforce authorization, consent, spend limits and regulatory requirements in the application. Optional `bundleSid` and `addressSid` are forwarded to Twilio; their presence does not imply approval.
|
|
37
|
+
|
|
38
|
+
Pass `timeLimitSeconds` to `createCall` to put a hard limit on the provider call,
|
|
39
|
+
including when the application worker goes away. `setCallTimeLimit(sid, seconds,
|
|
40
|
+
{operationKey})` updates that bound for an existing inbound/outbound call. Both
|
|
41
|
+
accept integer seconds from 1 to 86400; provider/account restrictions may be
|
|
42
|
+
stricter. Increasing a limit is a paid application decision: reserve its maximum
|
|
43
|
+
cost before changing the provider limit. Never rely on an in-process timer alone.
|
|
44
|
+
|
|
45
|
+
Call results expose `parentSid` for child-leg reconciliation, `durationSeconds`, `priceMicros`, `priceCurrency` and
|
|
46
|
+
`timeLimitSeconds`, null when the provider has not supplied them. `priceMicros`
|
|
47
|
+
is the nonnegative absolute provider charge at one million units per currency
|
|
48
|
+
unit, parsed from decimal strings without floating point. Currency is uppercase;
|
|
49
|
+
unknown price/currency must stay unreconciled, not be treated as free. The call
|
|
50
|
+
resource price may arrive after the completion callback and does not include
|
|
51
|
+
separate child legs, recordings, transcription, number rental or AI charges.
|
|
52
|
+
Aggregate the actual relevant resources in the application before settling.
|
|
53
|
+
|
|
54
|
+
`configureNumber(sid, voiceUrl, options)` changes inbound voice routing.
|
|
55
|
+
`configureSmsNumber(sid, smsUrl, options)` sets the HTTPS inbound SMS webhook
|
|
56
|
+
using POST without changing voice routing; it uses the same durable journal.
|
|
57
|
+
Number results expose voice/SMS application SIDs and the SIP trunk SID. Verify
|
|
58
|
+
these overrides are absent before claiming the webhook controls inbound routing,
|
|
59
|
+
because Twilio ignores the corresponding webhook when an application/trunk wins. `releaseNumber` releases a purchased number. `getCall`, `listCalls`, `redirectCall` and `endCall` retrieve or control calls. To answer an incoming call, return `voiceResponse(...)` as `text/xml` from the authenticated callback. Use `redirectCall` to change instructions for an existing call. `listNumbers(phoneNumber?)` and `listCalls({from?,to?})` return the first page, at most 50 results. Filter exact numbers when reconciling; eventual-consistency results cannot prove that an ambiguous outbound call was never created.
|
|
60
|
+
|
|
61
|
+
Each write requires a stable `operationKey` and a journal supplied at construction. Twilio Calls/IncomingPhoneNumbers are **not assumed** to implement an idempotency header. The adapter sends one request and never retries it automatically:
|
|
62
|
+
|
|
63
|
+
- A matching successful key replays the stored result. Different input with that key fails with `operation_conflict`.
|
|
64
|
+
- An in-flight key fails with `operation_pending`.
|
|
65
|
+
- Provider 4xx rejections are recorded and replayed without sending again; HTTP 408 remains uncertain.
|
|
66
|
+
- Transport errors, timeouts, 5xx, malformed success responses, or failure to persist a result produce `write_uncertain`. The key stays blocked.
|
|
67
|
+
|
|
68
|
+
Implement `OperationJournal.claim` as one durable atomic reservation keyed by `twilio:<accountSid>:<operationKey>`, storing its fingerprint and state. `settle` must atomically persist the outcome. Keep sensitive result data encrypted/scoped according to the application's retention policy. A process crash can leave `pending`; treat it as requiring reconciliation, never as an expired retry lease. Resolve pending/uncertain entries through an audited application reconciliation workflow using provider resources, callbacks and operator review. Do not rotate the key to bypass ambiguity. After a confirmed rejection, an explicitly authorized new attempt uses a new key.
|
|
69
|
+
|
|
70
|
+
`MemoryOperationJournal` is bounded (default 10,000 entries) and never evicts operations. It is for tests/development and offers no crash or multi-process durability. Production must supply a durable journal and outbox. The fake defaults to an isolated memory journal.
|
|
71
|
+
|
|
72
|
+
Requests use HTTPS Basic authentication, disabled redirects, a 10-second timeout (configurable to 120 seconds), and a 1 MiB response bound. Injected `fetch(url, init)` must honor `init.signal`. Errors expose fixed safe messages and typed `code`/HTTP status, never provider response text or transport error causes. No credentials are loaded implicitly and construction performs no network requests.
|
|
73
|
+
|
|
74
|
+
## Signed voice callbacks
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { parseVoiceWebhook, voiceResponse } from '@stacksjs/telephony'
|
|
78
|
+
|
|
79
|
+
const callback = parseVoiceWebhook({
|
|
80
|
+
url: 'https://app.example/provider/voice', // Exact public URL, original query encoding/order included.
|
|
81
|
+
fields: new URLSearchParams(rawFormBody),
|
|
82
|
+
signature: request.headers.get('x-twilio-signature') ?? '',
|
|
83
|
+
authToken: process.env.TWILIO_AUTH_TOKEN!,
|
|
84
|
+
accountSid: process.env.TWILIO_ACCOUNT_SID!,
|
|
85
|
+
})
|
|
86
|
+
// Bind callback.callSid / callback.to to the workspace before changing application state.
|
|
87
|
+
const xml = voiceResponse([{ type: 'say', text: 'Welcome to support' }])
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Retain all decoded form fields and whitespace when verifying, including fields unknown to the application. Apply the route's body-size bound before allocating `rawFormBody`. `parseVoiceWebhook` verifies the signature and expected account, then validates required fields and sequence. `verifyTwilioSignature` also supports an empty field set for an authenticated WebSocket upgrade; pass the exact HTTPS handshake URL. This helper supports form callbacks and upgrades, **not JSON bodySHA256 callbacks**. Never derive the public URL from untrusted forwarded headers.
|
|
91
|
+
|
|
92
|
+
Twilio signatures do not include a freshness timestamp. Persist callback deduplication and order call updates by `CallSid` and sequence in the application. Signatures alone do not authorize workspace access or prevent replay. Check account/call ownership again at WebSocket start.
|
|
93
|
+
|
|
94
|
+
## Media Streams
|
|
95
|
+
|
|
96
|
+
`voiceResponse([{type: 'stream', url: 'wss://voice.example/media', parameters: {...}}])` emits bidirectional `<Connect><Stream>`. Stream URLs have no query string. Parameters and speech are XML-escaped. The safe TwiML vocabulary also includes `say`, `dial`, `reject` and `hangup`. Raw TwiML accepted by call APIs must be trusted application code.
|
|
97
|
+
|
|
98
|
+
`parseMediaEvent` parses connected/start/media/stop/mark/DTMF events with a 64 KiB JSON limit and 8,000-byte audio-frame limit by default. Audio is canonical base64, mu-law/8,000 Hz/mono. `MediaStreamSession({accountSid, callSid})` additionally enforces handshake order, exact sequence progression, matching stream/account/call, declared tracks and a default 30-minute aggregate audio bound. It retains metadata and counters, not audio history. Authenticate the WebSocket upgrade before creating the session and use the application's verified call binding.
|
|
99
|
+
|
|
100
|
+
`mediaMessage(streamSid, bytes)`, `markMediaMessage` and `clearMediaMessage` encode outgoing messages. Bytes must already be raw mu-law without WAV/MP3 headers; the helpers do not transcode or inspect the codec. Twilio mark acknowledgements can mean playback finished **or** the buffer was cleared.
|
|
101
|
+
|
|
102
|
+
`AudioQueue` copies frames and applies explicit byte/frame limits. Overflow throws `capacity` without dropping previously accepted audio. The application must implement backpressure or close/fail the call. Do not silently drop or sample support audio. Configure WebSocket frame limits and outbound `bufferedAmount`/acknowledgement gates at the server; these helpers cannot bound the provider's playback buffer or the server's socket buffers.
|
|
103
|
+
|
|
104
|
+
## Testing and validation
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { FakeTelephony } from '@stacksjs/telephony/testing'
|
|
108
|
+
const fake = new FakeTelephony()
|
|
109
|
+
// Same Telephony methods; no network and deterministic per-instance SIDs.
|
|
110
|
+
// receiveCall, setCallStatus and failNext are explicit simulation controls.
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`failNext('before-request' | 'after-write' | 'rejected')` simulates loss before processing, a lost response after a successful mutation, or an HTTP rejection. The first mode is intentionally still uncertain to the adapter. The fake simulates basic caller ownership and terminal calls; it is not Twilio billing, regulation, timing or error-code parity.
|
|
114
|
+
|
|
115
|
+
From this package run `bun run build`, `bun test ./tests`, and `bun run typecheck`. From the repository run `./buddy lint storage/framework/core/telephony`. The isolated consumer test copies only the manifest and built output to a temporary `node_modules`, exercising both public exports with no repository aliases. Build before running tests.
|
|
116
|
+
|
|
117
|
+
Contracts follow [Twilio phone-number search](https://www.twilio.com/docs/phone-numbers/api/availablephonenumberlocal-resource), [provision/release](https://www.twilio.com/docs/phone-numbers/api/incomingphonenumber-resource), [call control](https://www.twilio.com/docs/voice/api/call-resource), [signature validation](https://www.twilio.com/docs/usage/security), [Stream TwiML](https://www.twilio.com/docs/voice/twiml/stream), and [Media Streams messages](https://www.twilio.com/docs/voice/media-streams/websocket-messages), reviewed November 13, 2026.
|
|
118
|
+
|
|
119
|
+
Live acceptance still requires authorized provider credentials, number/regulatory approval, a public HTTPS callback, signed WebSocket upgrade handling, and tested speech/AI integrations. No live provider calls or purchases are performed by the tests.
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type TelephonyErrorCode = 'configuration' | 'validation' | 'authentication' | 'not_found' | 'rate_limited' | 'rejected' | 'transport' | 'invalid_response' | 'operation_conflict' | 'operation_pending' | 'write_uncertain' | 'capacity' | 'invalid_signature' | 'invalid_media';
|
|
2
|
+
/** Messages are fixed locally. Provider bodies, credentials and fetch errors are never exposed. */
|
|
3
|
+
export declare class TelephonyError extends Error {
|
|
4
|
+
public readonly code: TelephonyErrorCode;
|
|
5
|
+
public readonly details: { status?: number, providerCode?: number, operationKey?: string };
|
|
6
|
+
constructor(code: TelephonyErrorCode, message: string, details?: { status?: number, providerCode?: number, operationKey?: string });
|
|
7
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export class TelephonyError extends Error{code;details;constructor(code,message,details={}){super(message);this.code=code;this.details=details;this.name="TelephonyError"}}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { OperationJournal, OperationOutcome, OperationRecord } from './journal';
|
|
2
|
+
export * from './types';
|
|
3
|
+
export * from './errors';
|
|
4
|
+
export { MemoryOperationJournal } from './journal';
|
|
5
|
+
export * from './twilio';
|
|
6
|
+
export * from './webhooks';
|
|
7
|
+
export * from './twiml';
|
|
8
|
+
export * from './media';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export*from"./types.js";export*from"./errors.js";export{MemoryOperationJournal}from"./journal.js";export*from"./twilio.js";export*from"./webhooks.js";export*from"./twiml.js";export*from"./media.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { TelephonyErrorCode } from './errors';
|
|
2
|
+
import type { WriteOptions } from './types';
|
|
3
|
+
/** Single-attempt writes. Twilio Calls/IncomingPhoneNumbers have no assumed idempotency header. */
|
|
4
|
+
export declare function executeWrite<T>(journal: OperationJournal, scope: string, description: string, options: WriteOptions, run: () => Promise<T>): Promise<T>;
|
|
5
|
+
/** claim must atomically reserve a key or return its existing record. Never expire pending/uncertain writes automatically. */
|
|
6
|
+
export declare interface OperationJournal {
|
|
7
|
+
claim: (key: string, fingerprint: string) => Promise<{ acquired: boolean, record: OperationRecord }>
|
|
8
|
+
settle: (key: string, outcome: OperationOutcome) => Promise<void>
|
|
9
|
+
}
|
|
10
|
+
export type OperationOutcome = { state: 'succeeded', value: unknown } | { state: 'uncertain' } | { state: 'rejected', code: TelephonyErrorCode, status?: number, providerCode?: number }
|
|
11
|
+
export type OperationRecord = { fingerprint: string } & ({ state: 'pending' } | OperationOutcome);
|
|
12
|
+
/** Bounded, process-local test/development journal. Production needs a durable atomic implementation. */
|
|
13
|
+
export declare class MemoryOperationJournal implements OperationJournal {
|
|
14
|
+
private records;
|
|
15
|
+
private capacity;
|
|
16
|
+
constructor(capacity?: number);
|
|
17
|
+
claim(key: string, fingerprint: string): Promise<{ acquired: boolean, record: OperationRecord }>;
|
|
18
|
+
settle(key: string, outcome: OperationOutcome): Promise<void>;
|
|
19
|
+
}
|
package/dist/journal.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{createHash}from"node:crypto";import{TelephonyError}from"./errors.js";export class MemoryOperationJournal{capacity;records=new Map;constructor(capacity=1e4){this.capacity=capacity;if(!Number.isSafeInteger(capacity)||capacity<1)throw new TelephonyError("configuration","Journal capacity must be a positive integer.")}async claim(key,fingerprint){const existing=this.records.get(key);if(existing)return{acquired:false,record:structuredClone(existing)};if(this.records.size>=this.capacity)throw new TelephonyError("capacity","Operation journal capacity reached.");const record={fingerprint,state:"pending"};this.records.set(key,record);return{acquired:true,record:structuredClone(record)}}async settle(key,outcome){const record=this.records.get(key);if(!record||record.state!=="pending")throw new TelephonyError("operation_conflict","Only a pending operation can be settled.");this.records.set(key,structuredClone({fingerprint:record.fingerprint,...outcome}))}}export async function executeWrite(journal,scope,description,options,run){const key=options?.operationKey;if(typeof key!=="string"||!/^[\w:.-]{1,200}$/.test(key))throw new TelephonyError("validation","A stable operation key is required.");const fingerprint=createHash("sha256").update(description).digest("hex");const scopedKey=`${scope}:${key}`;let claim;try{claim=await journal.claim(scopedKey,fingerprint)}catch(error){if(error instanceof TelephonyError&&error.code==="capacity")throw error;throw new TelephonyError("transport","Operation journal is unavailable. No provider request was sent.",{operationKey:key})}const{acquired,record}=claim;if(record.fingerprint!==fingerprint)throw new TelephonyError("operation_conflict","Operation key was already used with different input.",{operationKey:key});if(!acquired){if(record.state==="succeeded")return structuredClone(record.value);if(record.state==="rejected")throw new TelephonyError(record.code,"Provider rejected this operation.",{status:record.status,providerCode:record.providerCode,operationKey:key});throw new TelephonyError(record.state==="pending"?"operation_pending":"write_uncertain","Reconcile the existing operation before attempting another write.",{operationKey:key})}let value;try{value=await run()}catch(error){const rejected=error instanceof TelephonyError&&["authentication","not_found","rate_limited","rejected","validation"].includes(error.code);const outcome=rejected?{state:"rejected",code:error.code,status:error.details.status,providerCode:error.details.providerCode}:{state:"uncertain"};try{await journal.settle(scopedKey,outcome)}catch{throw new TelephonyError("write_uncertain","Write outcome could not be recorded. Reconcile before retrying.",{operationKey:key})}if(rejected)throw error;throw new TelephonyError("write_uncertain","Provider write may have completed. Reconcile before retrying.",{operationKey:key})}try{await journal.settle(scopedKey,{state:"succeeded",value})}catch{throw new TelephonyError("write_uncertain","Provider write completed but its result could not be recorded. Reconcile before retrying.",{operationKey:key})}return value}
|
package/dist/media.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Pure parser. Authenticate the WebSocket upgrade before accepting any message. */
|
|
2
|
+
export declare function parseMediaEvent(json: string, limits?: MediaLimits): MediaEvent;
|
|
3
|
+
/** Outbound raw mu-law audio. This encodes bytes; it does not transcode PCM/WAV or validate a codec. */
|
|
4
|
+
export declare function mediaMessage(streamSid: string, audio: Uint8Array, maxAudioBytes?: number): string;
|
|
5
|
+
export declare function clearMediaMessage(streamSid: string): string;
|
|
6
|
+
export declare function markMediaMessage(streamSid: string, name: string): string;
|
|
7
|
+
export declare interface MediaLimits { maxMessageBytes?: number, maxAudioBytes?: number }
|
|
8
|
+
export declare interface MediaSessionOptions extends MediaLimits {
|
|
9
|
+
accountSid: string
|
|
10
|
+
callSid: string
|
|
11
|
+
maxTotalAudioBytes?: number
|
|
12
|
+
}
|
|
13
|
+
export type MediaEvent = { event: 'connected', protocol: 'Call', version: '1.0.0' }
|
|
14
|
+
| { event: 'start', sequenceNumber: number, streamSid: string, accountSid: string, callSid: string, tracks: ('inbound' | 'outbound')[], customParameters: Record<string, string> }
|
|
15
|
+
| { event: 'media', sequenceNumber: number, streamSid: string, track: 'inbound' | 'outbound', chunk: number, timestamp: number, audio: Uint8Array }
|
|
16
|
+
| { event: 'stop', sequenceNumber: number, streamSid: string, accountSid: string, callSid: string }
|
|
17
|
+
| { event: 'mark', sequenceNumber: number, streamSid: string, name: string }
|
|
18
|
+
| { event: 'dtmf', sequenceNumber: number, streamSid: string, digit: string }
|
|
19
|
+
/** Per-WebSocket state: bind a signed connection to its expected account/call and reject replays or mixed streams. */
|
|
20
|
+
export declare class MediaStreamSession {
|
|
21
|
+
private stage;
|
|
22
|
+
private streamSid;
|
|
23
|
+
private sequence;
|
|
24
|
+
private audioBytes;
|
|
25
|
+
private maxTotal;
|
|
26
|
+
private options;
|
|
27
|
+
private tracks;
|
|
28
|
+
constructor(options: MediaSessionOptions);
|
|
29
|
+
accept(json: string): MediaEvent;
|
|
30
|
+
}
|
|
31
|
+
/** Explicit backpressure: overflow throws without dropping accepted audio. The caller must pause or close the stream. */
|
|
32
|
+
export declare class AudioQueue {
|
|
33
|
+
private frames;
|
|
34
|
+
private bytes;
|
|
35
|
+
private maxBytes;
|
|
36
|
+
private maxFrames;
|
|
37
|
+
constructor(options?: { maxBytes?: number, maxFrames?: number });
|
|
38
|
+
get byteLength(): number;
|
|
39
|
+
get length(): number;
|
|
40
|
+
push(audio: Uint8Array): void;
|
|
41
|
+
shift(): Uint8Array | undefined;
|
|
42
|
+
clear(): void;
|
|
43
|
+
}
|
package/dist/media.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{TelephonyError}from"./errors.js";import{sid}from"./validation.js";function badMedia(){throw new TelephonyError("invalid_media","Invalid, out-of-order or oversized Media Stream event.")}function record(value){if(!value||typeof value!=="object"||Array.isArray(value))badMedia();return value}function resource(value,prefix){if(typeof value!=="string"||!new RegExp(`^${prefix}[a-f0-9]{32}$`,"i").test(value))badMedia();return value}function integer(value,minimum=0){if(typeof value!=="string"||!/^\d+$/.test(value)||!Number.isSafeInteger(Number(value))||Number(value)<minimum)badMedia();return Number(value)}function limit(value,fallback,ceiling=1048576){const result=value??fallback;if(!Number.isSafeInteger(result)||result<1||result>ceiling)throw new TelephonyError("configuration","Media limits must be positive bounded integers.");return result}export function parseMediaEvent(json,limits={}){const maxMessage=limit(limits.maxMessageBytes,65536);const maxAudio=limit(limits.maxAudioBytes,8000);if(typeof json!=="string"||json.length>maxMessage||Buffer.byteLength(json)>maxMessage)badMedia();let parsed;try{parsed=JSON.parse(json)}catch{return badMedia()}const row=record(parsed);if(row.event==="connected"){if(row.protocol!=="Call"||row.version!=="1.0.0")badMedia();return{event:"connected",protocol:"Call",version:"1.0.0"}}const streamSid=resource(row.streamSid,"MZ");const sequenceNumber=integer(row.sequenceNumber,1);if(row.event==="start"){const start=record(row.start);const format=record(start.mediaFormat);if(format.encoding!=="audio/x-mulaw"||format.sampleRate!==8000||format.channels!==1||start.streamSid!==streamSid)badMedia();if(!Array.isArray(start.tracks)||start.tracks.length<1||start.tracks.length>2||start.tracks.some((track)=>track!=="inbound"&&track!=="outbound"))badMedia();const custom=record(start.customParameters??{});if(Object.keys(custom).length>20||Object.entries(custom).some(([key,value])=>typeof value!=="string"||key.length+value.length>=500))badMedia();return{event:"start",sequenceNumber,streamSid,accountSid:resource(start.accountSid,"AC"),callSid:resource(start.callSid,"CA"),tracks:start.tracks,customParameters:custom}}if(row.event==="media"){const media=record(row.media);if(media.track!=="inbound"&&media.track!=="outbound")badMedia();const payload=media.payload;if(typeof payload!=="string"||!payload||payload.length>Math.ceil(maxAudio/3)*4||!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(payload))badMedia();const audio=Buffer.from(payload,"base64");if(audio.length>maxAudio||audio.toString("base64")!==payload)badMedia();return{event:"media",sequenceNumber,streamSid,track:media.track,chunk:integer(media.chunk,1),timestamp:integer(media.timestamp),audio}}if(row.event==="stop"){const stop=record(row.stop);return{event:"stop",sequenceNumber,streamSid,accountSid:resource(stop.accountSid,"AC"),callSid:resource(stop.callSid,"CA")}}if(row.event==="mark"){const name=record(row.mark).name;if(typeof name!=="string"||name.length<1||name.length>500)badMedia();return{event:"mark",sequenceNumber,streamSid,name}}if(row.event==="dtmf"){const dtmf=record(row.dtmf);if(dtmf.track!=="inbound_track"||typeof dtmf.digit!=="string"||!/^[0-9*#]$/.test(dtmf.digit))badMedia();return{event:"dtmf",sequenceNumber,streamSid,digit:dtmf.digit}}return badMedia()}export class MediaStreamSession{stage="new";streamSid;sequence=0;audioBytes=0;maxTotal;options;tracks=[];constructor(options){sid(options.accountSid,"AC");sid(options.callSid,"CA");this.options={...options};this.maxTotal=limit(options.maxTotalAudioBytes,14400000,8000*60*60*24);limit(options.maxMessageBytes,65536);limit(options.maxAudioBytes,8000)}accept(json){const event=parseMediaEvent(json,this.options);if(event.event==="connected"){if(this.stage!=="new")badMedia();this.stage="connected";return event}if(event.sequenceNumber!==this.sequence+1)badMedia();if(event.event==="start"){if(this.stage!=="connected"||event.accountSid!==this.options.accountSid||event.callSid!==this.options.callSid)badMedia();this.streamSid=event.streamSid;this.tracks=[...event.tracks];this.stage="started"}else{if(this.stage!=="started"||event.streamSid!==this.streamSid)badMedia();if(event.event==="media"){if(!this.tracks.includes(event.track)||this.audioBytes+event.audio.byteLength>this.maxTotal)badMedia();this.audioBytes+=event.audio.byteLength}if(event.event==="stop"){if(event.accountSid!==this.options.accountSid||event.callSid!==this.options.callSid)badMedia();this.stage="stopped"}}this.sequence=event.sequenceNumber;return event}}export function mediaMessage(streamSid,audio,maxAudioBytes=8000){sid(streamSid,"MZ");if(!(audio instanceof Uint8Array)||audio.byteLength<1||audio.byteLength>limit(maxAudioBytes,8000))badMedia();return JSON.stringify({event:"media",streamSid,media:{payload:Buffer.from(audio).toString("base64")}})}export function clearMediaMessage(streamSid){return JSON.stringify({event:"clear",streamSid:sid(streamSid,"MZ")})}export function markMediaMessage(streamSid,name){if(!name||name.length>500)badMedia();return JSON.stringify({event:"mark",streamSid:sid(streamSid,"MZ"),mark:{name}})}export class AudioQueue{frames=[];bytes=0;maxBytes;maxFrames;constructor(options={}){this.maxBytes=limit(options.maxBytes,64000);this.maxFrames=limit(options.maxFrames,400,1e4)}get byteLength(){return this.bytes}get length(){return this.frames.length}push(audio){if(!(audio instanceof Uint8Array)||audio.byteLength<1)badMedia();if(this.frames.length>=this.maxFrames||this.bytes+audio.byteLength>this.maxBytes)throw new TelephonyError("capacity","Audio queue is full. Apply backpressure or close the stream.");this.frames.push(new Uint8Array(audio));this.bytes+=audio.byteLength}shift(){const frame=this.frames.shift();if(frame)this.bytes-=frame.byteLength;return frame}clear(){this.frames.length=0;this.bytes=0}}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { TwilioTelephony } from './twilio';
|
|
2
|
+
import type { AvailableNumber, Call, CallStatus } from './types';
|
|
3
|
+
import type { OperationJournal } from './journal';
|
|
4
|
+
export declare interface FakeTelephonyOptions { availableNumbers?: AvailableNumber[], journal?: OperationJournal }
|
|
5
|
+
/** Deterministic in-memory provider, routed through the real adapter's validation/journal/HTTP contracts. Never uses the network. */
|
|
6
|
+
export declare class FakeTelephony extends TwilioTelephony {
|
|
7
|
+
readonly mode: 'fake';
|
|
8
|
+
private state;
|
|
9
|
+
constructor(options?: FakeTelephonyOptions);
|
|
10
|
+
failNext(mode: 'before-request' | 'after-write' | 'rejected'): void;
|
|
11
|
+
receiveCall(input: { from: string, to: string }): Call;
|
|
12
|
+
setCallBilling(callSid: string, input: { durationSeconds: number, priceMicros: number, currency: string }): void;
|
|
13
|
+
setCallStatus(callSid: string, status: CallStatus): void;
|
|
14
|
+
}
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{TelephonyError}from"./errors.js";import{MemoryOperationJournal}from"./journal.js";import{TwilioTelephony}from"./twilio.js";import{callStatuses,phone}from"./validation.js";function nextSid(state,prefix){return`${prefix}${(++state.sequence).toString(16).padStart(32,"0")}`}function callJson(call){return{...call,sid:call.sid,duration:call.durationSeconds==null?null:String(call.durationSeconds),price:call.priceMicros==null?null:`-${Math.floor(call.priceMicros/1e6)}.${String(call.priceMicros%1e6).padStart(6,"0")}`,price_unit:call.priceCurrency??null,time_limit:call.timeLimitSeconds??null}}function numberJson(number){return{sid:number.sid,phone_number:number.phoneNumber,voice_url:number.voiceUrl,sms_url:number.smsUrl??null,voice_method:number.voiceMethod??null,sms_method:number.smsMethod??null}}function notFound(){return Response.json({code:20404},{status:404})}function respond(state,url,method,params){const path=url.pathname.split("/").slice(4).join("/");if(method==="GET"&&path.startsWith("AvailablePhoneNumbers/")){const country=path.split("/")[1];const available=state.available.filter((number)=>number.country===country&&![...state.numbers.values()].some((owned)=>owned.phoneNumber===number.phoneNumber)&&(!url.searchParams.has("AreaCode")||number.phoneNumber.slice(2,5)===url.searchParams.get("AreaCode"))).slice(0,Number(url.searchParams.get("PageSize")??20));return Response.json({available_phone_numbers:available.map((number)=>({phone_number:number.phoneNumber,iso_country:number.country,locality:number.locality,region:number.region,capabilities:{voice:number.voice,SMS:number.sms}}))})}if(path==="IncomingPhoneNumbers.json"){if(method==="GET")return Response.json({incoming_phone_numbers:[...state.numbers.values()].filter((number)=>!url.searchParams.has("PhoneNumber")||number.phoneNumber===url.searchParams.get("PhoneNumber")).slice(0,50).map(numberJson)});const selected=params.get("PhoneNumber");if(!state.available.some((number)=>number.phoneNumber===selected)||[...state.numbers.values()].some((number)=>number.phoneNumber===selected))return Response.json({},{status:400});const number={sid:nextSid(state,"PN"),phoneNumber:selected,voiceUrl:params.get("VoiceUrl"),voiceMethod:"POST"};state.numbers.set(number.sid,number);return Response.json(numberJson(number),{status:201})}if(path.startsWith("IncomingPhoneNumbers/")){const numberSid=path.split("/")[1].replace(".json","");const number=state.numbers.get(numberSid);if(!number)return notFound();if(method==="DELETE"){state.numbers.delete(numberSid);return new Response(null,{status:204})}if(params.has("VoiceUrl")){number.voiceUrl=params.get("VoiceUrl");number.voiceMethod="POST"}if(params.has("SmsUrl")){number.smsUrl=params.get("SmsUrl");number.smsMethod="POST"}return Response.json(numberJson(number))}if(path==="Calls.json"){if(method==="GET")return Response.json({calls:[...state.calls.values()].filter((call)=>(!url.searchParams.has("To")||call.to===url.searchParams.get("To"))&&(!url.searchParams.has("From")||call.from===url.searchParams.get("From"))).slice(0,50).map(callJson)});if(![...state.numbers.values()].some((number)=>number.phoneNumber===params.get("From")))return Response.json({},{status:400});const call={sid:nextSid(state,"CA"),from:params.get("From"),to:params.get("To"),status:"queued",direction:"outbound-api",timeLimitSeconds:params.has("TimeLimit")?Number(params.get("TimeLimit")):null};state.calls.set(call.sid,call);return Response.json(callJson(call),{status:201})}if(path.startsWith("Calls/")){const callSid=path.split("/")[1].replace(".json","");const call=state.calls.get(callSid);if(!call)return notFound();if(method==="POST"){if(["completed","busy","failed","no-answer","canceled"].includes(call.status))return Response.json({},{status:400});if(params.has("Status"))call.status=params.get("Status");else if(params.has("Twiml")||params.has("Url"))call.status="in-progress";if(params.has("TimeLimit"))call.timeLimitSeconds=Number(params.get("TimeLimit"))}return Response.json(callJson(call))}return notFound()}export class FakeTelephony extends TwilioTelephony{mode="fake";state;constructor(options={}){const state={available:structuredClone(options.availableNumbers??[{phoneNumber:"+14155550100",country:"US",voice:true,sms:true},{phoneNumber:"+16135550100",country:"CA",voice:true,sms:true}]),numbers:new Map,calls:new Map,sequence:0};super({accountSid:`AC${"0".repeat(32)}`,authToken:"fake-only",journal:options.journal??new MemoryOperationJournal,fetch:async(url,init)=>{const failure=state.failure;state.failure=undefined;if(failure==="before-request")throw new Error("Simulated transport loss");if(failure==="rejected")return Response.json({},{status:400});const method=init?.method??"GET";const response=respond(state,new URL(url),method,new URLSearchParams(String(init?.body??"")));if(failure==="after-write"&&method!=="GET"&&response.ok)throw new Error("Simulated lost response after commit");return response}});this.state=state}failNext(mode){this.state.failure=mode}receiveCall(input){phone(input.from);phone(input.to);if(![...this.state.numbers.values()].some((number)=>number.phoneNumber===input.to))throw new TelephonyError("not_found","The fake destination number has not been provisioned.");const call={sid:nextSid(this.state,"CA"),from:input.from,to:input.to,status:"ringing",direction:"inbound"};this.state.calls.set(call.sid,call);return structuredClone(call)}setCallBilling(callSid,input){const call=this.state.calls.get(callSid);if(!call)throw new TelephonyError("not_found","Fake call was not found.");if(!Number.isSafeInteger(input.durationSeconds)||input.durationSeconds<0||!Number.isSafeInteger(input.priceMicros)||input.priceMicros<0||!/^[A-Z]{3}$/.test(input.currency))throw new TelephonyError("validation","Invalid simulated billing.");Object.assign(call,{durationSeconds:input.durationSeconds,priceMicros:input.priceMicros,priceCurrency:input.currency})}setCallStatus(callSid,status){const call=this.state.calls.get(callSid);if(!call)throw new TelephonyError("not_found","Fake call was not found.");if(!callStatuses.includes(status))throw new TelephonyError("validation","Invalid fake call status.");call.status=status}}
|
package/dist/twilio.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { AvailableNumber, Call, CallInstructions, CreateCall, NumberSearch, PhoneNumber, ProvisionNumber, Telephony, WriteOptions } from './types';
|
|
2
|
+
import type { OperationJournal } from './journal';
|
|
3
|
+
export declare interface TwilioTelephonyOptions {
|
|
4
|
+
accountSid: string
|
|
5
|
+
authToken: string
|
|
6
|
+
journal: OperationJournal
|
|
7
|
+
fetch?: TelephonyFetch
|
|
8
|
+
timeoutMs?: number
|
|
9
|
+
}
|
|
10
|
+
export type TelephonyFetch = (url: string, init?: RequestInit) => Promise<Response>;
|
|
11
|
+
/** Server-only adapter. No network work at construction, retries, purchases or calls without an explicit method call. */
|
|
12
|
+
export declare class TwilioTelephony implements Telephony {
|
|
13
|
+
private account;
|
|
14
|
+
private auth;
|
|
15
|
+
private journal;
|
|
16
|
+
private fetcher;
|
|
17
|
+
private timeoutMs;
|
|
18
|
+
constructor(options: TwilioTelephonyOptions);
|
|
19
|
+
private request;
|
|
20
|
+
private write;
|
|
21
|
+
searchNumbers(input: NumberSearch): Promise<AvailableNumber[]>;
|
|
22
|
+
listNumbers(phoneNumber?: string): Promise<PhoneNumber[]>;
|
|
23
|
+
provisionNumber(input: ProvisionNumber, options: WriteOptions): Promise<PhoneNumber>;
|
|
24
|
+
configureNumber(numberSid: string, voiceUrl: string, options: WriteOptions): Promise<PhoneNumber>;
|
|
25
|
+
configureSmsNumber(numberSid: string, smsUrl: string, options: WriteOptions): Promise<PhoneNumber>;
|
|
26
|
+
releaseNumber(numberSid: string, options: WriteOptions): Promise<void>;
|
|
27
|
+
createCall(input: CreateCall, options: WriteOptions): Promise<Call>;
|
|
28
|
+
setCallTimeLimit(callSid: string, seconds: number, options: WriteOptions): Promise<Call>;
|
|
29
|
+
getCall(callSid: string): Promise<Call>;
|
|
30
|
+
listCalls(filter?: { from?: string, to?: string }): Promise<Call[]>;
|
|
31
|
+
redirectCall(callSid: string, input: CallInstructions, options: WriteOptions): Promise<Call>;
|
|
32
|
+
endCall(callSid: string, status: 'completed' | 'canceled', options: WriteOptions): Promise<Call>;
|
|
33
|
+
}
|
package/dist/twilio.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import{TelephonyError}from"./errors.js";import{executeWrite}from"./journal.js";import{callStatuses,instructions,invalid,phone,search,secureUrl,sid}from"./validation.js";function badResponse(){throw new TelephonyError("invalid_response","Provider returned an invalid response.")}function object(value){if(typeof value!=="object"||!value||Array.isArray(value))badResponse();return value}function text(value){if(typeof value!=="string")badResponse();return value}function resourceSid(value,prefix){const result=text(value);if(!new RegExp(`^${prefix}[a-f0-9]{32}$`,"i").test(result))badResponse();return result}function optionalSid(value,prefix){return value==null||value===""?null:resourceSid(value,prefix)}function callbackMethod(value){if(value==null)return null;if(value!=="GET"&&value!=="POST")badResponse();return value}function numberResult(value){const row=object(value);return{sid:resourceSid(row.sid,"PN"),phoneNumber:text(row.phone_number),voiceUrl:row.voice_url==null?null:text(row.voice_url),smsUrl:row.sms_url==null?null:text(row.sms_url),voiceMethod:callbackMethod(row.voice_method),smsMethod:callbackMethod(row.sms_method),voiceApplicationSid:optionalSid(row.voice_application_sid,"AP"),smsApplicationSid:optionalSid(row.sms_application_sid,"AP"),trunkSid:optionalSid(row.trunk_sid,"TK")}}function duration(value){if(value===undefined||value===null)return null;if((typeof value!=="string"||!/^\d+$/.test(value))&&(typeof value!=="number"||!Number.isSafeInteger(value)))badResponse();const parsed=Number(value);if(!Number.isSafeInteger(parsed)||parsed<0)badResponse();return parsed}function price(value){if(value===undefined||value===null)return null;if(typeof value!=="string"||!/^-?\d+(?:\.\d{1,6})?$/.test(value))badResponse();const[whole,fraction=""]=value.replace(/^-/,"").split(".");const micros=BigInt(whole)*1000000n+BigInt(fraction.padEnd(6,"0"));if(micros>BigInt(Number.MAX_SAFE_INTEGER))badResponse();return Number(micros)}function callLimit(value){if(!Number.isSafeInteger(value)||value<1||value>86400)invalid("Call time limit must be 1 to 86400 seconds.");return String(value)}function callResult(value){const row=object(value);if(!callStatuses.includes(row.status))badResponse();const priceMicros=price(row.price);const priceCurrency=row.price_unit==null?null:text(row.price_unit).toUpperCase();if(priceCurrency!==null&&!/^[A-Z]{3}$/.test(priceCurrency)||priceMicros!==null&&priceCurrency===null)badResponse();return{sid:resourceSid(row.sid,"CA"),from:text(row.from),to:text(row.to),status:row.status,direction:text(row.direction),parentSid:row.parent_call_sid==null?null:resourceSid(row.parent_call_sid,"CA"),durationSeconds:duration(row.duration),priceMicros,priceCurrency,timeLimitSeconds:duration(row.time_limit)}}function rows(value,key){const list=object(value)[key];if(!Array.isArray(list)||list.length>50)badResponse();return list}export class TwilioTelephony{account;auth;journal;fetcher;timeoutMs;constructor(options){if(!options.authToken||!/^AC[a-f0-9]{32}$/i.test(options.accountSid))throw new TelephonyError("configuration","Twilio account credentials are required.");if(!options.journal)throw new TelephonyError("configuration","An operation journal is required.");const timeout=options.timeoutMs??1e4;if(!Number.isSafeInteger(timeout)||timeout<1||timeout>120000)throw new TelephonyError("configuration","Timeout must be between 1 and 120000 milliseconds.");this.account=options.accountSid;this.auth=`Basic ${Buffer.from(`${options.accountSid}:${options.authToken}`).toString("base64")}`;this.journal=options.journal;this.fetcher=options.fetch??globalThis.fetch;this.timeoutMs=timeout}async request(path,method,params){const controller=new AbortController;const timeout=setTimeout(()=>controller.abort(),this.timeoutMs);try{const url=`https://api.twilio.com/2010-04-01/Accounts/${this.account}/${path}${method==="GET"&¶ms?`?${params}`:""}`;const response=await this.fetcher(url,{method,headers:{Authorization:this.auth,...method==="POST"?{"Content-Type":"application/x-www-form-urlencoded"}:{}},body:method==="POST"?params?.toString():undefined,redirect:"error",signal:controller.signal});if(!response.ok){await response.body?.cancel();const code=response.status===401||response.status===403?"authentication":response.status===404?"not_found":response.status===429?"rate_limited":response.status>=400&&response.status<500&&response.status!==408?"rejected":"transport";throw new TelephonyError(code,"Twilio request failed.",{status:response.status})}if(method==="DELETE"&&response.status===204)return;if(!response.body)badResponse();const reader=response.body.getReader();const chunks=[];let size=0;try{for(;;){const chunk=await reader.read();if(chunk.done)break;size+=chunk.value.byteLength;if(size>1048576){await reader.cancel();badResponse()}chunks.push(chunk.value)}}finally{reader.releaseLock()}try{return JSON.parse(Buffer.concat(chunks).toString("utf8"))}catch{return badResponse()}}catch(error){if(error instanceof TelephonyError)throw error;throw new TelephonyError("transport","Twilio transport failed or timed out.")}finally{clearTimeout(timeout)}}async write(path,method,params,options,parse){return executeWrite(this.journal,`twilio:${this.account}`,`${method}
|
|
2
|
+
${path}
|
|
3
|
+
${params}`,options,async()=>parse(await this.request(path,method,params)))}async searchNumbers(input){const limit=search(input);const query=new URLSearchParams({VoiceEnabled:"true",PageSize:String(limit)});if(input.areaCode)query.set("AreaCode",input.areaCode);return rows(await this.request(`AvailablePhoneNumbers/${input.country}/Local.json`,"GET",query),"available_phone_numbers").map((value)=>{const row=object(value);if(row.iso_country!==input.country)badResponse();const capabilities=object(row.capabilities);if(typeof capabilities.voice!=="boolean"||typeof capabilities.SMS!=="boolean")badResponse();return{phoneNumber:text(row.phone_number),country:input.country,locality:typeof row.locality==="string"?row.locality:undefined,region:typeof row.region==="string"?row.region:undefined,voice:capabilities.voice,sms:capabilities.SMS}})}async listNumbers(phoneNumber){const query=new URLSearchParams({PageSize:"50"});if(phoneNumber)query.set("PhoneNumber",phone(phoneNumber));return rows(await this.request("IncomingPhoneNumbers.json","GET",query),"incoming_phone_numbers").map(numberResult)}async provisionNumber(input,options){const params=new URLSearchParams({PhoneNumber:phone(input.phoneNumber),VoiceUrl:secureUrl(input.voiceUrl),VoiceMethod:"POST"});if(input.statusCallback){params.set("StatusCallback",secureUrl(input.statusCallback));params.set("StatusCallbackMethod","POST")}if(input.bundleSid)params.set("BundleSid",sid(input.bundleSid,"BU"));if(input.addressSid)params.set("AddressSid",sid(input.addressSid,"AD"));return this.write("IncomingPhoneNumbers.json","POST",params,options,numberResult)}async configureNumber(numberSid,voiceUrl,options){return this.write(`IncomingPhoneNumbers/${sid(numberSid,"PN")}.json`,"POST",new URLSearchParams({VoiceUrl:secureUrl(voiceUrl),VoiceMethod:"POST"}),options,numberResult)}async configureSmsNumber(numberSid,smsUrl,options){return this.write(`IncomingPhoneNumbers/${sid(numberSid,"PN")}.json`,"POST",new URLSearchParams({SmsUrl:secureUrl(smsUrl),SmsMethod:"POST"}),options,numberResult)}async releaseNumber(numberSid,options){return this.write(`IncomingPhoneNumbers/${sid(numberSid,"PN")}.json`,"DELETE",new URLSearchParams,options,(value)=>{if(value!==undefined)badResponse()})}async createCall(input,options){const params=instructions(input);params.set("From",phone(input.from));params.set("To",phone(input.to));if(input.timeLimitSeconds!==undefined)params.set("TimeLimit",callLimit(input.timeLimitSeconds));if(input.statusCallback){params.set("StatusCallback",secureUrl(input.statusCallback));params.set("StatusCallbackMethod","POST");for(const event of["initiated","ringing","answered","completed"])params.append("StatusCallbackEvent",event)}return this.write("Calls.json","POST",params,options,callResult)}async setCallTimeLimit(callSid,seconds,options){return this.write(`Calls/${sid(callSid,"CA")}.json`,"POST",new URLSearchParams({TimeLimit:callLimit(seconds)}),options,callResult)}async getCall(callSid){return callResult(await this.request(`Calls/${sid(callSid,"CA")}.json`,"GET"))}async listCalls(filter={}){const query=new URLSearchParams({PageSize:"50"});if(filter.from)query.set("From",phone(filter.from));if(filter.to)query.set("To",phone(filter.to));return rows(await this.request("Calls.json","GET",query),"calls").map(callResult)}async redirectCall(callSid,input,options){return this.write(`Calls/${sid(callSid,"CA")}.json`,"POST",instructions(input),options,callResult)}async endCall(callSid,status,options){if(status!=="completed"&&status!=="canceled")invalid("End-call status must be completed or canceled.");return this.write(`Calls/${sid(callSid,"CA")}.json`,"POST",new URLSearchParams({Status:status}),options,callResult)}}
|
package/dist/twiml.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Small safe vocabulary for incoming-call responses. Raw TwiML passed to call APIs must already be trusted. */
|
|
2
|
+
export declare function voiceResponse(steps: readonly VoiceStep[]): string;
|
|
3
|
+
export type VoiceStep = { type: 'say', text: string }
|
|
4
|
+
| { type: 'hangup' }
|
|
5
|
+
| { type: 'reject', reason?: 'busy' | 'rejected' }
|
|
6
|
+
| { type: 'dial', number: string }
|
|
7
|
+
| { type: 'stream', url: string, parameters?: Record<string, string> }
|
package/dist/twiml.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{invalid,phone,secureUrl}from"./validation.js";function xml(value){if(typeof value!=="string"||value.length>4000)invalid("TwiML content must contain at most 4000 characters.");for(const character of value){const code=character.codePointAt(0);if(code<32&&code!==9&&code!==10&&code!==13||code>=55296&&code<=57343||code===65534||code===65535)invalid("TwiML contains an invalid XML character.")}return value.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'").replace(/\r/g," ").replace(/\n/g," ").replace(/\t/g,"	")}export function voiceResponse(steps){if(steps.length>50)invalid("TwiML has too many instructions.");const content=steps.map((step)=>{if(step.type==="say")return`<Say>${xml(step.text)}</Say>`;if(step.type==="hangup")return"<Hangup/>";if(step.type==="reject"){const reason=step.reason??"rejected";if(reason!=="busy"&&reason!=="rejected")invalid("Invalid rejection reason.");return`<Reject reason="${reason}"/>`}if(step.type==="dial")return`<Dial><Number>${phone(step.number)}</Number></Dial>`;if(step.type==="stream"){const url=secureUrl(step.url,"wss:");if(new URL(url).search)invalid("Media Stream URLs cannot contain query parameters.");const parameters=Object.entries(step.parameters??{});if(parameters.length>20)invalid("Too many Media Stream custom parameters.");const children=parameters.map(([name,value])=>{if(!name||name.length+value.length>=500)invalid("Stream parameter names and values must total fewer than 500 characters.");return`<Parameter name="${xml(name)}" value="${xml(value)}"/>`}).join("");return`<Connect><Stream url="${xml(url)}">${children}</Stream></Connect>`}return invalid("Unsupported TwiML instruction.")}).join("");const result=`<?xml version="1.0" encoding="UTF-8"?><Response>${content}</Response>`;if(result.length>4000)invalid("TwiML exceeds the 4000 character call API limit.");return result}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export declare interface AvailableNumber {
|
|
2
|
+
phoneNumber: string
|
|
3
|
+
country: PhoneCountry
|
|
4
|
+
locality?: string
|
|
5
|
+
region?: string
|
|
6
|
+
voice: boolean
|
|
7
|
+
sms: boolean
|
|
8
|
+
}
|
|
9
|
+
export declare interface PhoneNumber {
|
|
10
|
+
sid: string
|
|
11
|
+
phoneNumber: string
|
|
12
|
+
voiceUrl: string | null
|
|
13
|
+
smsUrl?: string | null
|
|
14
|
+
voiceMethod?: 'GET' | 'POST' | null
|
|
15
|
+
smsMethod?: 'GET' | 'POST' | null
|
|
16
|
+
voiceApplicationSid?: string | null
|
|
17
|
+
smsApplicationSid?: string | null
|
|
18
|
+
trunkSid?: string | null
|
|
19
|
+
}
|
|
20
|
+
export declare interface Call {
|
|
21
|
+
sid: string
|
|
22
|
+
from: string
|
|
23
|
+
to: string
|
|
24
|
+
status: CallStatus
|
|
25
|
+
direction: string
|
|
26
|
+
parentSid?: string | null
|
|
27
|
+
durationSeconds?: number | null
|
|
28
|
+
priceMicros?: number | null
|
|
29
|
+
priceCurrency?: string | null
|
|
30
|
+
timeLimitSeconds?: number | null
|
|
31
|
+
}
|
|
32
|
+
export declare interface NumberSearch {
|
|
33
|
+
country: PhoneCountry
|
|
34
|
+
areaCode?: string
|
|
35
|
+
limit?: number
|
|
36
|
+
}
|
|
37
|
+
export declare interface ProvisionNumber {
|
|
38
|
+
phoneNumber: string
|
|
39
|
+
voiceUrl: string
|
|
40
|
+
statusCallback?: string
|
|
41
|
+
bundleSid?: string
|
|
42
|
+
addressSid?: string
|
|
43
|
+
}
|
|
44
|
+
export declare interface WriteOptions {
|
|
45
|
+
operationKey: string
|
|
46
|
+
}
|
|
47
|
+
export declare interface Telephony {
|
|
48
|
+
searchNumbers: (input: NumberSearch) => Promise<AvailableNumber[]>
|
|
49
|
+
listNumbers: (phoneNumber?: string) => Promise<PhoneNumber[]>
|
|
50
|
+
provisionNumber: (input: ProvisionNumber, options: WriteOptions) => Promise<PhoneNumber>
|
|
51
|
+
releaseNumber: (sid: string, options: WriteOptions) => Promise<void>
|
|
52
|
+
configureNumber: (sid: string, voiceUrl: string, options: WriteOptions) => Promise<PhoneNumber>
|
|
53
|
+
configureSmsNumber: (sid: string, smsUrl: string, options: WriteOptions) => Promise<PhoneNumber>
|
|
54
|
+
createCall: (input: CreateCall, options: WriteOptions) => Promise<Call>
|
|
55
|
+
getCall: (sid: string) => Promise<Call>
|
|
56
|
+
setCallTimeLimit: (sid: string, seconds: number, options: WriteOptions) => Promise<Call>
|
|
57
|
+
listCalls: (filter?: { from?: string, to?: string }) => Promise<Call[]>
|
|
58
|
+
redirectCall: (sid: string, instructions: CallInstructions, options: WriteOptions) => Promise<Call>
|
|
59
|
+
endCall: (sid: string, status: 'completed' | 'canceled', options: WriteOptions) => Promise<Call>
|
|
60
|
+
}
|
|
61
|
+
export type PhoneCountry = 'US' | 'CA';
|
|
62
|
+
export type CallStatus = 'queued' | 'ringing' | 'in-progress' | 'completed' | 'busy' | 'failed' | 'no-answer' | 'canceled';
|
|
63
|
+
export type CallInstructions = { twiml: string, url?: never } | { url: string, twiml?: never }
|
|
64
|
+
export type CreateCall = CallInstructions & { from: string, to: string, statusCallback?: string, timeLimitSeconds?: number }
|
package/dist/types.js
ADDED
|
File without changes
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CallInstructions, CallStatus, NumberSearch } from './types';
|
|
2
|
+
export declare function invalid(message: string): never;
|
|
3
|
+
export declare function phone(value: string): string;
|
|
4
|
+
export declare function sid(value: string, prefix: 'AC' | 'CA' | 'PN' | 'MZ' | 'BU' | 'AD'): string;
|
|
5
|
+
export declare function secureUrl(value: string, protocol?: 'https:' | 'wss:'): string;
|
|
6
|
+
export declare function search(input: NumberSearch): number;
|
|
7
|
+
export declare function instructions(input: CallInstructions): URLSearchParams;
|
|
8
|
+
export declare const callStatuses: readonly CallStatus[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{TelephonyError}from"./errors.js";export function invalid(message){throw new TelephonyError("validation",message)}export function phone(value){if(typeof value!=="string"||!/^\+1[2-9]\d{2}[2-9]\d{6}$/.test(value))invalid("Expected a North American E.164 phone number.");return value}export function sid(value,prefix){if(typeof value!=="string"||!new RegExp(`^${prefix}[a-f0-9]{32}$`,"i").test(value))invalid("Invalid provider resource identifier.");return value}export function secureUrl(value,protocol="https:"){try{const url=new URL(value);if(url.protocol!==protocol||url.username||url.password||url.hash)invalid("Expected a secure public callback URL without credentials or fragment.");return value}catch{return invalid("Expected a secure public callback URL without credentials or fragment.")}}export function search(input){if(input.country!=="US"&&input.country!=="CA")invalid("Only US and Canadian number searches are supported.");if(input.areaCode!==undefined&&!/^[2-9]\d{2}$/.test(input.areaCode))invalid("Invalid area code.");const limit=input.limit??20;if(!Number.isInteger(limit)||limit<1||limit>50)invalid("Search limit must be between 1 and 50.");return limit}export function instructions(input){if(typeof input.twiml==="string"&&input.url===undefined&&input.twiml.length>0&&input.twiml.length<=4000)return new URLSearchParams({Twiml:input.twiml});if(typeof input.url==="string"&&input.twiml===undefined)return new URLSearchParams({Url:secureUrl(input.url),Method:"POST"});return invalid("Supply exactly one callback URL or 1 to 4000 characters of trusted TwiML.")}export const callStatuses=["queued","ringing","in-progress","completed","busy","failed","no-answer","canceled"];
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CallStatus } from './types';
|
|
2
|
+
/** Twilio's HMAC-SHA1 form signature contract; this does not prevent replay. */
|
|
3
|
+
export declare function verifyTwilioSignature(input: TwilioSignatureInput): boolean;
|
|
4
|
+
/** Verify before trusting fields. Bind the account here; bind CallSid to the application workspace before any action. */
|
|
5
|
+
export declare function parseVoiceWebhook(input: TwilioSignatureInput & { accountSid: string }): VoiceWebhook;
|
|
6
|
+
export declare interface TwilioSignatureInput {
|
|
7
|
+
url: string
|
|
8
|
+
fields: URLSearchParams
|
|
9
|
+
signature: string
|
|
10
|
+
authToken: string
|
|
11
|
+
}
|
|
12
|
+
export declare interface VoiceWebhook {
|
|
13
|
+
accountSid: string
|
|
14
|
+
callSid: string
|
|
15
|
+
from: string
|
|
16
|
+
to: string
|
|
17
|
+
status: CallStatus
|
|
18
|
+
direction?: string
|
|
19
|
+
sequenceNumber?: number
|
|
20
|
+
}
|
package/dist/webhooks.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{createHmac,timingSafeEqual}from"node:crypto";import{TelephonyError}from"./errors.js";import{callStatuses,sid}from"./validation.js";export function verifyTwilioSignature(input){if(!input.authToken||!/^https:\/\//.test(input.url)||!/^[A-Za-z0-9+/]{27}=$/.test(input.signature))return false;let payload=input.url;for(const key of[...new Set(input.fields.keys())].sort()){for(const value of[...new Set(input.fields.getAll(key))].sort())payload+=key+value}const expected=createHmac("sha1",input.authToken).update(payload).digest();const actual=Buffer.from(input.signature,"base64");return actual.length===expected.length&&timingSafeEqual(actual,expected)}export function parseVoiceWebhook(input){if(!verifyTwilioSignature(input)||input.fields.get("AccountSid")!==input.accountSid)throw new TelephonyError("invalid_signature","Voice callback authentication failed.");const one=(key,required=true)=>{const values=input.fields.getAll(key);if(values.length>1||required&&!values[0])throw new TelephonyError("validation","Voice callback fields are invalid.");return values[0]};const status=one("CallStatus");if(!callStatuses.includes(status))throw new TelephonyError("validation","Voice callback status is invalid.");const sequence=one("SequenceNumber",false);if(sequence!==undefined&&(!/^\d+$/.test(sequence)||!Number.isSafeInteger(Number(sequence))))throw new TelephonyError("validation","Voice callback sequence is invalid.");return{accountSid:sid(one("AccountSid"),"AC"),callSid:sid(one("CallSid"),"CA"),from:one("From"),to:one("To"),status,direction:one("Direction",false),sequenceNumber:sequence===undefined?undefined:Number(sequence)}}
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stacksjs/telephony",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"type": "module",
|
|
4
|
+
"sideEffects": false,
|
|
5
|
+
"version": "0.75.580",
|
|
6
|
+
"description": "Native telephony contracts, Twilio voice and number provisioning, and bounded Media Streams helpers.",
|
|
7
|
+
"author": "Chris Breuer <chris@stacksjs.com>",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/stacksjs/stacks.git",
|
|
12
|
+
"directory": "storage/framework/core/telephony"
|
|
13
|
+
},
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"import": "./dist/index.js",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./testing": {
|
|
21
|
+
"types": "./dist/testing.d.ts",
|
|
22
|
+
"import": "./dist/testing.js",
|
|
23
|
+
"default": "./dist/testing.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"module": "dist/index.js",
|
|
27
|
+
"types": "dist/index.d.ts",
|
|
28
|
+
"files": [
|
|
29
|
+
"README.md",
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "bun build.ts",
|
|
34
|
+
"test": "bun run build && bun test ./tests",
|
|
35
|
+
"typecheck": "bun tsc --noEmit",
|
|
36
|
+
"prepublishOnly": "bun run build"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"better-dx": "^0.2.39"
|
|
40
|
+
}
|
|
41
|
+
}
|