@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,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
|
+
}
|