@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.

Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/bin/uplink.js +279 -0
  4. package/middleware/error-handler.js +69 -0
  5. package/package.json +93 -0
  6. package/public/css/agents.36b98c0f.css +1469 -0
  7. package/public/css/agents.css +1469 -0
  8. package/public/css/app.a6a7f8f5.css +2731 -0
  9. package/public/css/app.css +2731 -0
  10. package/public/css/artifacts.css +444 -0
  11. package/public/css/commands.css +55 -0
  12. package/public/css/connection.css +131 -0
  13. package/public/css/dashboard.css +233 -0
  14. package/public/css/developer.css +328 -0
  15. package/public/css/files.css +123 -0
  16. package/public/css/markdown.css +156 -0
  17. package/public/css/message-actions.css +278 -0
  18. package/public/css/mobile.css +614 -0
  19. package/public/css/panels-unified.css +483 -0
  20. package/public/css/premium.css +415 -0
  21. package/public/css/realtime.css +189 -0
  22. package/public/css/satellites.css +401 -0
  23. package/public/css/shortcuts.css +185 -0
  24. package/public/css/split-view.4def0262.css +673 -0
  25. package/public/css/split-view.css +673 -0
  26. package/public/css/theme-generator.css +391 -0
  27. package/public/css/themes.css +387 -0
  28. package/public/css/timestamps.css +54 -0
  29. package/public/css/variables.css +78 -0
  30. package/public/dist/bundle.b55050c4.js +15757 -0
  31. package/public/favicon.svg +24 -0
  32. package/public/img/agents/ada.png +0 -0
  33. package/public/img/agents/clarice.png +0 -0
  34. package/public/img/agents/dennis-nedry.png +0 -0
  35. package/public/img/agents/elliot-alderson.png +0 -0
  36. package/public/img/agents/main.png +0 -0
  37. package/public/img/agents/scotty.png +0 -0
  38. package/public/img/agents/top-flight-security.png +0 -0
  39. package/public/index.html +1083 -0
  40. package/public/js/agents-data.js +234 -0
  41. package/public/js/agents-ui.js +72 -0
  42. package/public/js/agents.js +1525 -0
  43. package/public/js/app.js +79 -0
  44. package/public/js/appearance-settings.js +111 -0
  45. package/public/js/artifacts.js +432 -0
  46. package/public/js/audio-queue.js +168 -0
  47. package/public/js/bootstrap.js +54 -0
  48. package/public/js/chat.js +1211 -0
  49. package/public/js/commands.js +581 -0
  50. package/public/js/connection-api.js +121 -0
  51. package/public/js/connection.js +1231 -0
  52. package/public/js/context-tracker.js +271 -0
  53. package/public/js/core.js +172 -0
  54. package/public/js/dashboard.js +452 -0
  55. package/public/js/developer.js +432 -0
  56. package/public/js/encryption.js +124 -0
  57. package/public/js/errors.js +122 -0
  58. package/public/js/event-bus.js +77 -0
  59. package/public/js/fetch-utils.js +171 -0
  60. package/public/js/file-handler.js +229 -0
  61. package/public/js/files.js +352 -0
  62. package/public/js/gateway-chat.js +538 -0
  63. package/public/js/logger.js +112 -0
  64. package/public/js/markdown.js +190 -0
  65. package/public/js/message-actions.js +431 -0
  66. package/public/js/message-renderer.js +288 -0
  67. package/public/js/missed-messages.js +235 -0
  68. package/public/js/mobile-debug.js +95 -0
  69. package/public/js/notifications.js +367 -0
  70. package/public/js/offline-queue.js +178 -0
  71. package/public/js/onboarding.js +543 -0
  72. package/public/js/panels.js +156 -0
  73. package/public/js/premium.js +412 -0
  74. package/public/js/realtime-voice.js +844 -0
  75. package/public/js/satellite-sync.js +256 -0
  76. package/public/js/satellite-ui.js +175 -0
  77. package/public/js/satellites.js +1516 -0
  78. package/public/js/settings.js +1087 -0
  79. package/public/js/shortcuts.js +381 -0
  80. package/public/js/split-chat.js +1234 -0
  81. package/public/js/split-resize.js +211 -0
  82. package/public/js/splitview.js +340 -0
  83. package/public/js/storage.js +408 -0
  84. package/public/js/streaming-handler.js +324 -0
  85. package/public/js/stt-settings.js +316 -0
  86. package/public/js/theme-generator.js +661 -0
  87. package/public/js/themes.js +164 -0
  88. package/public/js/timestamps.js +198 -0
  89. package/public/js/tts-settings.js +575 -0
  90. package/public/js/ui.js +267 -0
  91. package/public/js/update-notifier.js +143 -0
  92. package/public/js/utils/constants.js +165 -0
  93. package/public/js/utils/sanitize.js +93 -0
  94. package/public/js/utils/sse-parser.js +195 -0
  95. package/public/js/voice.js +883 -0
  96. package/public/manifest.json +58 -0
  97. package/public/moon_texture.jpg +0 -0
  98. package/public/sw.js +221 -0
  99. package/public/three.min.js +6 -0
  100. package/server/channel.js +529 -0
  101. package/server/chat.js +270 -0
  102. package/server/config-store.js +362 -0
  103. package/server/config.js +159 -0
  104. package/server/context.js +131 -0
  105. package/server/gateway-commands.js +211 -0
  106. package/server/gateway-proxy.js +318 -0
  107. package/server/index.js +22 -0
  108. package/server/logger.js +89 -0
  109. package/server/middleware/auth.js +188 -0
  110. package/server/middleware.js +218 -0
  111. package/server/openclaw-discover.js +308 -0
  112. package/server/premium/index.js +156 -0
  113. package/server/premium/license.js +140 -0
  114. package/server/realtime/bridge.js +837 -0
  115. package/server/realtime/index.js +349 -0
  116. package/server/realtime/tts-stream.js +446 -0
  117. package/server/routes/agents.js +564 -0
  118. package/server/routes/artifacts.js +174 -0
  119. package/server/routes/chat.js +311 -0
  120. package/server/routes/config-settings.js +345 -0
  121. package/server/routes/config.js +603 -0
  122. package/server/routes/files.js +307 -0
  123. package/server/routes/index.js +18 -0
  124. package/server/routes/media.js +451 -0
  125. package/server/routes/missed-messages.js +107 -0
  126. package/server/routes/premium.js +75 -0
  127. package/server/routes/push.js +156 -0
  128. package/server/routes/satellite.js +406 -0
  129. package/server/routes/status.js +251 -0
  130. package/server/routes/stt.js +35 -0
  131. package/server/routes/voice.js +260 -0
  132. package/server/routes/webhooks.js +203 -0
  133. package/server/routes.js +206 -0
  134. package/server/runtime-config.js +336 -0
  135. package/server/share.js +305 -0
  136. package/server/stt/faster-whisper.js +72 -0
  137. package/server/stt/groq.js +51 -0
  138. package/server/stt/index.js +196 -0
  139. package/server/stt/openai.js +49 -0
  140. package/server/sync.js +244 -0
  141. package/server/tailscale-https.js +175 -0
  142. package/server/tts.js +646 -0
  143. package/server/update-checker.js +172 -0
  144. package/server/utils/filename.js +129 -0
  145. package/server/utils.js +147 -0
  146. package/server/watchdog.js +318 -0
  147. package/server/websocket/broadcast.js +359 -0
  148. package/server/websocket/connections.js +339 -0
  149. package/server/websocket/index.js +215 -0
  150. package/server/websocket/routing.js +277 -0
  151. package/server/websocket/sync.js +102 -0
  152. package/server.js +404 -0
  153. package/utils/detect-tool-usage.js +93 -0
  154. package/utils/errors.js +158 -0
  155. package/utils/html-escape.js +84 -0
  156. package/utils/id-sanitize.js +94 -0
  157. package/utils/response.js +130 -0
  158. package/utils/with-retry.js +105 -0
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Status Routes - Health checks, activity feed, and session status
3
+ */
4
+
5
+ import fs from 'fs/promises';
6
+ import { badRequest, internalError, ErrorCodes } from '../../utils/errors.js';
7
+ import { getOpenClawStateDir } from '../openclaw-discover.js';
8
+ import { sanitizeSatelliteId } from '../../utils/id-sanitize.js';
9
+
10
+ /**
11
+ * Setup status routes
12
+ * @param {Express} app - Express app instance
13
+ * @param {Object} context - Request context
14
+ */
15
+ // Validation constants
16
+ const MAX_TYPE_LENGTH = 32;
17
+ const MAX_SUMMARY_LENGTH = 500;
18
+ const MAX_DETAILS_LENGTH = 5000;
19
+
20
+ export function setupStatusRoutes(app, context) {
21
+ const {
22
+ fetch: fetchWithTimeout,
23
+ log,
24
+ config,
25
+ requestHelpers,
26
+ } = context;
27
+
28
+ const { isProcessing, activeRequests, MAX_CONCURRENT_REQUESTS } = requestHelpers;
29
+ const {
30
+ TTS_VOICE_NAME,
31
+ ACTIVITY_FILE,
32
+ MESSAGES_FILE,
33
+ MAX_ACTIVITY_ITEMS,
34
+ GATEWAY_URL,
35
+ GATEWAY_TOKEN,
36
+ SESSION_USER,
37
+ } = config;
38
+
39
+ // ===========================================
40
+ // Status
41
+ // ===========================================
42
+
43
+ app.get('/api/status', (req, res) => {
44
+ res.json({
45
+ processing: isProcessing(),
46
+ activeRequests: activeRequests.size,
47
+ maxConcurrent: MAX_CONCURRENT_REQUESTS
48
+ });
49
+ });
50
+
51
+ app.get('/api/health', (req, res) => {
52
+ res.json({
53
+ status: 'ok',
54
+ processing: isProcessing(),
55
+ activeRequests: activeRequests.size,
56
+ timestamp: new Date().toISOString(),
57
+ tts: `ElevenLabs (${TTS_VOICE_NAME})`,
58
+ whisper: 'whisper-small',
59
+ websocket: true
60
+ });
61
+ });
62
+
63
+ // ===========================================
64
+ // Activity Feed
65
+ // ===========================================
66
+
67
+ app.get('/api/activity', async (req, res) => {
68
+ try {
69
+ const data = await fs.readFile(ACTIVITY_FILE, 'utf8').catch(() => '[]');
70
+ res.json(JSON.parse(data));
71
+ } catch (e) {
72
+ res.json([]);
73
+ }
74
+ });
75
+
76
+ app.post('/api/activity', async (req, res) => {
77
+ try {
78
+ const { type, summary, details, timestamp } = req.body;
79
+
80
+ // Validate required fields
81
+ if (!type || !summary) {
82
+ return badRequest(res, 'type and summary required', ErrorCodes.MISSING_FIELD);
83
+ }
84
+
85
+ // Validate type
86
+ if (typeof type !== 'string' || type.length > MAX_TYPE_LENGTH) {
87
+ return badRequest(res, `type must be a string with max ${MAX_TYPE_LENGTH} chars`, ErrorCodes.VALIDATION_ERROR);
88
+ }
89
+
90
+ // Validate summary
91
+ if (typeof summary !== 'string' || summary.length > MAX_SUMMARY_LENGTH) {
92
+ return badRequest(res, `summary must be a string with max ${MAX_SUMMARY_LENGTH} chars`, ErrorCodes.VALIDATION_ERROR);
93
+ }
94
+
95
+ // Validate details if provided
96
+ if (details !== undefined && details !== null) {
97
+ if (typeof details !== 'string' || details.length > MAX_DETAILS_LENGTH) {
98
+ return badRequest(res, `details must be a string with max ${MAX_DETAILS_LENGTH} chars`, ErrorCodes.VALIDATION_ERROR);
99
+ }
100
+ }
101
+
102
+ let activities = [];
103
+ try {
104
+ const data = await fs.readFile(ACTIVITY_FILE, 'utf8');
105
+ activities = JSON.parse(data);
106
+ } catch (e) {}
107
+
108
+ activities.unshift({
109
+ id: Date.now(),
110
+ type: type.slice(0, MAX_TYPE_LENGTH),
111
+ summary: summary.slice(0, MAX_SUMMARY_LENGTH),
112
+ details: details ? details.slice(0, MAX_DETAILS_LENGTH) : null,
113
+ timestamp: timestamp || new Date().toISOString()
114
+ });
115
+
116
+ if (activities.length > MAX_ACTIVITY_ITEMS) {
117
+ activities = activities.slice(0, MAX_ACTIVITY_ITEMS);
118
+ }
119
+
120
+ await fs.writeFile(ACTIVITY_FILE, JSON.stringify(activities, null, 2));
121
+ res.json({ ok: true });
122
+ } catch (e) {
123
+ internalError(res, e.message, ErrorCodes.INTERNAL_ERROR);
124
+ }
125
+ });
126
+
127
+ // ===========================================
128
+ // Message Sync
129
+ // ===========================================
130
+
131
+ app.get('/api/messages/sync', async (req, res) => {
132
+ try {
133
+ // Validate since param before parseInt
134
+ let since = 0;
135
+ if (req.query.since !== undefined) {
136
+ const sinceStr = String(req.query.since).trim();
137
+ if (!/^\d+$/.test(sinceStr)) {
138
+ return badRequest(res, 'since must be a numeric timestamp', ErrorCodes.INVALID_FORMAT);
139
+ }
140
+ since = parseInt(sinceStr, 10);
141
+ if (isNaN(since) || since < 0) {
142
+ return badRequest(res, 'since must be a valid positive number', ErrorCodes.VALIDATION_ERROR);
143
+ }
144
+ }
145
+
146
+ const data = await fs.readFile(MESSAGES_FILE, 'utf8').catch(() => '[]');
147
+ const messages = JSON.parse(data);
148
+ const filtered = since ? messages.filter(m => m.id > since) : messages;
149
+ res.json({ ok: true, messages: filtered });
150
+ } catch (e) {
151
+ res.json({ ok: false, messages: [], error: e.message });
152
+ }
153
+ });
154
+
155
+ // ===========================================
156
+ // Session Status
157
+ // ===========================================
158
+
159
+ /**
160
+ * Get session status for a satellite
161
+ * Returns derived session key and connection status
162
+ */
163
+ // ===========================================
164
+ // Context Tracking
165
+ // ===========================================
166
+
167
+ /**
168
+ * Get context window usage for a satellite session
169
+ * Reads the gateway session store file directly for accurate token counts
170
+ */
171
+ app.get('/api/session/context', async (req, res) => {
172
+ const satelliteId = sanitizeSatelliteId(req.query.satelliteId);
173
+ const agentId = req.query.agentId ? String(req.query.agentId).replace(/[^a-z0-9-]/g, '').substring(0, 64) || 'main' : 'main';
174
+
175
+ // Derive session key - must match channel.js logic
176
+ const sessionKey = satelliteId === 'main'
177
+ ? `agent:${agentId}:main`
178
+ : `agent:${agentId}:uplink:satellite:${satelliteId}`;
179
+
180
+ try {
181
+ // Read the gateway session store (supports WSL paths on Windows)
182
+ const path = await import('path');
183
+ const stateDir = await getOpenClawStateDir();
184
+ const sessionsFile = path.join(stateDir, 'agents', agentId, 'sessions', 'sessions.json');
185
+
186
+ const data = await fs.readFile(sessionsFile, 'utf8');
187
+ const sessions = JSON.parse(data);
188
+ const session = sessions[sessionKey];
189
+
190
+ if (session) {
191
+ res.json({
192
+ ok: true,
193
+ sessionKey,
194
+ totalTokens: session.totalTokens || 0,
195
+ contextTokens: session.contextTokens || 200000,
196
+ inputTokens: session.inputTokens || 0,
197
+ outputTokens: session.outputTokens || 0,
198
+ });
199
+ } else {
200
+ res.json({
201
+ ok: true,
202
+ sessionKey,
203
+ totalTokens: 0,
204
+ contextTokens: 200000,
205
+ inputTokens: 0,
206
+ outputTokens: 0,
207
+ });
208
+ }
209
+ } catch (e) {
210
+ log('warn', `[Context] Failed to read session store: ${e.message}`);
211
+ res.json({
212
+ ok: false,
213
+ error: e.message,
214
+ totalTokens: 0,
215
+ contextTokens: 200000,
216
+ });
217
+ }
218
+ });
219
+
220
+ app.get('/api/session/status', async (req, res) => {
221
+ const satelliteId = sanitizeSatelliteId(req.query.satelliteId);
222
+ const agentId = req.query.agentId ? String(req.query.agentId).replace(/[^a-z0-9-]/g, '').substring(0, 64) || 'main' : 'main';
223
+
224
+ // Derive session key - must match channel.js logic
225
+ // Main satellite shares session with Dashboard: agent:{agentId}:main
226
+ // Other satellites get isolated: agent:{agentId}:uplink:satellite:<satelliteId>
227
+ const fullSessionKey = satelliteId === 'main'
228
+ ? `agent:${agentId}:main`
229
+ : `agent:${agentId}:uplink:satellite:${satelliteId}`;
230
+
231
+ // Check gateway health
232
+ let gatewayConnected = false;
233
+ try {
234
+ const healthCheck = await fetchWithTimeout(`${GATEWAY_URL}/health`, {
235
+ method: 'GET',
236
+ headers: { 'Authorization': `Bearer ${GATEWAY_TOKEN}` }
237
+ }, 3000);
238
+ gatewayConnected = healthCheck.ok;
239
+ } catch {
240
+ gatewayConnected = false;
241
+ }
242
+
243
+ res.json({
244
+ satelliteId,
245
+ sessionKey: fullSessionKey,
246
+ gatewayConnected,
247
+ gatewayUrl: GATEWAY_URL,
248
+ timestamp: Date.now()
249
+ });
250
+ });
251
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * STT Routes - Speech-to-Text testing endpoint
3
+ */
4
+
5
+ import { testSTT } from '../stt/index.js';
6
+ import { internalError, ErrorCodes } from '../../utils/errors.js';
7
+ import { requirePremium } from '../premium/index.js';
8
+
9
+ /**
10
+ * Setup STT routes
11
+ * @param {Express} app - Express app instance
12
+ * @param {Object} context - Request context
13
+ */
14
+ export function setupSTTRoutes(app, context) {
15
+ const { strictLimiter, log } = context;
16
+
17
+ /**
18
+ * Test current STT provider configuration
19
+ * POST /api/stt/test
20
+ *
21
+ * Optionally accepts multipart audio file for a real transcription test.
22
+ * Without a file, just validates the config (API key present, server reachable).
23
+ *
24
+ * Returns: { success: boolean, provider: string, transcription?: string, error?: string }
25
+ */
26
+ app.post('/api/stt/test', requirePremium('Speech-to-text'), strictLimiter, async (req, res) => {
27
+ try {
28
+ const result = await testSTT();
29
+ res.json(result);
30
+ } catch (error) {
31
+ log('error', '[STT] Test endpoint error:', error.message);
32
+ internalError(res, 'Failed to test STT configuration', ErrorCodes.CONFIG_ERROR);
33
+ }
34
+ });
35
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Voice Routes - Audio transcription and voice chat
3
+ */
4
+
5
+ import fs from 'fs/promises';
6
+ import path from 'path';
7
+ import { fileTypeFromBuffer } from 'file-type';
8
+ import { sanitizeFilename } from '../utils/filename.js';
9
+ import { badRequest, internalError, ErrorCodes } from '../../utils/errors.js';
10
+ import { requirePremium } from '../premium/index.js';
11
+
12
+ // ===========================================
13
+ // Temp File Cleanup Utilities
14
+ // ===========================================
15
+
16
+ /**
17
+ * Creates a cleanup tracker for temp files
18
+ * @returns {Object} Cleanup tracker with add/cleanup methods
19
+ */
20
+ function createTempFileTracker() {
21
+ const files = new Set();
22
+ return {
23
+ add(filePath) {
24
+ if (filePath) files.add(filePath);
25
+ },
26
+ async cleanup() {
27
+ const promises = [...files].map(f =>
28
+ fs.unlink(f).catch(() => {}) // Ignore errors if file doesn't exist
29
+ );
30
+ await Promise.all(promises);
31
+ files.clear();
32
+ }
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Clean up orphaned temp files from previous sessions
38
+ * Removes files matching voice upload patterns older than maxAge
39
+ * @param {string} uploadDir - Directory to clean
40
+ * @param {number} maxAgeMs - Max age in milliseconds (default: 1 hour)
41
+ * @param {Function} log - Logger function
42
+ */
43
+ export async function cleanupOrphanedTempFiles(uploadDir, maxAgeMs = 60 * 60 * 1000, log = console.log) {
44
+ try {
45
+ const files = await fs.readdir(uploadDir);
46
+ const now = Date.now();
47
+ let cleaned = 0;
48
+
49
+ for (const file of files) {
50
+ // Match multer-style temp files (random hex names, with or without extensions)
51
+ if (/^[a-f0-9]{32}(\.(webm|mp4|m4a|wav|ogg))?$/i.test(file)) {
52
+ const filePath = path.join(uploadDir, file);
53
+ try {
54
+ const stat = await fs.stat(filePath);
55
+ if (now - stat.mtimeMs > maxAgeMs) {
56
+ await fs.unlink(filePath);
57
+ cleaned++;
58
+ }
59
+ } catch {
60
+ // File may have been removed already
61
+ }
62
+ }
63
+ }
64
+
65
+ if (cleaned > 0) {
66
+ log('info', `[Voice] Cleaned up ${cleaned} orphaned temp file(s)`);
67
+ }
68
+ } catch (error) {
69
+ // Upload directory may not exist yet
70
+ if (error.code !== 'ENOENT') {
71
+ log('warn', `[Voice] Temp cleanup error: ${error.message}`);
72
+ }
73
+ }
74
+ }
75
+
76
+ // ===========================================
77
+ // Wake Word Handling
78
+ // ===========================================
79
+
80
+ function buildWakePatterns(word) {
81
+ const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
82
+ return [
83
+ new RegExp(`^hey[\\s,\\.]*${escaped}`, 'i'),
84
+ new RegExp(`^hi[\\s,\\.]*${escaped}`, 'i'),
85
+ new RegExp(`^yo[\\s,\\.]*${escaped}`, 'i'),
86
+ new RegExp(`^${escaped}[\\s,\\.]`, 'i'),
87
+ new RegExp(`^okay[\\s,\\.]*${escaped}`, 'i'),
88
+ new RegExp(`^ok[\\s,\\.]*${escaped}`, 'i'),
89
+ new RegExp(`${escaped}[\\s,\\.]+`, 'i'),
90
+ ];
91
+ }
92
+
93
+ function hasWakeWord(text, patterns) {
94
+ return patterns.some(pattern => pattern.test(text.trim()));
95
+ }
96
+
97
+ function stripWakeWord(text, patterns) {
98
+ let cleaned = text.trim();
99
+ for (const pattern of patterns) {
100
+ cleaned = cleaned.replace(pattern, '').trim();
101
+ }
102
+ return cleaned.replace(/^[,.\s]+/, '').trim();
103
+ }
104
+
105
+ /**
106
+ * Setup voice routes
107
+ * @param {Express} app - Express app instance
108
+ * @param {Object} context - Request context
109
+ */
110
+ export function setupVoiceRoutes(app, context) {
111
+ const {
112
+ audioUpload,
113
+ transcribe,
114
+ chat,
115
+ chatWithParallelTTS,
116
+ generateTTS,
117
+ log,
118
+ config,
119
+ requestHelpers,
120
+ broadcastToAll,
121
+ } = context;
122
+
123
+ const { canAcceptRequest, startRequest, endRequest, activeRequests, MAX_CONCURRENT_REQUESTS } = requestHelpers;
124
+ const { ALLOWED_AUDIO_TYPES, WAKE_WORD, SESSION_USER, UPLOAD_DIR } = config;
125
+
126
+ const WAKE_PATTERNS = buildWakePatterns(WAKE_WORD);
127
+
128
+ // Clean up orphaned temp files on startup
129
+ if (UPLOAD_DIR) {
130
+ cleanupOrphanedTempFiles(UPLOAD_DIR, 60 * 60 * 1000, log);
131
+ }
132
+
133
+ // ===========================================
134
+ // Voice Chat
135
+ // ===========================================
136
+
137
+ app.post('/api/voice', requirePremium('Voice chat'), (req, res) => {
138
+ audioUpload(req, res, async (err) => {
139
+ if (err) {
140
+ return badRequest(res, 'Upload failed: ' + err.message, ErrorCodes.UPLOAD_FAILED);
141
+ }
142
+
143
+ if (!canAcceptRequest()) {
144
+ log('warn', `Rejecting request - at capacity (${activeRequests.size}/${MAX_CONCURRENT_REQUESTS})`);
145
+ return res.status(429).json({ error: true, message: 'Server busy. Please wait a moment.', code: 'RATE_LIMITED' });
146
+ }
147
+
148
+ const requestId = startRequest('voice');
149
+ const startTime = Date.now();
150
+ const handsFree = req.body?.handsFree === 'true';
151
+
152
+ // Track temp files for cleanup
153
+ const tempFiles = createTempFileTracker();
154
+
155
+ try {
156
+ if (!req.file) {
157
+ return badRequest(res, 'No audio file provided', ErrorCodes.MISSING_FIELD);
158
+ }
159
+
160
+ // Track the original uploaded file
161
+ tempFiles.add(req.file.path);
162
+
163
+ log('debug', `[Voice] Received audio: ${req.file.size} bytes (handsFree: ${handsFree})`);
164
+
165
+ // Validate audio type
166
+ const audioBuffer = await fs.readFile(req.file.path);
167
+ const detectedAudioType = await fileTypeFromBuffer(audioBuffer);
168
+
169
+ if (!detectedAudioType || !ALLOWED_AUDIO_TYPES.includes(detectedAudioType.mime)) {
170
+ log('warn', `[Voice] Rejected invalid audio type: ${detectedAudioType?.mime || 'unknown'}`);
171
+ return badRequest(res, 'Invalid audio type', ErrorCodes.INVALID_FILE_TYPE);
172
+ }
173
+
174
+ // Rename with proper extension (sanitize originalname to prevent path traversal)
175
+ const safeOriginalname = sanitizeFilename(req.file.originalname || '');
176
+ const ext = safeOriginalname?.endsWith('.mp4') ? '.mp4' :
177
+ safeOriginalname?.endsWith('.m4a') ? '.m4a' :
178
+ req.file.mimetype?.includes('mp4') ? '.mp4' :
179
+ req.file.mimetype?.includes('webm') ? '.webm' : '.webm';
180
+ const inputPath = `${req.file.path}${ext}`;
181
+ await fs.rename(req.file.path, inputPath);
182
+
183
+ // Update tracker: add renamed path (original path no longer exists after rename)
184
+ tempFiles.add(inputPath);
185
+
186
+ // Transcribe
187
+ log('debug', 'Transcribing with OpenAI Whisper...');
188
+ if (broadcastToAll) broadcastToAll({ type: 'voiceStatus', stage: 'transcribing', label: 'Transcribing speech...' });
189
+ const transcription = await transcribe(inputPath);
190
+ log('debug', `Transcription: "${transcription}"`);
191
+
192
+ if (!transcription || transcription.length < 2) {
193
+ return res.json({
194
+ transcription: '',
195
+ response: '',
196
+ audioUrl: null,
197
+ message: 'No speech detected'
198
+ });
199
+ }
200
+
201
+ // Check wake word in hands-free mode
202
+ if (handsFree && !hasWakeWord(transcription, WAKE_PATTERNS)) {
203
+ log('debug', 'No wake word detected, ignoring');
204
+ return res.json({ ignored: true });
205
+ }
206
+
207
+ const message = handsFree ? (stripWakeWord(transcription, WAKE_PATTERNS) || "Hey, what's up?") : transcription;
208
+
209
+ // Get response with parallel TTS
210
+ if (broadcastToAll) broadcastToAll({ type: 'voiceStatus', stage: 'thinking', label: 'Thinking...' });
211
+ let response = '';
212
+ let audioUrl = null;
213
+ let audioUrls = [];
214
+
215
+ try {
216
+ const result = await chatWithParallelTTS(message, SESSION_USER);
217
+ response = result.response;
218
+ audioUrl = result.audioUrl;
219
+ audioUrls = result.audioUrls || [];
220
+ } catch (chatError) {
221
+ log('error', 'Chat with parallel TTS failed, falling back:', chatError.message);
222
+ try {
223
+ response = await chat(message, SESSION_USER, 'voice');
224
+ try {
225
+ audioUrl = await generateTTS(response);
226
+ audioUrls = audioUrl ? [audioUrl] : [];
227
+ } catch (ttsError) {
228
+ log('error', 'TTS fallback error:', ttsError.message);
229
+ }
230
+ } catch (fallbackError) {
231
+ throw fallbackError;
232
+ }
233
+ }
234
+
235
+ if (broadcastToAll) broadcastToAll({ type: 'voiceStatus', stage: 'complete', label: 'Done' });
236
+ const elapsed = Date.now() - startTime;
237
+ log('info', `[Voice] Complete in ${elapsed}ms (${audioUrls.length} audio chunks)`);
238
+
239
+ res.json({
240
+ transcription: handsFree ? message : transcription,
241
+ response,
242
+ audioUrl,
243
+ audioUrls,
244
+ elapsed
245
+ });
246
+
247
+ } catch (error) {
248
+ log('error', 'Voice chat error:', error);
249
+ const errorMessage = process.env.NODE_ENV === 'production'
250
+ ? 'Voice processing failed'
251
+ : error.message;
252
+ internalError(res, errorMessage, ErrorCodes.INTERNAL_ERROR);
253
+ } finally {
254
+ // Always cleanup temp files, regardless of success or error
255
+ await tempFiles.cleanup();
256
+ endRequest(requestId);
257
+ }
258
+ });
259
+ });
260
+ }