@sayna-ai/node-sdk 0.0.5 → 0.0.7
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/README.md +198 -22
- package/dist/index.js +184 -21
- package/dist/index.js.map +6 -5
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/{index.d.ts → src/index.d.ts} +4 -2
- package/dist/src/index.d.ts.map +1 -0
- package/dist/{sayna-client.d.ts → src/sayna-client.d.ts} +48 -8
- package/dist/src/sayna-client.d.ts.map +1 -0
- package/dist/{types.d.ts → src/types.d.ts} +66 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/webhook-receiver.d.ts +203 -0
- package/dist/src/webhook-receiver.d.ts.map +1 -0
- package/dist/tests/errors.test.d.ts +2 -0
- package/dist/tests/errors.test.d.ts.map +1 -0
- package/dist/tests/sayna-client.test.d.ts +2 -0
- package/dist/tests/sayna-client.test.d.ts.map +1 -0
- package/dist/tests/types.test.d.ts +2 -0
- package/dist/tests/types.test.d.ts.map +1 -0
- package/dist/tests/webhook-receiver.test.d.ts +2 -0
- package/dist/tests/webhook-receiver.test.d.ts.map +1 -0
- package/package.json +5 -3
- package/dist/errors.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/sayna-client.d.ts.map +0 -1
- package/dist/types.d.ts.map +0 -1
- /package/dist/{errors.d.ts → src/errors.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -52,6 +52,7 @@ Performs a health check on the Sayna server.
|
|
|
52
52
|
**Returns**: `Promise<{ status: string }>` - Status object with "OK" when healthy.
|
|
53
53
|
|
|
54
54
|
**Example**:
|
|
55
|
+
|
|
55
56
|
```typescript
|
|
56
57
|
const health = await client.health();
|
|
57
58
|
console.log(health.status); // "OK"
|
|
@@ -64,10 +65,14 @@ Retrieves the catalogue of text-to-speech voices grouped by provider.
|
|
|
64
65
|
**Returns**: `Promise<Record<string, Voice[]>>` - Object where keys are provider names and values are arrays of voice descriptors.
|
|
65
66
|
|
|
66
67
|
**Example**:
|
|
68
|
+
|
|
67
69
|
```typescript
|
|
68
70
|
const voices = await client.getVoices();
|
|
69
71
|
for (const [provider, voiceList] of Object.entries(voices)) {
|
|
70
|
-
console.log(
|
|
72
|
+
console.log(
|
|
73
|
+
`${provider}:`,
|
|
74
|
+
voiceList.map((v) => v.name)
|
|
75
|
+
);
|
|
71
76
|
}
|
|
72
77
|
```
|
|
73
78
|
|
|
@@ -75,14 +80,15 @@ for (const [provider, voiceList] of Object.entries(voices)) {
|
|
|
75
80
|
|
|
76
81
|
Synthesizes text into audio using the REST API. This is a standalone method that doesn't require an active WebSocket connection.
|
|
77
82
|
|
|
78
|
-
| parameter
|
|
79
|
-
|
|
|
80
|
-
| `text`
|
|
81
|
-
| `ttsConfig` | `TTSConfig` | Text-to-speech provider configuration.
|
|
83
|
+
| parameter | type | purpose |
|
|
84
|
+
| ----------- | ----------- | --------------------------------------- |
|
|
85
|
+
| `text` | `string` | Text to synthesize (must be non-empty). |
|
|
86
|
+
| `ttsConfig` | `TTSConfig` | Text-to-speech provider configuration. |
|
|
82
87
|
|
|
83
88
|
**Returns**: `Promise<ArrayBuffer>` - Raw audio data.
|
|
84
89
|
|
|
85
90
|
**Example**:
|
|
91
|
+
|
|
86
92
|
```typescript
|
|
87
93
|
const audioBuffer = await client.speakRest("Hello, world!", {
|
|
88
94
|
provider: "elevenlabs",
|
|
@@ -93,7 +99,7 @@ const audioBuffer = await client.speakRest("Hello, world!", {
|
|
|
93
99
|
sample_rate: 24000,
|
|
94
100
|
connection_timeout: 30,
|
|
95
101
|
request_timeout: 60,
|
|
96
|
-
pronunciations: []
|
|
102
|
+
pronunciations: [],
|
|
97
103
|
});
|
|
98
104
|
```
|
|
99
105
|
|
|
@@ -101,15 +107,16 @@ const audioBuffer = await client.speakRest("Hello, world!", {
|
|
|
101
107
|
|
|
102
108
|
Issues a LiveKit access token for a participant.
|
|
103
109
|
|
|
104
|
-
| parameter
|
|
105
|
-
|
|
|
106
|
-
| `roomName`
|
|
107
|
-
| `participantName`
|
|
110
|
+
| parameter | type | purpose |
|
|
111
|
+
| --------------------- | -------- | -------------------------------------- |
|
|
112
|
+
| `roomName` | `string` | LiveKit room to join or create. |
|
|
113
|
+
| `participantName` | `string` | Display name for the participant. |
|
|
108
114
|
| `participantIdentity` | `string` | Unique identifier for the participant. |
|
|
109
115
|
|
|
110
116
|
**Returns**: `Promise<LiveKitTokenResponse>` - Object containing token, room name, participant identity, and LiveKit URL.
|
|
111
117
|
|
|
112
118
|
**Example**:
|
|
119
|
+
|
|
113
120
|
```typescript
|
|
114
121
|
const tokenInfo = await client.getLiveKitToken(
|
|
115
122
|
"my-room",
|
|
@@ -120,6 +127,48 @@ console.log("Token:", tokenInfo.token);
|
|
|
120
127
|
console.log("LiveKit URL:", tokenInfo.livekit_url);
|
|
121
128
|
```
|
|
122
129
|
|
|
130
|
+
### `await client.getSipHooks()`
|
|
131
|
+
|
|
132
|
+
Retrieves all configured SIP webhook hooks from the runtime cache.
|
|
133
|
+
|
|
134
|
+
**Returns**: `Promise<SipHooksResponse>` - Object containing an array of configured hooks.
|
|
135
|
+
|
|
136
|
+
**Example**:
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
const response = await client.getSipHooks();
|
|
140
|
+
for (const hook of response.hooks) {
|
|
141
|
+
console.log(`Host: ${hook.host}, URL: ${hook.url}`);
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### `await client.setSipHooks(hooks)`
|
|
146
|
+
|
|
147
|
+
Sets or updates SIP webhook hooks in the runtime cache. Hooks with matching hosts will be replaced; new hosts will be added.
|
|
148
|
+
|
|
149
|
+
| parameter | type | purpose |
|
|
150
|
+
| --------- | ----------- | ---------------------------------------- |
|
|
151
|
+
| `hooks` | `SipHook[]` | Array of SIP hook configurations to set. |
|
|
152
|
+
|
|
153
|
+
Each `SipHook` object contains:
|
|
154
|
+
|
|
155
|
+
| field | type | description |
|
|
156
|
+
| ------ | -------- | ---------------------------------------- |
|
|
157
|
+
| `host` | `string` | SIP domain pattern (case-insensitive). |
|
|
158
|
+
| `url` | `string` | HTTPS URL to forward webhook events to. |
|
|
159
|
+
|
|
160
|
+
**Returns**: `Promise<SipHooksResponse>` - Object containing the merged list of all configured hooks.
|
|
161
|
+
|
|
162
|
+
**Example**:
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
const response = await client.setSipHooks([
|
|
166
|
+
{ host: "example.com", url: "https://webhook.example.com/events" },
|
|
167
|
+
{ host: "another.com", url: "https://webhook.another.com/events" },
|
|
168
|
+
]);
|
|
169
|
+
console.log("Total hooks configured:", response.hooks.length);
|
|
170
|
+
```
|
|
171
|
+
|
|
123
172
|
---
|
|
124
173
|
|
|
125
174
|
### WebSocket API Methods
|
|
@@ -128,13 +177,13 @@ These methods require an active WebSocket connection:
|
|
|
128
177
|
|
|
129
178
|
### `new SaynaClient(url, sttConfig, ttsConfig, livekitConfig?, withoutAudio?)`
|
|
130
179
|
|
|
131
|
-
| parameter
|
|
132
|
-
|
|
|
133
|
-
| `url`
|
|
134
|
-
| `sttConfig`
|
|
135
|
-
| `ttsConfig`
|
|
136
|
-
| `livekitConfig` | `LiveKitConfig` | Optional LiveKit room configuration.
|
|
137
|
-
| `withoutAudio`
|
|
180
|
+
| parameter | type | purpose |
|
|
181
|
+
| --------------- | --------------- | ------------------------------------------------------- |
|
|
182
|
+
| `url` | `string` | Sayna server URL (http://, https://, ws://, or wss://). |
|
|
183
|
+
| `sttConfig` | `STTConfig` | Speech-to-text provider configuration. |
|
|
184
|
+
| `ttsConfig` | `TTSConfig` | Text-to-speech provider configuration. |
|
|
185
|
+
| `livekitConfig` | `LiveKitConfig` | Optional LiveKit room configuration. |
|
|
186
|
+
| `withoutAudio` | `boolean` | Disable audio streaming (defaults to `false`). |
|
|
138
187
|
|
|
139
188
|
### `await client.connect()`
|
|
140
189
|
|
|
@@ -168,11 +217,11 @@ Registers a callback for TTS playback completion events.
|
|
|
168
217
|
|
|
169
218
|
Sends text to be synthesized as speech.
|
|
170
219
|
|
|
171
|
-
| parameter
|
|
172
|
-
|
|
|
173
|
-
| `text`
|
|
174
|
-
| `flush`
|
|
175
|
-
| `allowInterruption` | `boolean` | `true`
|
|
220
|
+
| parameter | type | default | purpose |
|
|
221
|
+
| ------------------- | --------- | ------- | -------------------------------- |
|
|
222
|
+
| `text` | `string` | - | Text to synthesize. |
|
|
223
|
+
| `flush` | `boolean` | `true` | Clear TTS queue before speaking. |
|
|
224
|
+
| `allowInterruption` | `boolean` | `true` | Allow speech to be interrupted. |
|
|
176
225
|
|
|
177
226
|
### `await client.onAudioInput(audioData)`
|
|
178
227
|
|
|
@@ -218,6 +267,133 @@ Identity assigned to the agent participant when LiveKit is enabled, if available
|
|
|
218
267
|
|
|
219
268
|
Display name assigned to the agent participant when LiveKit is enabled, if available.
|
|
220
269
|
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Webhook Receiver
|
|
273
|
+
|
|
274
|
+
The SDK includes a `WebhookReceiver` class for securely receiving and verifying cryptographically signed webhooks from Sayna's SIP service.
|
|
275
|
+
|
|
276
|
+
### Security Features
|
|
277
|
+
|
|
278
|
+
- **HMAC-SHA256 Signature Verification**: Ensures webhook authenticity
|
|
279
|
+
- **Constant-Time Comparison**: Prevents timing attack vulnerabilities
|
|
280
|
+
- **Replay Protection**: 5-minute timestamp window prevents replay attacks
|
|
281
|
+
- **Strict Validation**: Comprehensive checks on all required fields
|
|
282
|
+
|
|
283
|
+
### `new WebhookReceiver(secret?)`
|
|
284
|
+
|
|
285
|
+
Creates a new webhook receiver instance.
|
|
286
|
+
|
|
287
|
+
| parameter | type | purpose |
|
|
288
|
+
| --------- | -------- | ---------------------------------------------------------------------------- |
|
|
289
|
+
| `secret` | `string` | HMAC signing secret (min 16 chars). Defaults to `SAYNA_WEBHOOK_SECRET` env. |
|
|
290
|
+
|
|
291
|
+
**Example**:
|
|
292
|
+
|
|
293
|
+
```typescript
|
|
294
|
+
import { WebhookReceiver } from "@sayna/node-sdk";
|
|
295
|
+
|
|
296
|
+
// Explicit secret
|
|
297
|
+
const receiver = new WebhookReceiver("your-secret-key-min-16-chars");
|
|
298
|
+
|
|
299
|
+
// Or use environment variable
|
|
300
|
+
process.env.SAYNA_WEBHOOK_SECRET = "your-secret-key";
|
|
301
|
+
const receiver = new WebhookReceiver();
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### `receiver.receive(headers, body)`
|
|
305
|
+
|
|
306
|
+
Verifies and parses an incoming SIP webhook.
|
|
307
|
+
|
|
308
|
+
| parameter | type | purpose |
|
|
309
|
+
| --------- | ------------------------------------------- | -------------------------------------------- |
|
|
310
|
+
| `headers` | `Record<string, string \| string[] \| undefined>` | HTTP request headers (case-insensitive). |
|
|
311
|
+
| `body` | `string` | Raw request body as string (not parsed JSON). |
|
|
312
|
+
|
|
313
|
+
**Returns**: `WebhookSIPOutput` - Parsed and validated webhook payload.
|
|
314
|
+
|
|
315
|
+
**Throws**: `SaynaValidationError` if signature verification fails or payload is invalid.
|
|
316
|
+
|
|
317
|
+
### Express Example
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
import express from "express";
|
|
321
|
+
import { WebhookReceiver } from "@sayna/node-sdk";
|
|
322
|
+
|
|
323
|
+
const app = express();
|
|
324
|
+
const receiver = new WebhookReceiver("your-secret-key-min-16-chars");
|
|
325
|
+
|
|
326
|
+
app.post(
|
|
327
|
+
"/webhook",
|
|
328
|
+
express.json({
|
|
329
|
+
verify: (req, res, buf) => {
|
|
330
|
+
(req as any).rawBody = buf.toString("utf8");
|
|
331
|
+
},
|
|
332
|
+
}),
|
|
333
|
+
(req, res) => {
|
|
334
|
+
try {
|
|
335
|
+
const webhook = receiver.receive(req.headers, (req as any).rawBody);
|
|
336
|
+
|
|
337
|
+
console.log("Valid webhook received:");
|
|
338
|
+
console.log(" From:", webhook.from_phone_number);
|
|
339
|
+
console.log(" To:", webhook.to_phone_number);
|
|
340
|
+
console.log(" Room:", webhook.room.name);
|
|
341
|
+
console.log(" SIP Host:", webhook.sip_host);
|
|
342
|
+
console.log(" Participant:", webhook.participant.identity);
|
|
343
|
+
|
|
344
|
+
res.status(200).json({ received: true });
|
|
345
|
+
} catch (error) {
|
|
346
|
+
console.error("Webhook verification failed:", error.message);
|
|
347
|
+
res.status(401).json({ error: "Invalid signature" });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
);
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### Fastify Example
|
|
354
|
+
|
|
355
|
+
```typescript
|
|
356
|
+
import Fastify from "fastify";
|
|
357
|
+
import { WebhookReceiver } from "@sayna/node-sdk";
|
|
358
|
+
|
|
359
|
+
const fastify = Fastify();
|
|
360
|
+
const receiver = new WebhookReceiver();
|
|
361
|
+
|
|
362
|
+
fastify.post(
|
|
363
|
+
"/webhook",
|
|
364
|
+
{
|
|
365
|
+
config: { rawBody: true },
|
|
366
|
+
},
|
|
367
|
+
async (request, reply) => {
|
|
368
|
+
try {
|
|
369
|
+
const webhook = receiver.receive(request.headers, request.rawBody);
|
|
370
|
+
return { received: true };
|
|
371
|
+
} catch (error) {
|
|
372
|
+
reply.code(401);
|
|
373
|
+
return { error: error.message };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
);
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
### WebhookSIPOutput Type
|
|
380
|
+
|
|
381
|
+
The `receive` method returns a `WebhookSIPOutput` object with the following structure:
|
|
382
|
+
|
|
383
|
+
| field | type | description |
|
|
384
|
+
| ------------------- | ----------------------- | -------------------------------------------------- |
|
|
385
|
+
| `participant` | `WebhookSIPParticipant` | SIP participant information. |
|
|
386
|
+
| `participant.identity` | `string` | Unique identity assigned to the participant. |
|
|
387
|
+
| `participant.sid` | `string` | Participant session ID from LiveKit. |
|
|
388
|
+
| `participant.name` | `string?` | Display name (optional). |
|
|
389
|
+
| `room` | `WebhookSIPRoom` | LiveKit room information. |
|
|
390
|
+
| `room.name` | `string` | Name of the LiveKit room. |
|
|
391
|
+
| `room.sid` | `string` | Room session ID from LiveKit. |
|
|
392
|
+
| `from_phone_number` | `string` | Caller's phone number (E.164 format). |
|
|
393
|
+
| `to_phone_number` | `string` | Called phone number (E.164 format). |
|
|
394
|
+
| `room_prefix` | `string` | Room name prefix configured in Sayna. |
|
|
395
|
+
| `sip_host` | `string` | SIP domain extracted from the To header. |
|
|
396
|
+
|
|
221
397
|
## Development
|
|
222
398
|
|
|
223
399
|
```bash
|
package/dist/index.js
CHANGED
|
@@ -56,6 +56,7 @@ class SaynaClient {
|
|
|
56
56
|
ttsConfig;
|
|
57
57
|
livekitConfig;
|
|
58
58
|
withoutAudio;
|
|
59
|
+
apiKey;
|
|
59
60
|
websocket;
|
|
60
61
|
isConnected = false;
|
|
61
62
|
isReady = false;
|
|
@@ -71,7 +72,7 @@ class SaynaClient {
|
|
|
71
72
|
ttsPlaybackCompleteCallback;
|
|
72
73
|
readyPromiseResolve;
|
|
73
74
|
readyPromiseReject;
|
|
74
|
-
constructor(url, sttConfig, ttsConfig, livekitConfig, withoutAudio = false) {
|
|
75
|
+
constructor(url, sttConfig, ttsConfig, livekitConfig, withoutAudio = false, apiKey) {
|
|
75
76
|
if (!url || typeof url !== "string") {
|
|
76
77
|
throw new SaynaValidationError("URL must be a non-empty string");
|
|
77
78
|
}
|
|
@@ -88,6 +89,7 @@ class SaynaClient {
|
|
|
88
89
|
this.ttsConfig = ttsConfig;
|
|
89
90
|
this.livekitConfig = livekitConfig;
|
|
90
91
|
this.withoutAudio = withoutAudio;
|
|
92
|
+
this.apiKey = apiKey ?? process.env.SAYNA_API_KEY;
|
|
91
93
|
}
|
|
92
94
|
async connect() {
|
|
93
95
|
if (this.isConnected) {
|
|
@@ -98,7 +100,9 @@ class SaynaClient {
|
|
|
98
100
|
this.readyPromiseResolve = resolve;
|
|
99
101
|
this.readyPromiseReject = reject;
|
|
100
102
|
try {
|
|
101
|
-
this.
|
|
103
|
+
const headers = this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : undefined;
|
|
104
|
+
const WebSocketConstructor = WebSocket;
|
|
105
|
+
this.websocket = headers ? new WebSocketConstructor(wsUrl, undefined, { headers }) : new WebSocket(wsUrl);
|
|
102
106
|
this.websocket.onopen = () => {
|
|
103
107
|
this.isConnected = true;
|
|
104
108
|
const configMessage = {
|
|
@@ -128,14 +132,18 @@ class SaynaClient {
|
|
|
128
132
|
await this.ttsCallback(buffer);
|
|
129
133
|
}
|
|
130
134
|
} else {
|
|
135
|
+
if (typeof event.data !== "string") {
|
|
136
|
+
throw new Error("Expected string data for JSON messages");
|
|
137
|
+
}
|
|
131
138
|
const data = JSON.parse(event.data);
|
|
132
139
|
await this.handleJsonMessage(data);
|
|
133
140
|
}
|
|
134
141
|
} catch (error) {
|
|
135
142
|
if (this.errorCallback) {
|
|
143
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
136
144
|
await this.errorCallback({
|
|
137
145
|
type: "error",
|
|
138
|
-
message: `Failed to process message: ${
|
|
146
|
+
message: `Failed to process message: ${errorMessage}`
|
|
139
147
|
});
|
|
140
148
|
}
|
|
141
149
|
}
|
|
@@ -239,6 +247,10 @@ class SaynaClient {
|
|
|
239
247
|
const headers = {
|
|
240
248
|
...options.headers
|
|
241
249
|
};
|
|
250
|
+
const hasAuthHeader = Object.keys(headers).some((key) => key.toLowerCase() === "authorization");
|
|
251
|
+
if (this.apiKey && !hasAuthHeader) {
|
|
252
|
+
headers["Authorization"] = `Bearer ${this.apiKey}`;
|
|
253
|
+
}
|
|
242
254
|
if (options.method === "POST" && options.body && !headers["Content-Type"]) {
|
|
243
255
|
headers["Content-Type"] = "application/json";
|
|
244
256
|
}
|
|
@@ -248,10 +260,14 @@ class SaynaClient {
|
|
|
248
260
|
headers
|
|
249
261
|
});
|
|
250
262
|
if (!response.ok) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
263
|
+
let errorMessage;
|
|
264
|
+
try {
|
|
265
|
+
const errorData = await response.json();
|
|
266
|
+
errorMessage = errorData && typeof errorData === "object" && "error" in errorData && typeof errorData.error === "string" ? errorData.error : `Request failed: ${response.status} ${response.statusText}`;
|
|
267
|
+
} catch {
|
|
268
|
+
errorMessage = `Request failed: ${response.status} ${response.statusText}`;
|
|
269
|
+
}
|
|
270
|
+
throw new SaynaServerError(errorMessage);
|
|
255
271
|
}
|
|
256
272
|
if (responseType === "arrayBuffer") {
|
|
257
273
|
return await response.arrayBuffer();
|
|
@@ -265,7 +281,7 @@ class SaynaClient {
|
|
|
265
281
|
throw new SaynaConnectionError(`Failed to fetch from ${endpoint}`, error);
|
|
266
282
|
}
|
|
267
283
|
}
|
|
268
|
-
|
|
284
|
+
disconnect() {
|
|
269
285
|
if (this.websocket) {
|
|
270
286
|
this.websocket.onopen = null;
|
|
271
287
|
this.websocket.onmessage = null;
|
|
@@ -278,7 +294,7 @@ class SaynaClient {
|
|
|
278
294
|
}
|
|
279
295
|
this.cleanup();
|
|
280
296
|
}
|
|
281
|
-
|
|
297
|
+
onAudioInput(audioData) {
|
|
282
298
|
if (!this.isConnected || !this.websocket) {
|
|
283
299
|
throw new SaynaNotConnectedError;
|
|
284
300
|
}
|
|
@@ -315,7 +331,7 @@ class SaynaClient {
|
|
|
315
331
|
registerOnTtsPlaybackComplete(callback) {
|
|
316
332
|
this.ttsPlaybackCompleteCallback = callback;
|
|
317
333
|
}
|
|
318
|
-
|
|
334
|
+
speak(text, flush = true, allowInterruption = true) {
|
|
319
335
|
if (!this.isConnected || !this.websocket) {
|
|
320
336
|
throw new SaynaNotConnectedError;
|
|
321
337
|
}
|
|
@@ -337,7 +353,7 @@ class SaynaClient {
|
|
|
337
353
|
throw new SaynaConnectionError("Failed to send speak command", error);
|
|
338
354
|
}
|
|
339
355
|
}
|
|
340
|
-
|
|
356
|
+
clear() {
|
|
341
357
|
if (!this.isConnected || !this.websocket) {
|
|
342
358
|
throw new SaynaNotConnectedError;
|
|
343
359
|
}
|
|
@@ -353,10 +369,10 @@ class SaynaClient {
|
|
|
353
369
|
throw new SaynaConnectionError("Failed to send clear command", error);
|
|
354
370
|
}
|
|
355
371
|
}
|
|
356
|
-
|
|
357
|
-
|
|
372
|
+
ttsFlush(allowInterruption = true) {
|
|
373
|
+
this.speak("", true, allowInterruption);
|
|
358
374
|
}
|
|
359
|
-
|
|
375
|
+
sendMessage(message, role, topic, debug) {
|
|
360
376
|
if (!this.isConnected || !this.websocket) {
|
|
361
377
|
throw new SaynaNotConnectedError;
|
|
362
378
|
}
|
|
@@ -383,16 +399,16 @@ class SaynaClient {
|
|
|
383
399
|
}
|
|
384
400
|
}
|
|
385
401
|
async health() {
|
|
386
|
-
return
|
|
402
|
+
return this.fetchFromSayna("");
|
|
387
403
|
}
|
|
388
404
|
async getVoices() {
|
|
389
|
-
return
|
|
405
|
+
return this.fetchFromSayna("voices");
|
|
390
406
|
}
|
|
391
407
|
async speakRest(text, ttsConfig) {
|
|
392
408
|
if (!text || text.trim().length === 0) {
|
|
393
409
|
throw new SaynaValidationError("Text cannot be empty");
|
|
394
410
|
}
|
|
395
|
-
return
|
|
411
|
+
return this.fetchFromSayna("speak", {
|
|
396
412
|
method: "POST",
|
|
397
413
|
body: JSON.stringify({
|
|
398
414
|
text,
|
|
@@ -410,7 +426,7 @@ class SaynaClient {
|
|
|
410
426
|
if (!participantIdentity || participantIdentity.trim().length === 0) {
|
|
411
427
|
throw new SaynaValidationError("participant_identity cannot be empty");
|
|
412
428
|
}
|
|
413
|
-
return
|
|
429
|
+
return this.fetchFromSayna("livekit/token", {
|
|
414
430
|
method: "POST",
|
|
415
431
|
body: JSON.stringify({
|
|
416
432
|
room_name: roomName,
|
|
@@ -419,6 +435,29 @@ class SaynaClient {
|
|
|
419
435
|
})
|
|
420
436
|
});
|
|
421
437
|
}
|
|
438
|
+
async getSipHooks() {
|
|
439
|
+
return this.fetchFromSayna("sip/hooks");
|
|
440
|
+
}
|
|
441
|
+
async setSipHooks(hooks) {
|
|
442
|
+
if (!Array.isArray(hooks)) {
|
|
443
|
+
throw new SaynaValidationError("hooks must be an array");
|
|
444
|
+
}
|
|
445
|
+
if (hooks.length === 0) {
|
|
446
|
+
throw new SaynaValidationError("hooks array cannot be empty");
|
|
447
|
+
}
|
|
448
|
+
for (const [i, hook] of hooks.entries()) {
|
|
449
|
+
if (!hook.host || typeof hook.host !== "string" || hook.host.trim().length === 0) {
|
|
450
|
+
throw new SaynaValidationError(`hooks[${i}].host must be a non-empty string`);
|
|
451
|
+
}
|
|
452
|
+
if (!hook.url || typeof hook.url !== "string" || hook.url.trim().length === 0) {
|
|
453
|
+
throw new SaynaValidationError(`hooks[${i}].url must be a non-empty string`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return this.fetchFromSayna("sip/hooks", {
|
|
457
|
+
method: "POST",
|
|
458
|
+
body: JSON.stringify({ hooks })
|
|
459
|
+
});
|
|
460
|
+
}
|
|
422
461
|
get ready() {
|
|
423
462
|
return this.isReady;
|
|
424
463
|
}
|
|
@@ -438,15 +477,139 @@ class SaynaClient {
|
|
|
438
477
|
return this._saynaParticipantName;
|
|
439
478
|
}
|
|
440
479
|
}
|
|
480
|
+
// src/webhook-receiver.ts
|
|
481
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
482
|
+
var MIN_SECRET_LENGTH = 16;
|
|
483
|
+
var TIMESTAMP_TOLERANCE_SECONDS = 300;
|
|
484
|
+
|
|
485
|
+
class WebhookReceiver {
|
|
486
|
+
secret;
|
|
487
|
+
constructor(secret) {
|
|
488
|
+
const effectiveSecret = secret ?? process.env.SAYNA_WEBHOOK_SECRET;
|
|
489
|
+
if (!effectiveSecret) {
|
|
490
|
+
throw new SaynaValidationError("Webhook secret is required. Provide it as a constructor parameter or set SAYNA_WEBHOOK_SECRET environment variable.");
|
|
491
|
+
}
|
|
492
|
+
const trimmedSecret = effectiveSecret.trim();
|
|
493
|
+
if (trimmedSecret.length < MIN_SECRET_LENGTH) {
|
|
494
|
+
throw new SaynaValidationError(`Webhook secret must be at least ${MIN_SECRET_LENGTH} characters long. ` + `Received ${trimmedSecret.length} characters. ` + `Generate a secure secret with: openssl rand -hex 32`);
|
|
495
|
+
}
|
|
496
|
+
this.secret = trimmedSecret;
|
|
497
|
+
}
|
|
498
|
+
receive(headers, body) {
|
|
499
|
+
const normalizedHeaders = this.normalizeHeaders(headers);
|
|
500
|
+
const signature = this.getRequiredHeader(normalizedHeaders, "x-sayna-signature");
|
|
501
|
+
const timestamp = this.getRequiredHeader(normalizedHeaders, "x-sayna-timestamp");
|
|
502
|
+
const eventId = this.getRequiredHeader(normalizedHeaders, "x-sayna-event-id");
|
|
503
|
+
if (!signature.startsWith("v1=")) {
|
|
504
|
+
throw new SaynaValidationError("Invalid signature format. Expected 'v1=<hex>' but got: " + signature.substring(0, 10) + "...");
|
|
505
|
+
}
|
|
506
|
+
const signatureHex = signature.substring(3);
|
|
507
|
+
if (!/^[0-9a-f]{64}$/i.test(signatureHex)) {
|
|
508
|
+
throw new SaynaValidationError("Invalid signature: must be 64 hex characters (HMAC-SHA256)");
|
|
509
|
+
}
|
|
510
|
+
this.validateTimestamp(timestamp);
|
|
511
|
+
const canonical = `v1:${timestamp}:${eventId}:${body}`;
|
|
512
|
+
const hmac = createHmac("sha256", this.secret);
|
|
513
|
+
hmac.update(canonical, "utf8");
|
|
514
|
+
const expectedSignature = hmac.digest("hex");
|
|
515
|
+
if (!this.constantTimeEqual(signatureHex, expectedSignature)) {
|
|
516
|
+
throw new SaynaValidationError("Signature verification failed. The webhook may have been tampered with or the secret is incorrect.");
|
|
517
|
+
}
|
|
518
|
+
return this.parseAndValidatePayload(body);
|
|
519
|
+
}
|
|
520
|
+
normalizeHeaders(headers) {
|
|
521
|
+
const normalized = {};
|
|
522
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
523
|
+
if (value !== undefined) {
|
|
524
|
+
const stringValue = Array.isArray(value) ? value[0] : value;
|
|
525
|
+
if (stringValue) {
|
|
526
|
+
normalized[key.toLowerCase()] = stringValue;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return normalized;
|
|
531
|
+
}
|
|
532
|
+
getRequiredHeader(headers, name) {
|
|
533
|
+
const value = headers[name.toLowerCase()];
|
|
534
|
+
if (!value) {
|
|
535
|
+
throw new SaynaValidationError(`Missing required header: ${name}`);
|
|
536
|
+
}
|
|
537
|
+
return value;
|
|
538
|
+
}
|
|
539
|
+
validateTimestamp(timestampStr) {
|
|
540
|
+
const timestamp = Number(timestampStr);
|
|
541
|
+
if (isNaN(timestamp)) {
|
|
542
|
+
throw new SaynaValidationError(`Invalid timestamp format: expected Unix seconds but got '${timestampStr}'`);
|
|
543
|
+
}
|
|
544
|
+
const now = Math.floor(Date.now() / 1000);
|
|
545
|
+
const diff = Math.abs(now - timestamp);
|
|
546
|
+
if (diff > TIMESTAMP_TOLERANCE_SECONDS) {
|
|
547
|
+
throw new SaynaValidationError(`Timestamp outside replay protection window. ` + `Difference: ${diff} seconds (max allowed: ${TIMESTAMP_TOLERANCE_SECONDS}). ` + `This webhook may be a replay attack or there may be significant clock skew.`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
constantTimeEqual(a, b) {
|
|
551
|
+
if (a.length !== b.length) {
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
const bufA = Buffer.from(a, "utf8");
|
|
555
|
+
const bufB = Buffer.from(b, "utf8");
|
|
556
|
+
return timingSafeEqual(bufA, bufB);
|
|
557
|
+
}
|
|
558
|
+
parseAndValidatePayload(body) {
|
|
559
|
+
let payload;
|
|
560
|
+
try {
|
|
561
|
+
payload = JSON.parse(body);
|
|
562
|
+
} catch (error) {
|
|
563
|
+
throw new SaynaValidationError(`Invalid JSON payload: ${error instanceof Error ? error.message : String(error)}`);
|
|
564
|
+
}
|
|
565
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
566
|
+
throw new SaynaValidationError("Webhook payload must be a JSON object");
|
|
567
|
+
}
|
|
568
|
+
const data = payload;
|
|
569
|
+
this.validateParticipant(data.participant);
|
|
570
|
+
this.validateRoom(data.room);
|
|
571
|
+
this.validateStringField(data, "from_phone_number", "from_phone_number");
|
|
572
|
+
this.validateStringField(data, "to_phone_number", "to_phone_number");
|
|
573
|
+
this.validateStringField(data, "room_prefix", "room_prefix");
|
|
574
|
+
this.validateStringField(data, "sip_host", "sip_host");
|
|
575
|
+
return data;
|
|
576
|
+
}
|
|
577
|
+
validateParticipant(participant) {
|
|
578
|
+
if (!participant || typeof participant !== "object" || Array.isArray(participant)) {
|
|
579
|
+
throw new SaynaValidationError("Webhook payload missing required field 'participant' (must be an object)");
|
|
580
|
+
}
|
|
581
|
+
const p = participant;
|
|
582
|
+
this.validateStringField(p, "identity", "participant.identity");
|
|
583
|
+
this.validateStringField(p, "sid", "participant.sid");
|
|
584
|
+
if (p.name !== undefined && typeof p.name !== "string") {
|
|
585
|
+
throw new SaynaValidationError("Field 'participant.name' must be a string if present");
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
validateRoom(room) {
|
|
589
|
+
if (!room || typeof room !== "object" || Array.isArray(room)) {
|
|
590
|
+
throw new SaynaValidationError("Webhook payload missing required field 'room' (must be an object)");
|
|
591
|
+
}
|
|
592
|
+
const r = room;
|
|
593
|
+
this.validateStringField(r, "name", "room.name");
|
|
594
|
+
this.validateStringField(r, "sid", "room.sid");
|
|
595
|
+
}
|
|
596
|
+
validateStringField(obj, field, displayName) {
|
|
597
|
+
const value = obj[field];
|
|
598
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
599
|
+
throw new SaynaValidationError(`Webhook payload missing required field '${displayName}' (must be a non-empty string)`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
441
603
|
|
|
442
604
|
// src/index.ts
|
|
443
|
-
async function saynaConnect(url, sttConfig, ttsConfig, livekitConfig, withoutAudio = false) {
|
|
444
|
-
const client = new SaynaClient(url, sttConfig, ttsConfig, livekitConfig, withoutAudio);
|
|
605
|
+
async function saynaConnect(url, sttConfig, ttsConfig, livekitConfig, withoutAudio = false, apiKey) {
|
|
606
|
+
const client = new SaynaClient(url, sttConfig, ttsConfig, livekitConfig, withoutAudio, apiKey);
|
|
445
607
|
await client.connect();
|
|
446
608
|
return client;
|
|
447
609
|
}
|
|
448
610
|
export {
|
|
449
611
|
saynaConnect,
|
|
612
|
+
WebhookReceiver,
|
|
450
613
|
SaynaValidationError,
|
|
451
614
|
SaynaServerError,
|
|
452
615
|
SaynaNotReadyError,
|
|
@@ -456,4 +619,4 @@ export {
|
|
|
456
619
|
SaynaClient
|
|
457
620
|
};
|
|
458
621
|
|
|
459
|
-
//# debugId=
|
|
622
|
+
//# debugId=DBDEB289893BB9C064756E2164756E21
|