@mooncompany/uplink-chat 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of @mooncompany/uplink-chat might be problematic. Click here for more details.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/bin/uplink.js +279 -0
- package/middleware/error-handler.js +69 -0
- package/package.json +93 -0
- package/public/css/agents.36b98c0f.css +1469 -0
- package/public/css/agents.css +1469 -0
- package/public/css/app.a6a7f8f5.css +2731 -0
- package/public/css/app.css +2731 -0
- package/public/css/artifacts.css +444 -0
- package/public/css/commands.css +55 -0
- package/public/css/connection.css +131 -0
- package/public/css/dashboard.css +233 -0
- package/public/css/developer.css +328 -0
- package/public/css/files.css +123 -0
- package/public/css/markdown.css +156 -0
- package/public/css/message-actions.css +278 -0
- package/public/css/mobile.css +614 -0
- package/public/css/panels-unified.css +483 -0
- package/public/css/premium.css +415 -0
- package/public/css/realtime.css +189 -0
- package/public/css/satellites.css +401 -0
- package/public/css/shortcuts.css +185 -0
- package/public/css/split-view.4def0262.css +673 -0
- package/public/css/split-view.css +673 -0
- package/public/css/theme-generator.css +391 -0
- package/public/css/themes.css +387 -0
- package/public/css/timestamps.css +54 -0
- package/public/css/variables.css +78 -0
- package/public/dist/bundle.b55050c4.js +15757 -0
- package/public/favicon.svg +24 -0
- package/public/img/agents/ada.png +0 -0
- package/public/img/agents/clarice.png +0 -0
- package/public/img/agents/dennis-nedry.png +0 -0
- package/public/img/agents/elliot-alderson.png +0 -0
- package/public/img/agents/main.png +0 -0
- package/public/img/agents/scotty.png +0 -0
- package/public/img/agents/top-flight-security.png +0 -0
- package/public/index.html +1083 -0
- package/public/js/agents-data.js +234 -0
- package/public/js/agents-ui.js +72 -0
- package/public/js/agents.js +1525 -0
- package/public/js/app.js +79 -0
- package/public/js/appearance-settings.js +111 -0
- package/public/js/artifacts.js +432 -0
- package/public/js/audio-queue.js +168 -0
- package/public/js/bootstrap.js +54 -0
- package/public/js/chat.js +1211 -0
- package/public/js/commands.js +581 -0
- package/public/js/connection-api.js +121 -0
- package/public/js/connection.js +1231 -0
- package/public/js/context-tracker.js +271 -0
- package/public/js/core.js +172 -0
- package/public/js/dashboard.js +452 -0
- package/public/js/developer.js +432 -0
- package/public/js/encryption.js +124 -0
- package/public/js/errors.js +122 -0
- package/public/js/event-bus.js +77 -0
- package/public/js/fetch-utils.js +171 -0
- package/public/js/file-handler.js +229 -0
- package/public/js/files.js +352 -0
- package/public/js/gateway-chat.js +538 -0
- package/public/js/logger.js +112 -0
- package/public/js/markdown.js +190 -0
- package/public/js/message-actions.js +431 -0
- package/public/js/message-renderer.js +288 -0
- package/public/js/missed-messages.js +235 -0
- package/public/js/mobile-debug.js +95 -0
- package/public/js/notifications.js +367 -0
- package/public/js/offline-queue.js +178 -0
- package/public/js/onboarding.js +543 -0
- package/public/js/panels.js +156 -0
- package/public/js/premium.js +412 -0
- package/public/js/realtime-voice.js +844 -0
- package/public/js/satellite-sync.js +256 -0
- package/public/js/satellite-ui.js +175 -0
- package/public/js/satellites.js +1516 -0
- package/public/js/settings.js +1087 -0
- package/public/js/shortcuts.js +381 -0
- package/public/js/split-chat.js +1234 -0
- package/public/js/split-resize.js +211 -0
- package/public/js/splitview.js +340 -0
- package/public/js/storage.js +408 -0
- package/public/js/streaming-handler.js +324 -0
- package/public/js/stt-settings.js +316 -0
- package/public/js/theme-generator.js +661 -0
- package/public/js/themes.js +164 -0
- package/public/js/timestamps.js +198 -0
- package/public/js/tts-settings.js +575 -0
- package/public/js/ui.js +267 -0
- package/public/js/update-notifier.js +143 -0
- package/public/js/utils/constants.js +165 -0
- package/public/js/utils/sanitize.js +93 -0
- package/public/js/utils/sse-parser.js +195 -0
- package/public/js/voice.js +883 -0
- package/public/manifest.json +58 -0
- package/public/moon_texture.jpg +0 -0
- package/public/sw.js +221 -0
- package/public/three.min.js +6 -0
- package/server/channel.js +529 -0
- package/server/chat.js +270 -0
- package/server/config-store.js +362 -0
- package/server/config.js +159 -0
- package/server/context.js +131 -0
- package/server/gateway-commands.js +211 -0
- package/server/gateway-proxy.js +318 -0
- package/server/index.js +22 -0
- package/server/logger.js +89 -0
- package/server/middleware/auth.js +188 -0
- package/server/middleware.js +218 -0
- package/server/openclaw-discover.js +308 -0
- package/server/premium/index.js +156 -0
- package/server/premium/license.js +140 -0
- package/server/realtime/bridge.js +837 -0
- package/server/realtime/index.js +349 -0
- package/server/realtime/tts-stream.js +446 -0
- package/server/routes/agents.js +564 -0
- package/server/routes/artifacts.js +174 -0
- package/server/routes/chat.js +311 -0
- package/server/routes/config-settings.js +345 -0
- package/server/routes/config.js +603 -0
- package/server/routes/files.js +307 -0
- package/server/routes/index.js +18 -0
- package/server/routes/media.js +451 -0
- package/server/routes/missed-messages.js +107 -0
- package/server/routes/premium.js +75 -0
- package/server/routes/push.js +156 -0
- package/server/routes/satellite.js +406 -0
- package/server/routes/status.js +251 -0
- package/server/routes/stt.js +35 -0
- package/server/routes/voice.js +260 -0
- package/server/routes/webhooks.js +203 -0
- package/server/routes.js +206 -0
- package/server/runtime-config.js +336 -0
- package/server/share.js +305 -0
- package/server/stt/faster-whisper.js +72 -0
- package/server/stt/groq.js +51 -0
- package/server/stt/index.js +196 -0
- package/server/stt/openai.js +49 -0
- package/server/sync.js +244 -0
- package/server/tailscale-https.js +175 -0
- package/server/tts.js +646 -0
- package/server/update-checker.js +172 -0
- package/server/utils/filename.js +129 -0
- package/server/utils.js +147 -0
- package/server/watchdog.js +318 -0
- package/server/websocket/broadcast.js +359 -0
- package/server/websocket/connections.js +339 -0
- package/server/websocket/index.js +215 -0
- package/server/websocket/routing.js +277 -0
- package/server/websocket/sync.js +102 -0
- package/server.js +404 -0
- package/utils/detect-tool-usage.js +93 -0
- package/utils/errors.js +158 -0
- package/utils/html-escape.js +84 -0
- package/utils/id-sanitize.js +94 -0
- package/utils/response.js +130 -0
- package/utils/with-retry.js +105 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Realtime Voice Relay — WebSocket proxy to OpenAI Realtime API
|
|
3
|
+
*
|
|
4
|
+
* Accepts client WebSocket connections on `/api/realtime`, opens a
|
|
5
|
+
* corresponding WebSocket to `wss://api.openai.com/v1/realtime`,
|
|
6
|
+
* and bidirectionally relays all JSON events between the two.
|
|
7
|
+
*
|
|
8
|
+
* On connection, sends a `session.update` to OpenAI with:
|
|
9
|
+
* - voice from runtime config (`realtimeVoice`)
|
|
10
|
+
* - `semantic_vad` turn detection
|
|
11
|
+
* - PCM 24kHz audio format (input + output)
|
|
12
|
+
* - agent instructions (from the main agent's identity.theme or a default)
|
|
13
|
+
*
|
|
14
|
+
* Usage (in server.js):
|
|
15
|
+
* import { setupRealtimeRelay } from './server/realtime/index.js';
|
|
16
|
+
* setupRealtimeRelay(httpServer);
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
20
|
+
import fs from 'fs/promises';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import os from 'os';
|
|
23
|
+
import { createLogger } from '../logger.js';
|
|
24
|
+
import { loadConfig } from '../runtime-config.js';
|
|
25
|
+
import { ALLOWED_ORIGINS } from '../config.js';
|
|
26
|
+
|
|
27
|
+
const log = createLogger('realtime');
|
|
28
|
+
|
|
29
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
30
|
+
const OPENAI_REALTIME_BASE = 'wss://api.openai.com/v1/realtime';
|
|
31
|
+
const DEFAULT_MODEL = 'gpt-4o-mini-realtime-preview';
|
|
32
|
+
|
|
33
|
+
// Connection bookkeeping
|
|
34
|
+
const MAX_REALTIME_CONNECTIONS = 5;
|
|
35
|
+
const activeSessions = new Map(); // sessionId → { clientWs, openaiWs }
|
|
36
|
+
|
|
37
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read agent identity/theme from the OpenClaw config file.
|
|
41
|
+
* Falls back to a sensible default if anything goes wrong.
|
|
42
|
+
*/
|
|
43
|
+
async function getAgentInstructions() {
|
|
44
|
+
const DEFAULT_INSTRUCTIONS =
|
|
45
|
+
'You are a helpful voice assistant. Be concise, friendly, and conversational. ' +
|
|
46
|
+
'Keep responses short since this is a voice conversation.';
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const stateDir = process.env.OPENCLAW_STATE_DIR || path.join(os.homedir(), '.openclaw');
|
|
50
|
+
const configPath = path.join(stateDir, 'openclaw.json');
|
|
51
|
+
const raw = await fs.readFile(configPath, 'utf8');
|
|
52
|
+
const config = JSON.parse(raw);
|
|
53
|
+
|
|
54
|
+
// Find the main agent (id === 'main') and pull identity.theme
|
|
55
|
+
const mainAgent = config.agents?.list?.find(a => a.id === 'main');
|
|
56
|
+
const name = mainAgent?.identity?.name;
|
|
57
|
+
const theme = mainAgent?.identity?.theme;
|
|
58
|
+
|
|
59
|
+
if (theme) {
|
|
60
|
+
return `Your name is ${name || 'Assistant'}. ${theme}. ` +
|
|
61
|
+
'Keep responses concise — this is a real-time voice conversation.';
|
|
62
|
+
}
|
|
63
|
+
if (name) {
|
|
64
|
+
return `Your name is ${name}. ${DEFAULT_INSTRUCTIONS}`;
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// Config not found or unparseable — fall through to default
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return DEFAULT_INSTRUCTIONS;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Verify that the WebSocket upgrade origin is allowed.
|
|
75
|
+
*/
|
|
76
|
+
function verifyOrigin(origin) {
|
|
77
|
+
if (!origin) return true; // Non-browser clients don't send Origin
|
|
78
|
+
try {
|
|
79
|
+
const url = new URL(origin);
|
|
80
|
+
const host = url.hostname;
|
|
81
|
+
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return true;
|
|
82
|
+
if (host.endsWith('.ts.net')) return true;
|
|
83
|
+
return ALLOWED_ORIGINS.some(allowed => {
|
|
84
|
+
try { return new URL(allowed).hostname === host; } catch { return false; }
|
|
85
|
+
});
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build the `session.update` event sent to OpenAI on connection.
|
|
93
|
+
*/
|
|
94
|
+
function buildSessionUpdate(voice, instructions) {
|
|
95
|
+
return JSON.stringify({
|
|
96
|
+
type: 'session.update',
|
|
97
|
+
session: {
|
|
98
|
+
modalities: ['text', 'audio'],
|
|
99
|
+
instructions,
|
|
100
|
+
voice: voice || 'marin',
|
|
101
|
+
input_audio_format: 'pcm16',
|
|
102
|
+
output_audio_format: 'pcm16',
|
|
103
|
+
input_audio_transcription: {
|
|
104
|
+
model: 'whisper-1',
|
|
105
|
+
},
|
|
106
|
+
turn_detection: {
|
|
107
|
+
type: 'server_vad',
|
|
108
|
+
threshold: 0.5,
|
|
109
|
+
prefix_padding_ms: 300,
|
|
110
|
+
silence_duration_ms: 500,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Set up the Realtime API WebSocket relay on the given HTTP server.
|
|
120
|
+
*
|
|
121
|
+
* Listens for upgrade requests on `/api/realtime` using noServer mode
|
|
122
|
+
* (same pattern as gateway-proxy.js) so it coexists with the main
|
|
123
|
+
* WebSocket server and Gateway proxy.
|
|
124
|
+
*
|
|
125
|
+
* @param {import('http').Server} server - The HTTP server instance
|
|
126
|
+
*/
|
|
127
|
+
export function setupRealtimeRelay(server) {
|
|
128
|
+
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
|
129
|
+
|
|
130
|
+
// ── Handle upgrade on /api/realtime ───────────────────────────────────
|
|
131
|
+
server.on('upgrade', (request, socket, head) => {
|
|
132
|
+
const { pathname } = new URL(request.url, `http://${request.headers.host}`);
|
|
133
|
+
|
|
134
|
+
if (pathname !== '/api/realtime') return; // Let other handlers take it
|
|
135
|
+
|
|
136
|
+
// Agent mode is handled by bridge.js's own upgrade handler
|
|
137
|
+
const url = new URL(request.url, `http://${request.headers.host}`);
|
|
138
|
+
if (url.searchParams.get('mode') === 'agent') return;
|
|
139
|
+
|
|
140
|
+
// Origin check
|
|
141
|
+
const origin = request.headers.origin;
|
|
142
|
+
if (!verifyOrigin(origin)) {
|
|
143
|
+
log.warn(`Rejected upgrade from invalid origin: ${origin}`);
|
|
144
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
145
|
+
socket.destroy();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Connection limit
|
|
150
|
+
if (activeSessions.size >= MAX_REALTIME_CONNECTIONS) {
|
|
151
|
+
log.warn(`Rejected upgrade — at capacity (${activeSessions.size}/${MAX_REALTIME_CONNECTIONS})`);
|
|
152
|
+
socket.write('HTTP/1.1 429 Too Many Requests\r\n\r\n');
|
|
153
|
+
socket.destroy();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
158
|
+
wss.emit('connection', ws, request);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ── New client connection ─────────────────────────────────────────────
|
|
163
|
+
wss.on('connection', async (clientWs, req) => {
|
|
164
|
+
const sessionId = `rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
165
|
+
const clientIp = req.socket.remoteAddress;
|
|
166
|
+
log.info(`Client connected: ${sessionId} from ${clientIp}`);
|
|
167
|
+
|
|
168
|
+
// ── Standalone mode (agent mode handled by bridge.js) ─────────────
|
|
169
|
+
|
|
170
|
+
// ── Read runtime config ─────────────────────────────────────────────
|
|
171
|
+
let config;
|
|
172
|
+
try {
|
|
173
|
+
config = await loadConfig();
|
|
174
|
+
} catch (err) {
|
|
175
|
+
log.error('Failed to load runtime config:', err);
|
|
176
|
+
clientWs.close(1011, 'Server configuration error');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const apiKey = config.openaiApiKey;
|
|
181
|
+
if (!apiKey) {
|
|
182
|
+
log.error('No OpenAI API key configured — cannot open realtime session');
|
|
183
|
+
clientWs.send(JSON.stringify({
|
|
184
|
+
type: 'error',
|
|
185
|
+
error: 'No OpenAI API key configured. Add your key in Settings → Voice.',
|
|
186
|
+
}));
|
|
187
|
+
clientWs.close(1008, 'Missing API key');
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const model = config.realtimeModel || DEFAULT_MODEL;
|
|
192
|
+
const voice = config.realtimeVoice || 'marin';
|
|
193
|
+
|
|
194
|
+
// ── Open outbound WebSocket to OpenAI ───────────────────────────────
|
|
195
|
+
const openaiUrl = `${OPENAI_REALTIME_BASE}?model=${encodeURIComponent(model)}`;
|
|
196
|
+
let openaiWs;
|
|
197
|
+
try {
|
|
198
|
+
openaiWs = new WebSocket(openaiUrl, {
|
|
199
|
+
headers: {
|
|
200
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
201
|
+
'OpenAI-Beta': 'realtime=v1',
|
|
202
|
+
},
|
|
203
|
+
perMessageDeflate: true,
|
|
204
|
+
});
|
|
205
|
+
} catch (err) {
|
|
206
|
+
log.error('Failed to create OpenAI WebSocket:', err);
|
|
207
|
+
clientWs.close(1011, 'Failed to connect to OpenAI');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Track session
|
|
212
|
+
let clientClosed = false;
|
|
213
|
+
let openaiClosed = false;
|
|
214
|
+
activeSessions.set(sessionId, { clientWs, openaiWs });
|
|
215
|
+
|
|
216
|
+
// Keepalive ping
|
|
217
|
+
const keepalive = setInterval(() => {
|
|
218
|
+
try {
|
|
219
|
+
if (clientWs.readyState === WebSocket.OPEN) clientWs.ping();
|
|
220
|
+
if (openaiWs.readyState === WebSocket.OPEN) openaiWs.ping();
|
|
221
|
+
} catch { /* swallow */ }
|
|
222
|
+
}, 15_000);
|
|
223
|
+
|
|
224
|
+
function cleanup() {
|
|
225
|
+
clearInterval(keepalive);
|
|
226
|
+
activeSessions.delete(sessionId);
|
|
227
|
+
if (!clientClosed && clientWs.readyState === WebSocket.OPEN) {
|
|
228
|
+
clientWs.close();
|
|
229
|
+
}
|
|
230
|
+
if (!openaiClosed && openaiWs.readyState === WebSocket.OPEN) {
|
|
231
|
+
openaiWs.close();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── OpenAI WebSocket events ─────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
openaiWs.on('open', async () => {
|
|
238
|
+
log.info(`OpenAI connected for session ${sessionId} (model=${model}, voice=${voice})`);
|
|
239
|
+
|
|
240
|
+
// Send session.update with agent instructions
|
|
241
|
+
try {
|
|
242
|
+
const instructions = await getAgentInstructions();
|
|
243
|
+
const sessionUpdate = buildSessionUpdate(voice, instructions);
|
|
244
|
+
log.info(`Sending session.update for ${sessionId}: ${sessionUpdate.substring(0, 500)}`);
|
|
245
|
+
openaiWs.send(sessionUpdate);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
log.error(`Failed to send session.update for ${sessionId}:`, err);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Notify client that the relay is ready
|
|
251
|
+
if (clientWs.readyState === WebSocket.OPEN) {
|
|
252
|
+
clientWs.send(JSON.stringify({ type: 'relay.ready' }));
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
openaiWs.on('message', (data, isBinary) => {
|
|
257
|
+
// Log OpenAI events for debugging
|
|
258
|
+
if (!isBinary) {
|
|
259
|
+
try {
|
|
260
|
+
const event = JSON.parse(data.toString());
|
|
261
|
+
if (event.type === 'error') {
|
|
262
|
+
log.error(`OpenAI error event for ${sessionId}:`, JSON.stringify(event));
|
|
263
|
+
} else {
|
|
264
|
+
log.info(`OpenAI event for ${sessionId}: ${event.type}`);
|
|
265
|
+
}
|
|
266
|
+
} catch { /* not JSON, skip */ }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Forward OpenAI → Client
|
|
270
|
+
if (clientWs.readyState === WebSocket.OPEN) {
|
|
271
|
+
try {
|
|
272
|
+
if (isBinary) {
|
|
273
|
+
clientWs.send(data, { binary: true });
|
|
274
|
+
} else {
|
|
275
|
+
clientWs.send(data.toString());
|
|
276
|
+
}
|
|
277
|
+
} catch (err) {
|
|
278
|
+
log.error(`Error forwarding OpenAI → client (${sessionId}):`, err.message);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
openaiWs.on('close', (code, reason) => {
|
|
284
|
+
openaiClosed = true;
|
|
285
|
+
const reasonStr = reason?.toString() || '';
|
|
286
|
+
log.info(`OpenAI disconnected for ${sessionId} (code=${code}${reasonStr ? ', reason=' + reasonStr : ''})`);
|
|
287
|
+
cleanup();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
openaiWs.on('error', (err) => {
|
|
291
|
+
log.error(`OpenAI error for ${sessionId}:`, err.message);
|
|
292
|
+
|
|
293
|
+
// Forward a structured error to the client so it can display something useful
|
|
294
|
+
if (clientWs.readyState === WebSocket.OPEN) {
|
|
295
|
+
try {
|
|
296
|
+
clientWs.send(JSON.stringify({
|
|
297
|
+
type: 'error',
|
|
298
|
+
error: `OpenAI connection error: ${err.message}`,
|
|
299
|
+
}));
|
|
300
|
+
} catch { /* swallow */ }
|
|
301
|
+
}
|
|
302
|
+
cleanup();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// ── Client WebSocket events ─────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
clientWs.on('message', (data, isBinary) => {
|
|
308
|
+
// Log what the client is sending
|
|
309
|
+
if (!isBinary) {
|
|
310
|
+
try {
|
|
311
|
+
const clientMsg = JSON.parse(data.toString());
|
|
312
|
+
log.info(`Client event for ${sessionId}: ${clientMsg.type} (len=${data.length})`);
|
|
313
|
+
} catch { /* not JSON */ }
|
|
314
|
+
} else {
|
|
315
|
+
log.info(`Client binary for ${sessionId}: len=${data.length}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Forward Client → OpenAI
|
|
319
|
+
// IMPORTANT: Send text frames as strings, not Buffers, so ws sends them
|
|
320
|
+
// as text frames (opcode 0x1) instead of binary frames (opcode 0x2).
|
|
321
|
+
// OpenAI Realtime API expects JSON as text frames.
|
|
322
|
+
if (openaiWs.readyState === WebSocket.OPEN) {
|
|
323
|
+
try {
|
|
324
|
+
if (isBinary) {
|
|
325
|
+
openaiWs.send(data);
|
|
326
|
+
} else {
|
|
327
|
+
openaiWs.send(data.toString());
|
|
328
|
+
}
|
|
329
|
+
} catch (err) {
|
|
330
|
+
log.error(`Error forwarding client → OpenAI (${sessionId}):`, err.message);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
clientWs.on('close', (code) => {
|
|
336
|
+
clientClosed = true;
|
|
337
|
+
log.info(`Client disconnected for ${sessionId} (code=${code})`);
|
|
338
|
+
cleanup();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
clientWs.on('error', (err) => {
|
|
342
|
+
log.error(`Client error for ${sessionId}:`, err.message);
|
|
343
|
+
cleanup();
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
log.info('Realtime voice relay ready at /api/realtime');
|
|
348
|
+
return wss;
|
|
349
|
+
}
|