@framers/agentos 0.1.111 → 0.1.112
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/dist/voice/CallManager.d.ts.map +1 -1
- package/dist/voice/CallManager.js +9 -1
- package/dist/voice/CallManager.js.map +1 -1
- package/dist/voice/MediaStreamParser.d.ts +115 -6
- package/dist/voice/MediaStreamParser.d.ts.map +1 -1
- package/dist/voice/MediaStreamParser.js +44 -0
- package/dist/voice/MediaStreamParser.js.map +1 -1
- package/dist/voice/TelephonyStreamTransport.d.ts +112 -20
- package/dist/voice/TelephonyStreamTransport.d.ts.map +1 -1
- package/dist/voice/TelephonyStreamTransport.js +136 -30
- package/dist/voice/TelephonyStreamTransport.js.map +1 -1
- package/dist/voice/parsers/PlivoMediaStreamParser.d.ts +64 -6
- package/dist/voice/parsers/PlivoMediaStreamParser.d.ts.map +1 -1
- package/dist/voice/parsers/PlivoMediaStreamParser.js +67 -6
- package/dist/voice/parsers/PlivoMediaStreamParser.js.map +1 -1
- package/dist/voice/parsers/TelnyxMediaStreamParser.d.ts +55 -8
- package/dist/voice/parsers/TelnyxMediaStreamParser.d.ts.map +1 -1
- package/dist/voice/parsers/TelnyxMediaStreamParser.js +60 -9
- package/dist/voice/parsers/TelnyxMediaStreamParser.js.map +1 -1
- package/dist/voice/parsers/TwilioMediaStreamParser.d.ts +73 -11
- package/dist/voice/parsers/TwilioMediaStreamParser.d.ts.map +1 -1
- package/dist/voice/parsers/TwilioMediaStreamParser.js +81 -12
- package/dist/voice/parsers/TwilioMediaStreamParser.js.map +1 -1
- package/dist/voice/providers/plivo.d.ts +108 -12
- package/dist/voice/providers/plivo.d.ts.map +1 -1
- package/dist/voice/providers/plivo.js +106 -9
- package/dist/voice/providers/plivo.js.map +1 -1
- package/dist/voice/providers/telnyx.d.ts +110 -20
- package/dist/voice/providers/telnyx.d.ts.map +1 -1
- package/dist/voice/providers/telnyx.js +111 -20
- package/dist/voice/providers/telnyx.js.map +1 -1
- package/dist/voice/providers/twilio.d.ts +91 -13
- package/dist/voice/providers/twilio.d.ts.map +1 -1
- package/dist/voice/providers/twilio.js +94 -14
- package/dist/voice/providers/twilio.js.map +1 -1
- package/dist/voice/twiml.d.ts +70 -12
- package/dist/voice/twiml.d.ts.map +1 -1
- package/dist/voice/twiml.js +70 -12
- package/dist/voice/twiml.js.map +1 -1
- package/dist/voice/types.d.ts +142 -15
- package/dist/voice/types.d.ts.map +1 -1
- package/dist/voice/types.js +34 -3
- package/dist/voice/types.js.map +1 -1
- package/package.json +1 -1
|
@@ -2,7 +2,63 @@
|
|
|
2
2
|
* @fileoverview Plivo telephony provider for AgentOS voice calls.
|
|
3
3
|
*
|
|
4
4
|
* Implements {@link IVoiceCallProvider} using the Plivo Voice REST API v1.
|
|
5
|
-
*
|
|
5
|
+
*
|
|
6
|
+
* ## REST API contract
|
|
7
|
+
*
|
|
8
|
+
* | Operation | Method | Endpoint | Body format |
|
|
9
|
+
* |----------------|--------|----------------------------------------------|-------------|
|
|
10
|
+
* | Initiate call | POST | `/v1/Account/{authId}/Call/` | JSON |
|
|
11
|
+
* | Hangup call | DELETE | `/v1/Account/{authId}/Call/{callUuid}/` | (none) |
|
|
12
|
+
* | Play TTS | POST | `/v1/Account/{authId}/Call/{callUuid}/Speak/` | JSON |
|
|
13
|
+
*
|
|
14
|
+
* All requests use HTTP Basic authentication: `Authorization: Basic base64(authId:authToken)`.
|
|
15
|
+
*
|
|
16
|
+
* ### Hangup uses DELETE (not POST)
|
|
17
|
+
*
|
|
18
|
+
* Unlike Twilio (which POSTs `Status=completed`) and Telnyx (which POSTs to
|
|
19
|
+
* an `/actions/hangup` endpoint), Plivo uses the HTTP `DELETE` method on the
|
|
20
|
+
* Call resource to terminate an active call. This is a RESTful design choice
|
|
21
|
+
* where "deleting" the call resource means terminating the call.
|
|
22
|
+
*
|
|
23
|
+
* ## Webhook verification: HMAC-SHA256 + nonce (v3 scheme)
|
|
24
|
+
*
|
|
25
|
+
* Plivo's v3 webhook signature scheme:
|
|
26
|
+
*
|
|
27
|
+
* 1. Plivo generates a random `nonce` and includes it in the
|
|
28
|
+
* `X-Plivo-Signature-V3-Nonce` header.
|
|
29
|
+
* 2. The signed data is: `{fullRequestURL}{nonce}` (concatenated, no separator).
|
|
30
|
+
* 3. Compute `HMAC-SHA256(authToken, signedData)`.
|
|
31
|
+
* 4. Base64-encode the HMAC digest.
|
|
32
|
+
* 5. Compare the result with the `X-Plivo-Signature-V3` header.
|
|
33
|
+
*
|
|
34
|
+
* Note: Unlike Twilio's scheme, Plivo does NOT include the POST body in the
|
|
35
|
+
* signed data -- only the URL and nonce. The nonce prevents replay attacks.
|
|
36
|
+
*
|
|
37
|
+
* ## DTMF via `<GetDigits>` XML pattern
|
|
38
|
+
*
|
|
39
|
+
* Plivo delivers DTMF input through the `<GetDigits>` XML element callback,
|
|
40
|
+
* not through the media stream WebSocket. When a call executes:
|
|
41
|
+
*
|
|
42
|
+
* ```xml
|
|
43
|
+
* <GetDigits action="https://example.com/dtmf" method="POST" timeout="10">
|
|
44
|
+
* <Speak>Press 1 to confirm.</Speak>
|
|
45
|
+
* </GetDigits>
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* Plivo POSTs the pressed digits to the `action` URL with a `Digits`
|
|
49
|
+
* parameter in the form-encoded body (e.g., `Digits=1&CallUUID=xxx`).
|
|
50
|
+
*
|
|
51
|
+
* ## Event mapping table
|
|
52
|
+
*
|
|
53
|
+
* | Plivo `CallStatus` | Normalised `kind` |
|
|
54
|
+
* |---------------------|----------------------|
|
|
55
|
+
* | `ringing` | `call-ringing` |
|
|
56
|
+
* | `in-progress` | `call-answered` |
|
|
57
|
+
* | `completed` | `call-completed` |
|
|
58
|
+
* | `busy` | `call-busy` |
|
|
59
|
+
* | `no-answer` | `call-no-answer` |
|
|
60
|
+
* | `failed` | `call-failed` |
|
|
61
|
+
* | (+ `Digits` param) | `call-dtmf` |
|
|
6
62
|
*
|
|
7
63
|
* @module @framers/agentos/voice/providers/plivo
|
|
8
64
|
*/
|
|
@@ -25,10 +81,15 @@ import { createHmac, randomUUID } from 'node:crypto';
|
|
|
25
81
|
* ```
|
|
26
82
|
*/
|
|
27
83
|
export class PlivoVoiceProvider {
|
|
84
|
+
/**
|
|
85
|
+
* @param config - Plivo credentials and optional overrides.
|
|
86
|
+
*/
|
|
28
87
|
constructor(config) {
|
|
88
|
+
/** Provider identifier, always `'plivo'`. */
|
|
29
89
|
this.name = 'plivo';
|
|
30
90
|
this.config = config;
|
|
31
91
|
this.baseUrl = 'https://api.plivo.com/v1';
|
|
92
|
+
// Plivo uses HTTP Basic auth with authId:authToken (similar to Twilio).
|
|
32
93
|
this.authHeader =
|
|
33
94
|
'Basic ' + Buffer.from(`${config.authId}:${config.authToken}`).toString('base64');
|
|
34
95
|
this.fetch = config.fetchImpl ?? globalThis.fetch;
|
|
@@ -37,9 +98,17 @@ export class PlivoVoiceProvider {
|
|
|
37
98
|
/**
|
|
38
99
|
* Verify an incoming Plivo webhook request using HMAC-SHA256 (v3 scheme).
|
|
39
100
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* the `
|
|
101
|
+
* ## Algorithm (step by step)
|
|
102
|
+
*
|
|
103
|
+
* 1. Extract the `X-Plivo-Signature-V3-Nonce` and `X-Plivo-Signature-V3` headers.
|
|
104
|
+
* 2. Build the signed data string: `{fullRequestURL}{nonce}`.
|
|
105
|
+
* Note: the POST body is NOT included in the signed data (unlike Twilio).
|
|
106
|
+
* 3. Compute `HMAC-SHA256(authToken, signedData)`.
|
|
107
|
+
* 4. Base64-encode the digest.
|
|
108
|
+
* 5. Compare with the `X-Plivo-Signature-V3` header.
|
|
109
|
+
*
|
|
110
|
+
* @param ctx - Raw webhook request context.
|
|
111
|
+
* @returns Verification result with `valid: true` if the signature matches.
|
|
43
112
|
*/
|
|
44
113
|
verifyWebhook(ctx) {
|
|
45
114
|
const nonce = ctx.headers['x-plivo-signature-v3-nonce'];
|
|
@@ -47,6 +116,8 @@ export class PlivoVoiceProvider {
|
|
|
47
116
|
if (Array.isArray(nonce) || Array.isArray(signature)) {
|
|
48
117
|
return { valid: false, error: 'Duplicate Plivo signature headers' };
|
|
49
118
|
}
|
|
119
|
+
// Use empty string as nonce fallback when header is missing --
|
|
120
|
+
// the HMAC will still compute but won't match the expected signature.
|
|
50
121
|
const nonceStr = nonce ?? '';
|
|
51
122
|
const data = ctx.url + nonceStr;
|
|
52
123
|
const expected = createHmac('sha256', this.config.authToken)
|
|
@@ -58,8 +129,17 @@ export class PlivoVoiceProvider {
|
|
|
58
129
|
/**
|
|
59
130
|
* Parse a Plivo webhook body into normalized {@link NormalizedCallEvent}s.
|
|
60
131
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
132
|
+
* Plivo sends most webhooks with URL-encoded bodies, but some callbacks
|
|
133
|
+
* may arrive as JSON. This parser handles both formats by inspecting
|
|
134
|
+
* whether the body starts with `{` (JSON) or not (form-encoded).
|
|
135
|
+
*
|
|
136
|
+
* Plivo uses two naming conventions for the same fields:
|
|
137
|
+
* - PascalCase (`CallUUID`, `CallStatus`, `Digits`) in URL callbacks.
|
|
138
|
+
* - snake_case (`call_uuid`, `call_status`) in some API responses.
|
|
139
|
+
* Both are checked for maximum compatibility.
|
|
140
|
+
*
|
|
141
|
+
* @param ctx - Raw webhook request context.
|
|
142
|
+
* @returns Parsed result containing zero or more normalized events.
|
|
63
143
|
*/
|
|
64
144
|
parseWebhookEvent(ctx) {
|
|
65
145
|
const body = ctx.body.toString();
|
|
@@ -77,16 +157,19 @@ export class PlivoVoiceProvider {
|
|
|
77
157
|
catch {
|
|
78
158
|
params = new URLSearchParams(body);
|
|
79
159
|
}
|
|
160
|
+
// Support both PascalCase and snake_case field naming conventions.
|
|
80
161
|
const callUuid = params.get('CallUUID') ?? params.get('call_uuid') ?? '';
|
|
81
162
|
const callStatus = params.get('CallStatus') ?? params.get('call_status') ?? '';
|
|
82
163
|
const digits = params.get('Digits');
|
|
83
164
|
const timestamp = Date.now();
|
|
84
165
|
const events = [];
|
|
166
|
+
/** Helper: shared base fields with a unique event ID for idempotency. */
|
|
85
167
|
const base = () => ({
|
|
86
168
|
eventId: randomUUID(),
|
|
87
169
|
providerCallId: callUuid,
|
|
88
170
|
timestamp,
|
|
89
171
|
});
|
|
172
|
+
// Map Plivo CallStatus values to normalized event kinds.
|
|
90
173
|
switch (callStatus) {
|
|
91
174
|
case 'ringing':
|
|
92
175
|
events.push({ ...base(), kind: 'call-ringing' });
|
|
@@ -107,9 +190,10 @@ export class PlivoVoiceProvider {
|
|
|
107
190
|
events.push({ ...base(), kind: 'call-failed' });
|
|
108
191
|
break;
|
|
109
192
|
default:
|
|
110
|
-
// initiated / queued / etc.
|
|
193
|
+
// initiated / queued / etc. -- no normalized event emitted.
|
|
111
194
|
break;
|
|
112
195
|
}
|
|
196
|
+
// DTMF digit input (from <GetDigits> XML element callback).
|
|
113
197
|
if (digits != null && digits !== '') {
|
|
114
198
|
events.push({ ...base(), kind: 'call-dtmf', digit: digits });
|
|
115
199
|
}
|
|
@@ -119,8 +203,12 @@ export class PlivoVoiceProvider {
|
|
|
119
203
|
/**
|
|
120
204
|
* Initiate an outbound call via the Plivo Call API.
|
|
121
205
|
*
|
|
122
|
-
* POSTs a JSON body to `/Account/{authId}/Call/` with the caller, callee,
|
|
206
|
+
* POSTs a JSON body to `/v1/Account/{authId}/Call/` with the caller, callee,
|
|
123
207
|
* and answer URL. Returns the `request_uuid` as the provider call ID.
|
|
208
|
+
*
|
|
209
|
+
* @param input - Call initiation parameters (from/to numbers, webhook URL).
|
|
210
|
+
* @returns Result containing the Plivo `request_uuid` on success.
|
|
211
|
+
* @throws Never throws; returns `{ success: false, error: '...' }` on failure.
|
|
124
212
|
*/
|
|
125
213
|
async initiateCall(input) {
|
|
126
214
|
const url = `${this.baseUrl}/Account/${this.config.authId}/Call/`;
|
|
@@ -146,6 +234,12 @@ export class PlivoVoiceProvider {
|
|
|
146
234
|
}
|
|
147
235
|
/**
|
|
148
236
|
* Hang up an active call using the Plivo Call DELETE endpoint.
|
|
237
|
+
*
|
|
238
|
+
* Plivo uses HTTP `DELETE` to terminate a call (unlike Twilio's POST with
|
|
239
|
+
* `Status=completed` or Telnyx's POST to `/actions/hangup`). This is a
|
|
240
|
+
* RESTful convention where deleting the call resource ends the call.
|
|
241
|
+
*
|
|
242
|
+
* @param input - Contains the Plivo `call_uuid` to hang up.
|
|
149
243
|
*/
|
|
150
244
|
async hangupCall(input) {
|
|
151
245
|
const url = `${this.baseUrl}/Account/${this.config.authId}/Call/${input.providerCallId}/`;
|
|
@@ -159,7 +253,10 @@ export class PlivoVoiceProvider {
|
|
|
159
253
|
/**
|
|
160
254
|
* Speak text into a live call using the Plivo Speak API.
|
|
161
255
|
*
|
|
162
|
-
*
|
|
256
|
+
* POSTs a JSON body to `/v1/Account/{authId}/Call/{callUuid}/Speak/`
|
|
257
|
+
* with the text, voice (default `'WOMAN'`), and language (default `'en-US'`).
|
|
258
|
+
*
|
|
259
|
+
* @param input - TTS parameters (text, optional voice, call ID).
|
|
163
260
|
*/
|
|
164
261
|
async playTts(input) {
|
|
165
262
|
const url = `${this.baseUrl}/Account/${this.config.authId}/Call/${input.providerCallId}/Speak/`;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plivo.js","sourceRoot":"","sources":["../../../src/voice/providers/plivo.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"plivo.js","sourceRoot":"","sources":["../../../src/voice/providers/plivo.ts"],"names":[],"mappings":"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"}
|
|
@@ -2,14 +2,64 @@
|
|
|
2
2
|
* @fileoverview Telnyx telephony provider for AgentOS voice calls.
|
|
3
3
|
*
|
|
4
4
|
* Implements {@link IVoiceCallProvider} using the Telnyx Call Control v2 API.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
*
|
|
6
|
+
* ## REST API contract
|
|
7
|
+
*
|
|
8
|
+
* | Operation | Method | Endpoint | Body format |
|
|
9
|
+
* |----------------|--------|------------------------------------------|-------------|
|
|
10
|
+
* | Initiate call | POST | `/v2/calls` | JSON |
|
|
11
|
+
* | Hangup call | POST | `/v2/calls/{id}/actions/hangup` | JSON |
|
|
12
|
+
* | Play TTS | POST | `/v2/calls/{id}/actions/speak` | JSON |
|
|
13
|
+
* | Start stream | POST | `/v2/calls/{id}/actions/streaming_start` | JSON |
|
|
14
|
+
*
|
|
15
|
+
* All requests use Bearer token authentication: `Authorization: Bearer {apiKey}`.
|
|
16
|
+
* Request bodies are JSON (unlike Twilio's form-encoded convention).
|
|
17
|
+
*
|
|
18
|
+
* ## Streaming after `call.answered`
|
|
19
|
+
*
|
|
20
|
+
* Telnyx requires a two-step flow for media streaming:
|
|
21
|
+
* 1. Initiate the call via `POST /v2/calls` with a `webhook_url`.
|
|
22
|
+
* 2. When the `call.answered` webhook fires, issue a separate
|
|
23
|
+
* `POST /v2/calls/{id}/actions/streaming_start` request with the
|
|
24
|
+
* WebSocket URL. This is handled by the CallManager, not by this provider.
|
|
25
|
+
*
|
|
26
|
+
* ## Webhook verification: Ed25519
|
|
27
|
+
*
|
|
28
|
+
* Telnyx signs webhooks using Ed25519 public key cryptography:
|
|
29
|
+
*
|
|
30
|
+
* 1. Telnyx generates a signed payload: `{timestamp}|{rawBody}`.
|
|
31
|
+
* 2. The signature is computed with the account's Ed25519 private key.
|
|
32
|
+
* 3. The public key is provided as a base64-encoded DER SPKI blob.
|
|
33
|
+
* 4. Headers: `X-Telnyx-Timestamp` (the timestamp) and
|
|
34
|
+
* `X-Telnyx-Signature-Ed25519` (base64-encoded Ed25519 signature).
|
|
35
|
+
* 5. Verification: decode the signature from base64, construct the payload
|
|
36
|
+
* string `{timestamp}|{body}`, and verify using `crypto.verify()` with
|
|
37
|
+
* the SPKI public key.
|
|
38
|
+
*
|
|
39
|
+
* When no public key is configured, verification is skipped (returns
|
|
40
|
+
* `valid: true`) to support development environments.
|
|
41
|
+
*
|
|
42
|
+
* ## Event mapping table (hangup_cause)
|
|
43
|
+
*
|
|
44
|
+
* | Telnyx `event_type` | `hangup_cause` | Normalised `kind` |
|
|
45
|
+
* |----------------------------------|---------------------------|----------------------|
|
|
46
|
+
* | `call.initiated` | -- | `call-ringing` |
|
|
47
|
+
* | `call.answered` | -- | `call-answered` |
|
|
48
|
+
* | `call.hangup` | `normal_clearing` | `call-hangup-user` |
|
|
49
|
+
* | `call.hangup` | `user_busy` | `call-hangup-user` |
|
|
50
|
+
* | `call.hangup` | `originator_cancel` | `call-hangup-user` |
|
|
51
|
+
* | `call.hangup` | (anything else) | `call-completed` |
|
|
52
|
+
* | `call.dtmf.received` | -- | `call-dtmf` |
|
|
53
|
+
* | `call.machine.detection.ended` | result=`machine` | `call-voicemail` |
|
|
54
|
+
* | `call.machine.detection.ended` | result=`human` | (no event) |
|
|
7
55
|
*
|
|
8
56
|
* @module @framers/agentos/voice/providers/telnyx
|
|
9
57
|
*/
|
|
10
58
|
import type { IVoiceCallProvider, InitiateCallInput, InitiateCallResult, HangupCallInput, PlayTtsInput } from '../IVoiceCallProvider.js';
|
|
11
59
|
import type { WebhookContext, WebhookVerificationResult, WebhookParseResult } from '../types.js';
|
|
12
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Configuration for {@link TelnyxVoiceProvider}.
|
|
62
|
+
*/
|
|
13
63
|
export interface TelnyxVoiceProviderConfig {
|
|
14
64
|
/** Telnyx API key (starts with "KEY"). */
|
|
15
65
|
apiKey: string;
|
|
@@ -17,11 +67,13 @@ export interface TelnyxVoiceProviderConfig {
|
|
|
17
67
|
connectionId: string;
|
|
18
68
|
/**
|
|
19
69
|
* Base64-encoded DER-encoded SPKI Ed25519 public key for webhook verification.
|
|
20
|
-
*
|
|
70
|
+
*
|
|
71
|
+
* When omitted, webhook verification is skipped (always returns `valid: true`).
|
|
72
|
+
* This is acceptable for development but should always be set in production.
|
|
21
73
|
*/
|
|
22
74
|
publicKey?: string;
|
|
23
75
|
/**
|
|
24
|
-
* Optional fetch override
|
|
76
|
+
* Optional fetch implementation override -- inject a mock in tests.
|
|
25
77
|
* Defaults to the global `fetch`.
|
|
26
78
|
*/
|
|
27
79
|
fetchImpl?: typeof fetch;
|
|
@@ -43,50 +95,88 @@ export interface TelnyxVoiceProviderConfig {
|
|
|
43
95
|
* ```
|
|
44
96
|
*/
|
|
45
97
|
export declare class TelnyxVoiceProvider implements IVoiceCallProvider {
|
|
98
|
+
/** Provider identifier, always `'telnyx'`. */
|
|
46
99
|
readonly name: "telnyx";
|
|
100
|
+
/** Immutable configuration snapshot. */
|
|
47
101
|
private readonly config;
|
|
102
|
+
/** Base URL for the Telnyx v2 API. */
|
|
48
103
|
private readonly baseUrl;
|
|
104
|
+
/** Pre-computed `Authorization: Bearer ...` header value. */
|
|
49
105
|
private readonly authHeader;
|
|
106
|
+
/** HTTP fetch implementation (injectable for testing). */
|
|
50
107
|
private readonly fetch;
|
|
108
|
+
/**
|
|
109
|
+
* @param config - Telnyx credentials and optional overrides.
|
|
110
|
+
*/
|
|
51
111
|
constructor(config: TelnyxVoiceProviderConfig);
|
|
52
112
|
/**
|
|
53
113
|
* Verify an incoming Telnyx webhook using Ed25519 signature verification.
|
|
54
114
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
115
|
+
* ## Algorithm (step by step)
|
|
116
|
+
*
|
|
117
|
+
* 1. If no public key is configured, skip verification (return `valid: true`).
|
|
118
|
+
* This supports development environments without cryptographic setup.
|
|
119
|
+
* 2. Extract `X-Telnyx-Timestamp` and `X-Telnyx-Signature-Ed25519` headers.
|
|
120
|
+
* 3. Decode the signature from base64 into a raw byte Buffer.
|
|
121
|
+
* 4. Construct the signed payload: `"{timestamp}|{rawBody}"`.
|
|
122
|
+
* 5. Decode the SPKI public key from base64.
|
|
123
|
+
* 6. Call `crypto.verify(null, payload, { key, format: 'der', type: 'spki' }, signature)`.
|
|
124
|
+
* 7. Return the verification result.
|
|
57
125
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
126
|
+
* @param ctx - Raw webhook request context.
|
|
127
|
+
* @returns Verification result. Returns `{ valid: true }` when no public key
|
|
128
|
+
* is configured (development mode).
|
|
61
129
|
*/
|
|
62
130
|
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult;
|
|
63
131
|
/**
|
|
64
132
|
* Parse a Telnyx webhook JSON body into normalized {@link NormalizedCallEvent}s.
|
|
65
133
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
134
|
+
* Telnyx sends all webhook payloads as JSON with a `data.event_type`
|
|
135
|
+
* discriminant field. The `data.payload` object contains call-specific
|
|
136
|
+
* fields like `call_control_id`, `hangup_cause`, `digit`, and `result`.
|
|
137
|
+
*
|
|
138
|
+
* ## Hangup cause mapping
|
|
139
|
+
*
|
|
140
|
+
* Telnyx's `call.hangup` event includes a `hangup_cause` field that must
|
|
141
|
+
* be inspected to determine whether the user or the system terminated
|
|
142
|
+
* the call:
|
|
143
|
+
* - `normal_clearing` / `user_busy` / `originator_cancel` -> `call-hangup-user`
|
|
144
|
+
* - All other causes (e.g., `call_rejected`, `unallocated_number`) -> `call-completed`
|
|
145
|
+
*
|
|
146
|
+
* @param ctx - Raw webhook request context.
|
|
147
|
+
* @returns Parsed result containing zero or more normalized events.
|
|
72
148
|
*/
|
|
73
149
|
parseWebhookEvent(ctx: WebhookContext): WebhookParseResult;
|
|
74
150
|
/**
|
|
75
151
|
* Initiate an outbound call via the Telnyx Call Control v2 API.
|
|
76
152
|
*
|
|
77
|
-
* POSTs to `/calls` with a JSON body
|
|
78
|
-
*
|
|
79
|
-
*
|
|
153
|
+
* POSTs to `/v2/calls` with a JSON body containing the `connection_id`,
|
|
154
|
+
* phone numbers, and webhook URL. The `mediaStreamUrl` (if provided) is
|
|
155
|
+
* stored internally for use after the call is answered -- it is NOT sent
|
|
156
|
+
* in the initial call creation request because Telnyx requires
|
|
157
|
+
* `streaming_start` to be issued as a separate action after `call.answered`.
|
|
158
|
+
*
|
|
159
|
+
* @param input - Call initiation parameters (from/to numbers, webhook URL).
|
|
160
|
+
* @returns Result containing the Telnyx `call_control_id` on success.
|
|
161
|
+
* @throws Never throws; returns `{ success: false, error: '...' }` on failure.
|
|
80
162
|
*/
|
|
81
163
|
initiateCall(input: InitiateCallInput): Promise<InitiateCallResult>;
|
|
82
164
|
/**
|
|
83
165
|
* Hang up an active call via the Telnyx Call Control hangup action.
|
|
166
|
+
*
|
|
167
|
+
* POSTs an empty JSON body to `/v2/calls/{call_control_id}/actions/hangup`.
|
|
168
|
+
* Telnyx will terminate the call and fire a `call.hangup` webhook.
|
|
169
|
+
*
|
|
170
|
+
* @param input - Contains the Telnyx `call_control_id` to hang up.
|
|
84
171
|
*/
|
|
85
172
|
hangupCall(input: HangupCallInput): Promise<void>;
|
|
86
173
|
/**
|
|
87
174
|
* Speak text into a live call using Telnyx's text-to-speech speak action.
|
|
88
175
|
*
|
|
89
|
-
*
|
|
176
|
+
* POSTs a JSON body to `/v2/calls/{id}/actions/speak` with the text
|
|
177
|
+
* `payload`, `voice` (default `'female'`), and `language` (default `'en-US'`).
|
|
178
|
+
*
|
|
179
|
+
* @param input - TTS parameters (text, optional voice, call ID).
|
|
90
180
|
*/
|
|
91
181
|
playTts(input: PlayTtsInput): Promise<void>;
|
|
92
182
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"telnyx.d.ts","sourceRoot":"","sources":["../../../src/voice/providers/telnyx.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"telnyx.d.ts","sourceRoot":"","sources":["../../../src/voice/providers/telnyx.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AAKH,OAAO,KAAK,EACV,kBAAkB,EAClB,iBAAiB,EACjB,kBAAkB,EAClB,eAAe,EACf,YAAY,EACb,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EACV,cAAc,EACd,yBAAyB,EACzB,kBAAkB,EAEnB,MAAM,aAAa,CAAC;AAMrB;;GAEG;AACH,MAAM,WAAW,yBAAyB;IACxC,0CAA0C;IAC1C,MAAM,EAAE,MAAM,CAAC;IACf,yDAAyD;IACzD,YAAY,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAiCD;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,mBAAoB,YAAW,kBAAkB;IAC5D,8CAA8C;IAC9C,QAAQ,CAAC,IAAI,EAAG,QAAQ,CAAU;IAElC,wCAAwC;IACxC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA4B;IAEnD,sCAAsC;IACtC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IAEjC,6DAA6D;IAC7D,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IAEpC,0DAA0D;IAC1D,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAe;IAErC;;OAEG;gBACS,MAAM,EAAE,yBAAyB;IAS7C;;;;;;;;;;;;;;;;;OAiBG;IACH,aAAa,CAAC,GAAG,EAAE,cAAc,GAAG,yBAAyB;IAoC7D;;;;;;;;;;;;;;;;;OAiBG;IACH,iBAAiB,CAAC,GAAG,EAAE,cAAc,GAAG,kBAAkB;IA6D1D;;;;;;;;;;;;OAYG;IACG,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,kBAAkB,CAAC;IA0BzE;;;;;;;OAOG;IACG,UAAU,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IAavD;;;;;;;OAOG;IACG,OAAO,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;CAgBlD"}
|
|
@@ -2,8 +2,56 @@
|
|
|
2
2
|
* @fileoverview Telnyx telephony provider for AgentOS voice calls.
|
|
3
3
|
*
|
|
4
4
|
* Implements {@link IVoiceCallProvider} using the Telnyx Call Control v2 API.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
*
|
|
6
|
+
* ## REST API contract
|
|
7
|
+
*
|
|
8
|
+
* | Operation | Method | Endpoint | Body format |
|
|
9
|
+
* |----------------|--------|------------------------------------------|-------------|
|
|
10
|
+
* | Initiate call | POST | `/v2/calls` | JSON |
|
|
11
|
+
* | Hangup call | POST | `/v2/calls/{id}/actions/hangup` | JSON |
|
|
12
|
+
* | Play TTS | POST | `/v2/calls/{id}/actions/speak` | JSON |
|
|
13
|
+
* | Start stream | POST | `/v2/calls/{id}/actions/streaming_start` | JSON |
|
|
14
|
+
*
|
|
15
|
+
* All requests use Bearer token authentication: `Authorization: Bearer {apiKey}`.
|
|
16
|
+
* Request bodies are JSON (unlike Twilio's form-encoded convention).
|
|
17
|
+
*
|
|
18
|
+
* ## Streaming after `call.answered`
|
|
19
|
+
*
|
|
20
|
+
* Telnyx requires a two-step flow for media streaming:
|
|
21
|
+
* 1. Initiate the call via `POST /v2/calls` with a `webhook_url`.
|
|
22
|
+
* 2. When the `call.answered` webhook fires, issue a separate
|
|
23
|
+
* `POST /v2/calls/{id}/actions/streaming_start` request with the
|
|
24
|
+
* WebSocket URL. This is handled by the CallManager, not by this provider.
|
|
25
|
+
*
|
|
26
|
+
* ## Webhook verification: Ed25519
|
|
27
|
+
*
|
|
28
|
+
* Telnyx signs webhooks using Ed25519 public key cryptography:
|
|
29
|
+
*
|
|
30
|
+
* 1. Telnyx generates a signed payload: `{timestamp}|{rawBody}`.
|
|
31
|
+
* 2. The signature is computed with the account's Ed25519 private key.
|
|
32
|
+
* 3. The public key is provided as a base64-encoded DER SPKI blob.
|
|
33
|
+
* 4. Headers: `X-Telnyx-Timestamp` (the timestamp) and
|
|
34
|
+
* `X-Telnyx-Signature-Ed25519` (base64-encoded Ed25519 signature).
|
|
35
|
+
* 5. Verification: decode the signature from base64, construct the payload
|
|
36
|
+
* string `{timestamp}|{body}`, and verify using `crypto.verify()` with
|
|
37
|
+
* the SPKI public key.
|
|
38
|
+
*
|
|
39
|
+
* When no public key is configured, verification is skipped (returns
|
|
40
|
+
* `valid: true`) to support development environments.
|
|
41
|
+
*
|
|
42
|
+
* ## Event mapping table (hangup_cause)
|
|
43
|
+
*
|
|
44
|
+
* | Telnyx `event_type` | `hangup_cause` | Normalised `kind` |
|
|
45
|
+
* |----------------------------------|---------------------------|----------------------|
|
|
46
|
+
* | `call.initiated` | -- | `call-ringing` |
|
|
47
|
+
* | `call.answered` | -- | `call-answered` |
|
|
48
|
+
* | `call.hangup` | `normal_clearing` | `call-hangup-user` |
|
|
49
|
+
* | `call.hangup` | `user_busy` | `call-hangup-user` |
|
|
50
|
+
* | `call.hangup` | `originator_cancel` | `call-hangup-user` |
|
|
51
|
+
* | `call.hangup` | (anything else) | `call-completed` |
|
|
52
|
+
* | `call.dtmf.received` | -- | `call-dtmf` |
|
|
53
|
+
* | `call.machine.detection.ended` | result=`machine` | `call-voicemail` |
|
|
54
|
+
* | `call.machine.detection.ended` | result=`human` | (no event) |
|
|
7
55
|
*
|
|
8
56
|
* @module @framers/agentos/voice/providers/telnyx
|
|
9
57
|
*/
|
|
@@ -29,7 +77,11 @@ import { randomUUID } from 'node:crypto';
|
|
|
29
77
|
* ```
|
|
30
78
|
*/
|
|
31
79
|
export class TelnyxVoiceProvider {
|
|
80
|
+
/**
|
|
81
|
+
* @param config - Telnyx credentials and optional overrides.
|
|
82
|
+
*/
|
|
32
83
|
constructor(config) {
|
|
84
|
+
/** Provider identifier, always `'telnyx'`. */
|
|
33
85
|
this.name = 'telnyx';
|
|
34
86
|
this.config = config;
|
|
35
87
|
this.baseUrl = 'https://api.telnyx.com/v2';
|
|
@@ -40,14 +92,24 @@ export class TelnyxVoiceProvider {
|
|
|
40
92
|
/**
|
|
41
93
|
* Verify an incoming Telnyx webhook using Ed25519 signature verification.
|
|
42
94
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
95
|
+
* ## Algorithm (step by step)
|
|
96
|
+
*
|
|
97
|
+
* 1. If no public key is configured, skip verification (return `valid: true`).
|
|
98
|
+
* This supports development environments without cryptographic setup.
|
|
99
|
+
* 2. Extract `X-Telnyx-Timestamp` and `X-Telnyx-Signature-Ed25519` headers.
|
|
100
|
+
* 3. Decode the signature from base64 into a raw byte Buffer.
|
|
101
|
+
* 4. Construct the signed payload: `"{timestamp}|{rawBody}"`.
|
|
102
|
+
* 5. Decode the SPKI public key from base64.
|
|
103
|
+
* 6. Call `crypto.verify(null, payload, { key, format: 'der', type: 'spki' }, signature)`.
|
|
104
|
+
* 7. Return the verification result.
|
|
45
105
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
106
|
+
* @param ctx - Raw webhook request context.
|
|
107
|
+
* @returns Verification result. Returns `{ valid: true }` when no public key
|
|
108
|
+
* is configured (development mode).
|
|
49
109
|
*/
|
|
50
110
|
verifyWebhook(ctx) {
|
|
111
|
+
// Skip verification when no public key is configured -- allows
|
|
112
|
+
// development without needing to set up Ed25519 key pairs.
|
|
51
113
|
if (!this.config.publicKey) {
|
|
52
114
|
return { valid: true };
|
|
53
115
|
}
|
|
@@ -57,9 +119,11 @@ export class TelnyxVoiceProvider {
|
|
|
57
119
|
return { valid: false, error: 'Missing Telnyx signature headers' };
|
|
58
120
|
}
|
|
59
121
|
const signature = Buffer.from(sigHeader, 'base64');
|
|
122
|
+
// Telnyx signs the concatenation of timestamp, pipe separator, and raw body.
|
|
60
123
|
const payload = Buffer.from(`${timestamp}|${ctx.body.toString()}`);
|
|
61
124
|
try {
|
|
62
|
-
const valid = verify(null,
|
|
125
|
+
const valid = verify(null, // Ed25519 does not use a separate hash algorithm parameter.
|
|
126
|
+
payload, {
|
|
63
127
|
key: Buffer.from(this.config.publicKey, 'base64'),
|
|
64
128
|
format: 'der',
|
|
65
129
|
type: 'spki',
|
|
@@ -67,18 +131,27 @@ export class TelnyxVoiceProvider {
|
|
|
67
131
|
return { valid };
|
|
68
132
|
}
|
|
69
133
|
catch {
|
|
134
|
+
// crypto.verify throws on malformed keys or invalid DER encoding.
|
|
70
135
|
return { valid: false, error: 'Verification failed' };
|
|
71
136
|
}
|
|
72
137
|
}
|
|
73
138
|
/**
|
|
74
139
|
* Parse a Telnyx webhook JSON body into normalized {@link NormalizedCallEvent}s.
|
|
75
140
|
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
141
|
+
* Telnyx sends all webhook payloads as JSON with a `data.event_type`
|
|
142
|
+
* discriminant field. The `data.payload` object contains call-specific
|
|
143
|
+
* fields like `call_control_id`, `hangup_cause`, `digit`, and `result`.
|
|
144
|
+
*
|
|
145
|
+
* ## Hangup cause mapping
|
|
146
|
+
*
|
|
147
|
+
* Telnyx's `call.hangup` event includes a `hangup_cause` field that must
|
|
148
|
+
* be inspected to determine whether the user or the system terminated
|
|
149
|
+
* the call:
|
|
150
|
+
* - `normal_clearing` / `user_busy` / `originator_cancel` -> `call-hangup-user`
|
|
151
|
+
* - All other causes (e.g., `call_rejected`, `unallocated_number`) -> `call-completed`
|
|
152
|
+
*
|
|
153
|
+
* @param ctx - Raw webhook request context.
|
|
154
|
+
* @returns Parsed result containing zero or more normalized events.
|
|
82
155
|
*/
|
|
83
156
|
parseWebhookEvent(ctx) {
|
|
84
157
|
let parsed;
|
|
@@ -92,6 +165,7 @@ export class TelnyxVoiceProvider {
|
|
|
92
165
|
const providerCallId = payload.call_control_id ?? '';
|
|
93
166
|
const timestamp = Date.now();
|
|
94
167
|
const events = [];
|
|
168
|
+
/** Helper: shared base fields with a unique event ID for idempotency. */
|
|
95
169
|
const base = () => ({ eventId: randomUUID(), providerCallId, timestamp });
|
|
96
170
|
switch (event_type) {
|
|
97
171
|
case 'call.initiated':
|
|
@@ -101,7 +175,8 @@ export class TelnyxVoiceProvider {
|
|
|
101
175
|
events.push({ ...base(), kind: 'call-answered' });
|
|
102
176
|
break;
|
|
103
177
|
case 'call.hangup': {
|
|
104
|
-
// Distinguish user-initiated hangup from normal completion
|
|
178
|
+
// Distinguish user-initiated hangup from normal call completion
|
|
179
|
+
// by inspecting the hangup_cause field.
|
|
105
180
|
const cause = payload.hangup_cause ?? '';
|
|
106
181
|
const kind = cause === 'user_busy' || cause === 'normal_clearing' || cause === 'originator_cancel'
|
|
107
182
|
? 'call-hangup-user'
|
|
@@ -110,17 +185,19 @@ export class TelnyxVoiceProvider {
|
|
|
110
185
|
break;
|
|
111
186
|
}
|
|
112
187
|
case 'call.dtmf.received': {
|
|
188
|
+
// DTMF arrives via webhook only (never over the media stream WebSocket).
|
|
113
189
|
const digit = payload.digit ?? '';
|
|
114
190
|
events.push({ ...base(), kind: 'call-dtmf', digit });
|
|
115
191
|
break;
|
|
116
192
|
}
|
|
117
193
|
case 'call.machine.detection.ended':
|
|
194
|
+
// Only emit voicemail for machine detection; human detection is a no-op.
|
|
118
195
|
if (payload.result === 'machine') {
|
|
119
196
|
events.push({ ...base(), kind: 'call-voicemail' });
|
|
120
197
|
}
|
|
121
198
|
break;
|
|
122
199
|
default:
|
|
123
|
-
// Unrecognized event type
|
|
200
|
+
// Unrecognized event type -- silently ignore for forward-compatibility.
|
|
124
201
|
break;
|
|
125
202
|
}
|
|
126
203
|
return { events };
|
|
@@ -129,9 +206,15 @@ export class TelnyxVoiceProvider {
|
|
|
129
206
|
/**
|
|
130
207
|
* Initiate an outbound call via the Telnyx Call Control v2 API.
|
|
131
208
|
*
|
|
132
|
-
* POSTs to `/calls` with a JSON body
|
|
133
|
-
*
|
|
134
|
-
*
|
|
209
|
+
* POSTs to `/v2/calls` with a JSON body containing the `connection_id`,
|
|
210
|
+
* phone numbers, and webhook URL. The `mediaStreamUrl` (if provided) is
|
|
211
|
+
* stored internally for use after the call is answered -- it is NOT sent
|
|
212
|
+
* in the initial call creation request because Telnyx requires
|
|
213
|
+
* `streaming_start` to be issued as a separate action after `call.answered`.
|
|
214
|
+
*
|
|
215
|
+
* @param input - Call initiation parameters (from/to numbers, webhook URL).
|
|
216
|
+
* @returns Result containing the Telnyx `call_control_id` on success.
|
|
217
|
+
* @throws Never throws; returns `{ success: false, error: '...' }` on failure.
|
|
135
218
|
*/
|
|
136
219
|
async initiateCall(input) {
|
|
137
220
|
const url = `${this.baseUrl}/calls`;
|
|
@@ -157,6 +240,11 @@ export class TelnyxVoiceProvider {
|
|
|
157
240
|
}
|
|
158
241
|
/**
|
|
159
242
|
* Hang up an active call via the Telnyx Call Control hangup action.
|
|
243
|
+
*
|
|
244
|
+
* POSTs an empty JSON body to `/v2/calls/{call_control_id}/actions/hangup`.
|
|
245
|
+
* Telnyx will terminate the call and fire a `call.hangup` webhook.
|
|
246
|
+
*
|
|
247
|
+
* @param input - Contains the Telnyx `call_control_id` to hang up.
|
|
160
248
|
*/
|
|
161
249
|
async hangupCall(input) {
|
|
162
250
|
const url = `${this.baseUrl}/calls/${input.providerCallId}/actions/hangup`;
|
|
@@ -172,7 +260,10 @@ export class TelnyxVoiceProvider {
|
|
|
172
260
|
/**
|
|
173
261
|
* Speak text into a live call using Telnyx's text-to-speech speak action.
|
|
174
262
|
*
|
|
175
|
-
*
|
|
263
|
+
* POSTs a JSON body to `/v2/calls/{id}/actions/speak` with the text
|
|
264
|
+
* `payload`, `voice` (default `'female'`), and `language` (default `'en-US'`).
|
|
265
|
+
*
|
|
266
|
+
* @param input - TTS parameters (text, optional voice, call ID).
|
|
176
267
|
*/
|
|
177
268
|
async playTts(input) {
|
|
178
269
|
const url = `${this.baseUrl}/calls/${input.providerCallId}/actions/speak`;
|