UNPKG

@sayna-ai/node-sdk

Version:

Node.js SDK for Sayna.ai server-side WebSocket connections

655 lines (645 loc) 22.5 kB
// src/errors.ts class SaynaError extends Error { constructor(message) { super(message); this.name = "SaynaError"; Object.setPrototypeOf(this, SaynaError.prototype); } } class SaynaNotConnectedError extends SaynaError { constructor(message = "Not connected to Sayna WebSocket") { super(message); this.name = "SaynaNotConnectedError"; Object.setPrototypeOf(this, SaynaNotConnectedError.prototype); } } class SaynaNotReadyError extends SaynaError { constructor(message = "Sayna voice providers are not ready. Wait for the connection to be established.") { super(message); this.name = "SaynaNotReadyError"; Object.setPrototypeOf(this, SaynaNotReadyError.prototype); } } class SaynaConnectionError extends SaynaError { cause; constructor(message, cause) { super(message); this.name = "SaynaConnectionError"; this.cause = cause; Object.setPrototypeOf(this, SaynaConnectionError.prototype); } } class SaynaValidationError extends SaynaError { constructor(message) { super(message); this.name = "SaynaValidationError"; Object.setPrototypeOf(this, SaynaValidationError.prototype); } } class SaynaServerError extends SaynaError { constructor(message) { super(message); this.name = "SaynaServerError"; Object.setPrototypeOf(this, SaynaServerError.prototype); } } // src/sayna-client.ts class SaynaClient { url; sttConfig; ttsConfig; livekitConfig; withoutAudio; apiKey; websocket; isConnected = false; isReady = false; _livekitRoomName; _livekitUrl; _saynaParticipantIdentity; _saynaParticipantName; _streamId; inputStreamId; sttCallback; ttsCallback; errorCallback; messageCallback; participantDisconnectedCallback; ttsPlaybackCompleteCallback; readyPromiseResolve; readyPromiseReject; constructor(url, sttConfig, ttsConfig, livekitConfig, withoutAudio = false, apiKey, streamId) { if (!url || typeof url !== "string") { throw new SaynaValidationError("URL must be a non-empty string"); } if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("ws://") && !url.startsWith("wss://")) { throw new SaynaValidationError("URL must start with http://, https://, ws://, or wss://"); } if (!withoutAudio) { if (!sttConfig || !ttsConfig) { throw new SaynaValidationError("sttConfig and ttsConfig are required when withoutAudio=false (audio streaming enabled). " + "Either provide both configs or set withoutAudio=true for non-audio use cases."); } } this.url = url; this.sttConfig = sttConfig; this.ttsConfig = ttsConfig; this.livekitConfig = livekitConfig; this.withoutAudio = withoutAudio; this.apiKey = apiKey ?? process.env.SAYNA_API_KEY; this.inputStreamId = streamId; } async connect() { if (this.isConnected) { return; } const wsUrl = this.url.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://") + (this.url.endsWith("/") ? "ws" : "/ws"); return new Promise((resolve, reject) => { this.readyPromiseResolve = resolve; this.readyPromiseReject = reject; try { const headers = this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : undefined; const WebSocketConstructor = WebSocket; this.websocket = headers ? new WebSocketConstructor(wsUrl, undefined, { headers }) : new WebSocket(wsUrl); this.websocket.onopen = () => { this.isConnected = true; const configMessage = { type: "config", stream_id: this.inputStreamId, stt_config: this.sttConfig, tts_config: this.ttsConfig, livekit: this.livekitConfig, audio: !this.withoutAudio }; try { if (this.websocket) { this.websocket.send(JSON.stringify(configMessage)); } } catch (error) { this.cleanup(); const err = new SaynaConnectionError("Failed to send configuration", error); if (this.readyPromiseReject) { this.readyPromiseReject(err); } } }; this.websocket.onmessage = async (event) => { try { if (event.data instanceof Blob || event.data instanceof ArrayBuffer) { const buffer = event.data instanceof Blob ? await event.data.arrayBuffer() : event.data; if (this.ttsCallback) { await this.ttsCallback(buffer); } } else { if (typeof event.data !== "string") { throw new Error("Expected string data for JSON messages"); } const data = JSON.parse(event.data); await this.handleJsonMessage(data); } } catch (error) { if (this.errorCallback) { const errorMessage = error instanceof Error ? error.message : String(error); await this.errorCallback({ type: "error", message: `Failed to process message: ${errorMessage}` }); } } }; this.websocket.onerror = () => { const error = new SaynaConnectionError("WebSocket connection error"); if (this.readyPromiseReject && !this.isReady) { this.readyPromiseReject(error); } }; this.websocket.onclose = (event) => { const wasReady = this.isReady; this.cleanup(); if (!wasReady && this.readyPromiseReject) { this.readyPromiseReject(new SaynaConnectionError(`WebSocket closed before ready (code: ${event.code}, reason: ${event.reason || "none"})`)); } }; } catch (error) { reject(new SaynaConnectionError("Failed to create WebSocket", error)); } }); } async handleJsonMessage(data) { const messageType = data.type; try { switch (messageType) { case "ready": { const readyMsg = data; this.isReady = true; this._livekitRoomName = readyMsg.livekit_room_name; this._livekitUrl = readyMsg.livekit_url; this._saynaParticipantIdentity = readyMsg.sayna_participant_identity; this._saynaParticipantName = readyMsg.sayna_participant_name; this._streamId = readyMsg.stream_id; if (this.readyPromiseResolve) { this.readyPromiseResolve(); } break; } case "stt_result": { const sttResult = data; if (this.sttCallback) { await this.sttCallback(sttResult); } break; } case "error": { const errorMsg = data; if (this.errorCallback) { await this.errorCallback(errorMsg); } if (this.readyPromiseReject && !this.isReady) { this.readyPromiseReject(new SaynaServerError(errorMsg.message)); } break; } case "message": { const messageData = data; if (this.messageCallback) { await this.messageCallback(messageData.message); } break; } case "participant_disconnected": { const participantMsg = data; if (this.participantDisconnectedCallback) { await this.participantDisconnectedCallback(participantMsg.participant); } break; } case "tts_playback_complete": { const ttsPlaybackCompleteMsg = data; if (this.ttsPlaybackCompleteCallback) { await this.ttsPlaybackCompleteCallback(ttsPlaybackCompleteMsg.timestamp); } break; } } } catch (error) { if (this.errorCallback) { await this.errorCallback({ type: "error", message: `Handler error: ${error instanceof Error ? error.message : String(error)}` }); } } } cleanup() { this.isConnected = false; this.isReady = false; this._livekitRoomName = undefined; this._livekitUrl = undefined; this._saynaParticipantIdentity = undefined; this._saynaParticipantName = undefined; this._streamId = undefined; } getHttpUrl() { return this.url.replace(/^ws:\/\//, "http://").replace(/^wss:\/\//, "https://"); } async fetchFromSayna(endpoint, options = {}, responseType = "json") { const httpUrl = this.getHttpUrl(); const url = `${httpUrl}${httpUrl.endsWith("/") ? "" : "/"}${endpoint.startsWith("/") ? endpoint.slice(1) : endpoint}`; const headers = { ...options.headers }; const hasAuthHeader = Object.keys(headers).some((key) => key.toLowerCase() === "authorization"); if (this.apiKey && !hasAuthHeader) { headers["Authorization"] = `Bearer ${this.apiKey}`; } if (options.method === "POST" && options.body && !headers["Content-Type"]) { headers["Content-Type"] = "application/json"; } try { const response = await fetch(url, { ...options, headers }); if (!response.ok) { let errorMessage; try { const errorData = await response.json(); errorMessage = errorData && typeof errorData === "object" && "error" in errorData && typeof errorData.error === "string" ? errorData.error : `Request failed: ${response.status} ${response.statusText}`; } catch { errorMessage = `Request failed: ${response.status} ${response.statusText}`; } throw new SaynaServerError(errorMessage); } if (responseType === "arrayBuffer") { return await response.arrayBuffer(); } else { return await response.json(); } } catch (error) { if (error instanceof SaynaServerError) { throw error; } throw new SaynaConnectionError(`Failed to fetch from ${endpoint}`, error); } } disconnect() { if (this.websocket) { this.websocket.onopen = null; this.websocket.onmessage = null; this.websocket.onerror = null; this.websocket.onclose = null; if (this.websocket.readyState === WebSocket.OPEN) { this.websocket.close(1000, "Client disconnect"); } this.websocket = undefined; } this.cleanup(); } onAudioInput(audioData) { if (!this.isConnected || !this.websocket) { throw new SaynaNotConnectedError; } if (!this.isReady) { throw new SaynaNotReadyError; } if (!(audioData instanceof ArrayBuffer)) { throw new SaynaValidationError("audioData must be an ArrayBuffer"); } if (audioData.byteLength === 0) { throw new SaynaValidationError("audioData cannot be empty"); } try { this.websocket.send(audioData); } catch (error) { throw new SaynaConnectionError("Failed to send audio data", error); } } registerOnSttResult(callback) { this.sttCallback = callback; } registerOnTtsAudio(callback) { this.ttsCallback = callback; } registerOnError(callback) { this.errorCallback = callback; } registerOnMessage(callback) { this.messageCallback = callback; } registerOnParticipantDisconnected(callback) { this.participantDisconnectedCallback = callback; } registerOnTtsPlaybackComplete(callback) { this.ttsPlaybackCompleteCallback = callback; } speak(text, flush = true, allowInterruption = true) { if (!this.isConnected || !this.websocket) { throw new SaynaNotConnectedError; } if (!this.isReady) { throw new SaynaNotReadyError; } if (typeof text !== "string") { throw new SaynaValidationError("text must be a string"); } try { const speakMessage = { type: "speak", text, flush, allow_interruption: allowInterruption }; this.websocket.send(JSON.stringify(speakMessage)); } catch (error) { throw new SaynaConnectionError("Failed to send speak command", error); } } clear() { if (!this.isConnected || !this.websocket) { throw new SaynaNotConnectedError; } if (!this.isReady) { throw new SaynaNotReadyError; } try { const clearMessage = { type: "clear" }; this.websocket.send(JSON.stringify(clearMessage)); } catch (error) { throw new SaynaConnectionError("Failed to send clear command", error); } } ttsFlush(allowInterruption = true) { this.speak("", true, allowInterruption); } sendMessage(message, role, topic, debug) { if (!this.isConnected || !this.websocket) { throw new SaynaNotConnectedError; } if (!this.isReady) { throw new SaynaNotReadyError; } if (typeof message !== "string") { throw new SaynaValidationError("message must be a string"); } if (typeof role !== "string") { throw new SaynaValidationError("role must be a string"); } try { const sendMsg = { type: "send_message", message, role, topic, debug }; this.websocket.send(JSON.stringify(sendMsg)); } catch (error) { throw new SaynaConnectionError("Failed to send message", error); } } async health() { return this.fetchFromSayna(""); } async getVoices() { return this.fetchFromSayna("voices"); } async speakRest(text, ttsConfig) { if (!text || text.trim().length === 0) { throw new SaynaValidationError("Text cannot be empty"); } return this.fetchFromSayna("speak", { method: "POST", body: JSON.stringify({ text, tts_config: ttsConfig }) }, "arrayBuffer"); } async getLiveKitToken(roomName, participantName, participantIdentity) { if (!roomName || roomName.trim().length === 0) { throw new SaynaValidationError("room_name cannot be empty"); } if (!participantName || participantName.trim().length === 0) { throw new SaynaValidationError("participant_name cannot be empty"); } if (!participantIdentity || participantIdentity.trim().length === 0) { throw new SaynaValidationError("participant_identity cannot be empty"); } return this.fetchFromSayna("livekit/token", { method: "POST", body: JSON.stringify({ room_name: roomName, participant_name: participantName, participant_identity: participantIdentity }) }); } async getRecording(streamId) { if (!streamId || streamId.trim().length === 0) { throw new SaynaValidationError("streamId cannot be empty"); } return this.fetchFromSayna(`recording/${encodeURIComponent(streamId)}`, { method: "GET" }, "arrayBuffer"); } async getSipHooks() { return this.fetchFromSayna("sip/hooks"); } async setSipHooks(hooks) { if (!Array.isArray(hooks)) { throw new SaynaValidationError("hooks must be an array"); } if (hooks.length === 0) { throw new SaynaValidationError("hooks array cannot be empty"); } for (const [i, hook] of hooks.entries()) { if (!hook.host || typeof hook.host !== "string" || hook.host.trim().length === 0) { throw new SaynaValidationError(`hooks[${i}].host must be a non-empty string`); } if (!hook.url || typeof hook.url !== "string" || hook.url.trim().length === 0) { throw new SaynaValidationError(`hooks[${i}].url must be a non-empty string`); } } return this.fetchFromSayna("sip/hooks", { method: "POST", body: JSON.stringify({ hooks }) }); } async deleteSipHooks(hosts) { if (!Array.isArray(hosts)) { throw new SaynaValidationError("hosts must be an array"); } if (hosts.length === 0) { throw new SaynaValidationError("hosts array cannot be empty"); } for (const [i, host] of hosts.entries()) { if (!host || typeof host !== "string" || host.trim().length === 0) { throw new SaynaValidationError(`hosts[${i}] must be a non-empty string`); } } return this.fetchFromSayna("sip/hooks", { method: "DELETE", body: JSON.stringify({ hosts }) }); } get ready() { return this.isReady; } get connected() { return this.isConnected; } get livekitRoomName() { return this._livekitRoomName; } get livekitUrl() { return this._livekitUrl; } get saynaParticipantIdentity() { return this._saynaParticipantIdentity; } get saynaParticipantName() { return this._saynaParticipantName; } get streamId() { return this._streamId; } } // src/webhook-receiver.ts import { createHmac, timingSafeEqual } from "crypto"; var MIN_SECRET_LENGTH = 16; var TIMESTAMP_TOLERANCE_SECONDS = 300; class WebhookReceiver { secret; constructor(secret) { const effectiveSecret = secret ?? process.env.SAYNA_WEBHOOK_SECRET; if (!effectiveSecret) { throw new SaynaValidationError("Webhook secret is required. Provide it as a constructor parameter or set SAYNA_WEBHOOK_SECRET environment variable."); } const trimmedSecret = effectiveSecret.trim(); if (trimmedSecret.length < MIN_SECRET_LENGTH) { 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`); } this.secret = trimmedSecret; } receive(headers, body) { const normalizedHeaders = this.normalizeHeaders(headers); const signature = this.getRequiredHeader(normalizedHeaders, "x-sayna-signature"); const timestamp = this.getRequiredHeader(normalizedHeaders, "x-sayna-timestamp"); const eventId = this.getRequiredHeader(normalizedHeaders, "x-sayna-event-id"); if (!signature.startsWith("v1=")) { throw new SaynaValidationError("Invalid signature format. Expected 'v1=<hex>' but got: " + signature.substring(0, 10) + "..."); } const signatureHex = signature.substring(3); if (!/^[0-9a-f]{64}$/i.test(signatureHex)) { throw new SaynaValidationError("Invalid signature: must be 64 hex characters (HMAC-SHA256)"); } this.validateTimestamp(timestamp); const canonical = `v1:${timestamp}:${eventId}:${body}`; const hmac = createHmac("sha256", this.secret); hmac.update(canonical, "utf8"); const expectedSignature = hmac.digest("hex"); if (!this.constantTimeEqual(signatureHex, expectedSignature)) { throw new SaynaValidationError("Signature verification failed. The webhook may have been tampered with or the secret is incorrect."); } return this.parseAndValidatePayload(body); } normalizeHeaders(headers) { const normalized = {}; for (const [key, value] of Object.entries(headers)) { if (value !== undefined) { const stringValue = Array.isArray(value) ? value[0] : value; if (stringValue) { normalized[key.toLowerCase()] = stringValue; } } } return normalized; } getRequiredHeader(headers, name) { const value = headers[name.toLowerCase()]; if (!value) { throw new SaynaValidationError(`Missing required header: ${name}`); } return value; } validateTimestamp(timestampStr) { const timestamp = Number(timestampStr); if (isNaN(timestamp)) { throw new SaynaValidationError(`Invalid timestamp format: expected Unix seconds but got '${timestampStr}'`); } const now = Math.floor(Date.now() / 1000); const diff = Math.abs(now - timestamp); if (diff > TIMESTAMP_TOLERANCE_SECONDS) { 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.`); } } constantTimeEqual(a, b) { if (a.length !== b.length) { return false; } const bufA = Buffer.from(a, "utf8"); const bufB = Buffer.from(b, "utf8"); return timingSafeEqual(bufA, bufB); } parseAndValidatePayload(body) { let payload; try { payload = JSON.parse(body); } catch (error) { throw new SaynaValidationError(`Invalid JSON payload: ${error instanceof Error ? error.message : String(error)}`); } if (!payload || typeof payload !== "object" || Array.isArray(payload)) { throw new SaynaValidationError("Webhook payload must be a JSON object"); } const data = payload; this.validateParticipant(data.participant); this.validateRoom(data.room); this.validateStringField(data, "from_phone_number", "from_phone_number"); this.validateStringField(data, "to_phone_number", "to_phone_number"); this.validateStringField(data, "room_prefix", "room_prefix"); this.validateStringField(data, "sip_host", "sip_host"); return data; } validateParticipant(participant) { if (!participant || typeof participant !== "object" || Array.isArray(participant)) { throw new SaynaValidationError("Webhook payload missing required field 'participant' (must be an object)"); } const p = participant; this.validateStringField(p, "identity", "participant.identity"); this.validateStringField(p, "sid", "participant.sid"); if (p.name !== undefined && typeof p.name !== "string") { throw new SaynaValidationError("Field 'participant.name' must be a string if present"); } } validateRoom(room) { if (!room || typeof room !== "object" || Array.isArray(room)) { throw new SaynaValidationError("Webhook payload missing required field 'room' (must be an object)"); } const r = room; this.validateStringField(r, "name", "room.name"); this.validateStringField(r, "sid", "room.sid"); } validateStringField(obj, field, displayName) { const value = obj[field]; if (typeof value !== "string" || value.length === 0) { throw new SaynaValidationError(`Webhook payload missing required field '${displayName}' (must be a non-empty string)`); } } } // src/index.ts async function saynaConnect(url, sttConfig, ttsConfig, livekitConfig, withoutAudio = false, apiKey) { const client = new SaynaClient(url, sttConfig, ttsConfig, livekitConfig, withoutAudio, apiKey); await client.connect(); return client; } export { saynaConnect, WebhookReceiver, SaynaValidationError, SaynaServerError, SaynaNotReadyError, SaynaNotConnectedError, SaynaError, SaynaConnectionError, SaynaClient }; //# debugId=439FAA862268544264756E2164756E21