@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,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Faster-Whisper STT Provider
|
|
3
|
+
*
|
|
4
|
+
* Calls a local faster-whisper-server instance via its OpenAI-compatible API.
|
|
5
|
+
* User must run faster-whisper-server separately (e.g., via pip or Docker).
|
|
6
|
+
* No API key needed — it's a local service.
|
|
7
|
+
*
|
|
8
|
+
* Server: https://github.com/fedirz/faster-whisper-server
|
|
9
|
+
* Install: pip install faster-whisper-server
|
|
10
|
+
* Run: faster-whisper-server --host 0.0.0.0 --port 8000
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'fs/promises';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Transcribe audio using a local Faster-Whisper server
|
|
18
|
+
* @param {string} audioPath - Path to the audio file
|
|
19
|
+
* @param {Object} config - Runtime config object
|
|
20
|
+
* @returns {Promise<string>} Transcribed text
|
|
21
|
+
*/
|
|
22
|
+
export async function transcribe(audioPath, config) {
|
|
23
|
+
const baseUrl = config.fasterWhisperUrl || process.env.FASTER_WHISPER_URL;
|
|
24
|
+
if (!baseUrl) {
|
|
25
|
+
throw new Error('Faster-Whisper server URL not configured');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Normalize URL — remove trailing slash
|
|
29
|
+
const url = `${baseUrl.replace(/\/+$/, '')}/v1/audio/transcriptions`;
|
|
30
|
+
|
|
31
|
+
const audioBuffer = await fs.readFile(audioPath);
|
|
32
|
+
const ext = path.extname(audioPath).slice(1) || 'webm';
|
|
33
|
+
const blob = new Blob([audioBuffer], { type: `audio/${ext}` });
|
|
34
|
+
|
|
35
|
+
const formData = new FormData();
|
|
36
|
+
formData.append('file', blob, `audio.${ext}`);
|
|
37
|
+
formData.append('model', 'whisper-large-v3-turbo'); // faster-whisper-server ignores this if model is fixed
|
|
38
|
+
formData.append('language', 'en');
|
|
39
|
+
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const response = await fetch(url, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
body: formData,
|
|
47
|
+
signal: controller.signal,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
clearTimeout(timeoutId);
|
|
51
|
+
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
const error = await response.text();
|
|
54
|
+
throw new Error(`Faster-Whisper server error: ${response.status} - ${error}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const data = await response.json();
|
|
58
|
+
return data.text || '';
|
|
59
|
+
} catch (error) {
|
|
60
|
+
clearTimeout(timeoutId);
|
|
61
|
+
|
|
62
|
+
if (error.name === 'AbortError') {
|
|
63
|
+
throw new Error('Faster-Whisper request timed out (30s). Is the server running?');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (error.code === 'ECONNREFUSED') {
|
|
67
|
+
throw new Error(`Faster-Whisper server not reachable at ${baseUrl}. Is it running?`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Groq STT Provider
|
|
3
|
+
*
|
|
4
|
+
* Uses Groq's OpenAI-compatible Audio Transcriptions API.
|
|
5
|
+
* Requires a Groq API key. Free tier available.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs/promises';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
|
|
11
|
+
const GROQ_BASE_URL = 'https://api.groq.com/openai/v1/audio/transcriptions';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Transcribe audio using Groq API
|
|
15
|
+
* @param {string} audioPath - Path to the audio file
|
|
16
|
+
* @param {Object} config - Runtime config object
|
|
17
|
+
* @returns {Promise<string>} Transcribed text
|
|
18
|
+
*/
|
|
19
|
+
export async function transcribe(audioPath, config) {
|
|
20
|
+
const apiKey = config.groqApiKey || process.env.GROQ_API_KEY;
|
|
21
|
+
if (!apiKey) {
|
|
22
|
+
throw new Error('Groq API key not configured');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const model = config.groqSttModel || 'whisper-large-v3-turbo';
|
|
26
|
+
|
|
27
|
+
const audioBuffer = await fs.readFile(audioPath);
|
|
28
|
+
const ext = path.extname(audioPath).slice(1) || 'webm';
|
|
29
|
+
const blob = new Blob([audioBuffer], { type: `audio/${ext}` });
|
|
30
|
+
|
|
31
|
+
const formData = new FormData();
|
|
32
|
+
formData.append('file', blob, `audio.${ext}`);
|
|
33
|
+
formData.append('model', model);
|
|
34
|
+
formData.append('language', 'en');
|
|
35
|
+
|
|
36
|
+
const response = await fetch(GROQ_BASE_URL, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: {
|
|
39
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
40
|
+
},
|
|
41
|
+
body: formData,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
const error = await response.text();
|
|
46
|
+
throw new Error(`Groq API error: ${response.status} - ${error}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const data = await response.json();
|
|
50
|
+
return data.text || '';
|
|
51
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STT Module - Speech-to-Text provider abstraction
|
|
3
|
+
*
|
|
4
|
+
* Reads runtime config to determine which STT provider to use,
|
|
5
|
+
* then delegates to the appropriate provider module.
|
|
6
|
+
*
|
|
7
|
+
* Providers:
|
|
8
|
+
* - openai: OpenAI Whisper API (requires OpenAI API key)
|
|
9
|
+
* - groq: Groq API (requires Groq API key, free tier available)
|
|
10
|
+
* - faster-whisper: Local faster-whisper-server (requires running server)
|
|
11
|
+
* - none: STT disabled
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { loadConfig } from '../runtime-config.js';
|
|
15
|
+
import { transcribe as openaiTranscribe } from './openai.js';
|
|
16
|
+
import { transcribe as groqTranscribe } from './groq.js';
|
|
17
|
+
import { transcribe as fasterWhisperTranscribe } from './faster-whisper.js';
|
|
18
|
+
import { createLogger } from '../logger.js';
|
|
19
|
+
|
|
20
|
+
const log = createLogger('STT');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the effective STT provider.
|
|
24
|
+
* If sttProvider is 'none' but openaiApiKey exists, auto-detect to 'openai' (backward compat).
|
|
25
|
+
* @param {Object} config - Runtime config
|
|
26
|
+
* @returns {string} Effective provider name
|
|
27
|
+
*/
|
|
28
|
+
function resolveProvider(config) {
|
|
29
|
+
const provider = config.sttProvider;
|
|
30
|
+
|
|
31
|
+
// Explicit provider set — use it
|
|
32
|
+
if (provider && provider !== 'none') {
|
|
33
|
+
return provider;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Auto-detect: if openaiApiKey is available and no provider explicitly set, use OpenAI
|
|
37
|
+
if (config.openaiApiKey || process.env.OPENAI_API_KEY) {
|
|
38
|
+
return 'openai';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return 'none';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Transcribe audio using the configured STT provider
|
|
46
|
+
* @param {string} audioPath - Path to the audio file
|
|
47
|
+
* @returns {Promise<string>} Transcribed text, or empty string on error
|
|
48
|
+
*/
|
|
49
|
+
export async function transcribe(audioPath) {
|
|
50
|
+
const config = await loadConfig();
|
|
51
|
+
const provider = resolveProvider(config);
|
|
52
|
+
|
|
53
|
+
if (provider === 'none') {
|
|
54
|
+
log.warn('No STT provider configured. Set one in Settings → Voice & STT.');
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
let text = '';
|
|
60
|
+
|
|
61
|
+
switch (provider) {
|
|
62
|
+
case 'openai':
|
|
63
|
+
text = await openaiTranscribe(audioPath, config);
|
|
64
|
+
break;
|
|
65
|
+
case 'groq':
|
|
66
|
+
text = await groqTranscribe(audioPath, config);
|
|
67
|
+
break;
|
|
68
|
+
case 'faster-whisper':
|
|
69
|
+
text = await fasterWhisperTranscribe(audioPath, config);
|
|
70
|
+
break;
|
|
71
|
+
default:
|
|
72
|
+
log.error(`Unknown provider: ${provider}`);
|
|
73
|
+
return '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
log.debug(`[${provider}] Transcribed: "${text}"`);
|
|
77
|
+
return text;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
log.error(`[${provider}] Transcription error:`, error.message);
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Detect if a local Faster-Whisper server is running at localhost:8000
|
|
86
|
+
* @returns {Promise<{fasterWhisperAvailable: boolean}>}
|
|
87
|
+
*/
|
|
88
|
+
export async function detectLocalSTT() {
|
|
89
|
+
try {
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
|
92
|
+
const resp = await fetch('http://localhost:8000/health', {
|
|
93
|
+
signal: controller.signal,
|
|
94
|
+
}).catch(() => null);
|
|
95
|
+
clearTimeout(timeoutId);
|
|
96
|
+
|
|
97
|
+
if (resp) {
|
|
98
|
+
return { fasterWhisperAvailable: true };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Some servers don't have /health, try root
|
|
102
|
+
const controller2 = new AbortController();
|
|
103
|
+
const timeoutId2 = setTimeout(() => controller2.abort(), 2000);
|
|
104
|
+
const resp2 = await fetch('http://localhost:8000', {
|
|
105
|
+
signal: controller2.signal,
|
|
106
|
+
}).catch(() => null);
|
|
107
|
+
clearTimeout(timeoutId2);
|
|
108
|
+
|
|
109
|
+
return { fasterWhisperAvailable: !!resp2 };
|
|
110
|
+
} catch {
|
|
111
|
+
return { fasterWhisperAvailable: false };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Test the current STT configuration
|
|
117
|
+
* Verifies the provider is reachable and properly configured.
|
|
118
|
+
* @param {string} [audioPath] - Optional audio file to test with
|
|
119
|
+
* @returns {Promise<{success: boolean, provider: string, transcription?: string, error?: string}>}
|
|
120
|
+
*/
|
|
121
|
+
export async function testSTT(audioPath) {
|
|
122
|
+
const config = await loadConfig();
|
|
123
|
+
const provider = resolveProvider(config);
|
|
124
|
+
|
|
125
|
+
if (provider === 'none') {
|
|
126
|
+
return { success: false, provider: 'none', error: 'No STT provider configured' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// If no audio file provided, just validate config
|
|
130
|
+
if (!audioPath) {
|
|
131
|
+
try {
|
|
132
|
+
switch (provider) {
|
|
133
|
+
case 'openai': {
|
|
134
|
+
const key = config.openaiApiKey || process.env.OPENAI_API_KEY;
|
|
135
|
+
if (!key) return { success: false, provider, error: 'OpenAI API key not set' };
|
|
136
|
+
return { success: true, provider, message: 'OpenAI API key is configured' };
|
|
137
|
+
}
|
|
138
|
+
case 'groq': {
|
|
139
|
+
const key = config.groqApiKey || process.env.GROQ_API_KEY;
|
|
140
|
+
if (!key) return { success: false, provider, error: 'Groq API key not set' };
|
|
141
|
+
return { success: true, provider, message: 'Groq API key is configured' };
|
|
142
|
+
}
|
|
143
|
+
case 'faster-whisper': {
|
|
144
|
+
const url = config.fasterWhisperUrl || process.env.FASTER_WHISPER_URL;
|
|
145
|
+
if (!url) return { success: false, provider, error: 'Faster-Whisper server URL not set' };
|
|
146
|
+
// Try to reach the server
|
|
147
|
+
try {
|
|
148
|
+
const controller = new AbortController();
|
|
149
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
150
|
+
const resp = await fetch(`${url.replace(/\/+$/, '')}/health`, {
|
|
151
|
+
signal: controller.signal,
|
|
152
|
+
}).catch(() => null);
|
|
153
|
+
clearTimeout(timeoutId);
|
|
154
|
+
// Some servers don't have /health, so any response is fine
|
|
155
|
+
if (resp) {
|
|
156
|
+
return { success: true, provider, message: `Faster-Whisper server reachable at ${url}` };
|
|
157
|
+
}
|
|
158
|
+
// Try just connecting
|
|
159
|
+
const controller2 = new AbortController();
|
|
160
|
+
const timeoutId2 = setTimeout(() => controller2.abort(), 5000);
|
|
161
|
+
const resp2 = await fetch(url.replace(/\/+$/, ''), {
|
|
162
|
+
signal: controller2.signal,
|
|
163
|
+
}).catch(() => null);
|
|
164
|
+
clearTimeout(timeoutId2);
|
|
165
|
+
if (resp2) {
|
|
166
|
+
return { success: true, provider, message: `Faster-Whisper server reachable at ${url}` };
|
|
167
|
+
}
|
|
168
|
+
return { success: false, provider, error: `Cannot reach Faster-Whisper server at ${url}` };
|
|
169
|
+
} catch {
|
|
170
|
+
return { success: false, provider, error: `Cannot reach Faster-Whisper server at ${url}` };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
default:
|
|
174
|
+
return { success: false, provider, error: `Unknown provider: ${provider}` };
|
|
175
|
+
}
|
|
176
|
+
} catch (error) {
|
|
177
|
+
return { success: false, provider, error: error.message };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Test with actual audio file
|
|
182
|
+
try {
|
|
183
|
+
const text = await transcribe(audioPath);
|
|
184
|
+
return {
|
|
185
|
+
success: true,
|
|
186
|
+
provider,
|
|
187
|
+
transcription: text,
|
|
188
|
+
};
|
|
189
|
+
} catch (error) {
|
|
190
|
+
return {
|
|
191
|
+
success: false,
|
|
192
|
+
provider,
|
|
193
|
+
error: error.message,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI Whisper STT Provider
|
|
3
|
+
*
|
|
4
|
+
* Uses the OpenAI Audio Transcriptions API.
|
|
5
|
+
* Requires an OpenAI API key.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs/promises';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Transcribe audio using OpenAI Whisper API
|
|
13
|
+
* @param {string} audioPath - Path to the audio file
|
|
14
|
+
* @param {Object} config - Runtime config object
|
|
15
|
+
* @returns {Promise<string>} Transcribed text
|
|
16
|
+
*/
|
|
17
|
+
export async function transcribe(audioPath, config) {
|
|
18
|
+
const apiKey = config.openaiApiKey || process.env.OPENAI_API_KEY;
|
|
19
|
+
if (!apiKey) {
|
|
20
|
+
throw new Error('OpenAI API key not configured');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const model = config.openaiSttModel || 'whisper-1';
|
|
24
|
+
|
|
25
|
+
const audioBuffer = await fs.readFile(audioPath);
|
|
26
|
+
const ext = path.extname(audioPath).slice(1) || 'webm';
|
|
27
|
+
const blob = new Blob([audioBuffer], { type: `audio/${ext}` });
|
|
28
|
+
|
|
29
|
+
const formData = new FormData();
|
|
30
|
+
formData.append('file', blob, `audio.${ext}`);
|
|
31
|
+
formData.append('model', model);
|
|
32
|
+
formData.append('language', 'en');
|
|
33
|
+
|
|
34
|
+
const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: {
|
|
37
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
38
|
+
},
|
|
39
|
+
body: formData,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
const error = await response.text();
|
|
44
|
+
throw new Error(`OpenAI Whisper API error: ${response.status} - ${error}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const data = await response.json();
|
|
48
|
+
return data.text || '';
|
|
49
|
+
}
|
package/server/sync.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync Module - Encrypted cross-device sync
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'fs/promises';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import lockfile from 'proper-lockfile';
|
|
8
|
+
import { log } from './utils.js';
|
|
9
|
+
import { sanitizeSyncId } from '../utils/id-sanitize.js';
|
|
10
|
+
import { sendSuccess, sendError, sendNotFoundError } from '../utils/response.js';
|
|
11
|
+
import { verifyBearerToken } from './middleware.js';
|
|
12
|
+
|
|
13
|
+
// Maximum sync data size: 10MB (encrypted data can be large)
|
|
14
|
+
const MAX_SYNC_SIZE_BYTES = 10 * 1024 * 1024;
|
|
15
|
+
// Sync data TTL: 30 days of inactivity
|
|
16
|
+
const SYNC_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
17
|
+
// Minimum encryption key length (base64-encoded 256-bit key)
|
|
18
|
+
const MIN_ENCRYPTION_KEY_LENGTH = 32;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validate encrypted data structure
|
|
22
|
+
* Checks that the data appears to be properly encrypted (not plaintext)
|
|
23
|
+
* @param {any} encryptedData - The encrypted data payload
|
|
24
|
+
* @returns {{ valid: boolean, error?: string }}
|
|
25
|
+
*/
|
|
26
|
+
function validateEncryptedData(encryptedData) {
|
|
27
|
+
if (!encryptedData) {
|
|
28
|
+
return { valid: false, error: 'encryptedData required' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// If it's a string, check it looks like encrypted data (base64 or similar)
|
|
32
|
+
if (typeof encryptedData === 'string') {
|
|
33
|
+
// Encrypted data should be reasonably long (at least 32 chars for IV + some ciphertext)
|
|
34
|
+
if (encryptedData.length < MIN_ENCRYPTION_KEY_LENGTH) {
|
|
35
|
+
return { valid: false, error: 'encryptedData appears too short to be properly encrypted' };
|
|
36
|
+
}
|
|
37
|
+
return { valid: true };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// If it's an object, check for expected encryption fields
|
|
41
|
+
if (typeof encryptedData === 'object') {
|
|
42
|
+
// Common encrypted data structure: { iv, ciphertext } or { encrypted, nonce }
|
|
43
|
+
const hasIv = encryptedData.iv && typeof encryptedData.iv === 'string';
|
|
44
|
+
const hasCiphertext = encryptedData.ciphertext && typeof encryptedData.ciphertext === 'string';
|
|
45
|
+
const hasEncrypted = encryptedData.encrypted && typeof encryptedData.encrypted === 'string';
|
|
46
|
+
const hasNonce = encryptedData.nonce && typeof encryptedData.nonce === 'string';
|
|
47
|
+
|
|
48
|
+
if ((hasIv && hasCiphertext) || (hasEncrypted && hasNonce) || hasEncrypted) {
|
|
49
|
+
return { valid: true };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Also accept raw encrypted string in object
|
|
53
|
+
if (encryptedData.data && typeof encryptedData.data === 'string') {
|
|
54
|
+
return { valid: true };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { valid: false, error: 'encryptedData missing required encryption fields (iv/ciphertext or encrypted/nonce)' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { valid: false, error: 'encryptedData must be a string or object' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Cleanup old sync files that haven't been accessed in SYNC_TTL_MS
|
|
65
|
+
* @param {string} syncDir - Directory containing sync files
|
|
66
|
+
*/
|
|
67
|
+
async function cleanupOldSyncFiles(syncDir) {
|
|
68
|
+
try {
|
|
69
|
+
const files = await fs.readdir(syncDir);
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
let cleaned = 0;
|
|
72
|
+
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
if (!file.endsWith('.json')) continue;
|
|
75
|
+
|
|
76
|
+
const filePath = path.join(syncDir, file);
|
|
77
|
+
try {
|
|
78
|
+
const stat = await fs.stat(filePath);
|
|
79
|
+
const age = now - stat.mtimeMs;
|
|
80
|
+
|
|
81
|
+
if (age > SYNC_TTL_MS) {
|
|
82
|
+
await fs.unlink(filePath);
|
|
83
|
+
cleaned++;
|
|
84
|
+
log('debug', `[Sync] Cleaned up stale sync file: ${file}`);
|
|
85
|
+
}
|
|
86
|
+
} catch (statError) {
|
|
87
|
+
// Skip files we can't stat
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (cleaned > 0) {
|
|
92
|
+
log('info', `[Sync] Cleaned up ${cleaned} stale sync files`);
|
|
93
|
+
}
|
|
94
|
+
} catch (cleanupError) {
|
|
95
|
+
log('error', '[Sync] Cleanup error:', cleanupError.message);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Middleware to validate Content-Length before receiving body
|
|
101
|
+
* @param {number} maxSize - Maximum allowed body size in bytes
|
|
102
|
+
*/
|
|
103
|
+
function validateContentLength(maxSize) {
|
|
104
|
+
return (req, res, next) => {
|
|
105
|
+
const contentLength = parseInt(req.get('Content-Length') || '0', 10);
|
|
106
|
+
|
|
107
|
+
if (contentLength > maxSize) {
|
|
108
|
+
log('warn', `[Sync] Rejected request: Content-Length ${contentLength} exceeds max ${maxSize}`);
|
|
109
|
+
return sendError(res, `Request too large (max ${maxSize / 1024 / 1024}MB)`, 413);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
next();
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Setup sync routes
|
|
118
|
+
* @param {Express} app - Express app instance
|
|
119
|
+
* @param {string} syncDir - Directory to store sync data
|
|
120
|
+
*/
|
|
121
|
+
export function setupSyncRoutes(app, syncDir) {
|
|
122
|
+
// Ensure sync directory exists
|
|
123
|
+
fs.mkdir(syncDir, { recursive: true }).catch(() => {});
|
|
124
|
+
|
|
125
|
+
// Run cleanup on startup and every 24 hours
|
|
126
|
+
cleanupOldSyncFiles(syncDir);
|
|
127
|
+
setInterval(() => cleanupOldSyncFiles(syncDir), 24 * 60 * 60 * 1000);
|
|
128
|
+
|
|
129
|
+
// Push encrypted history to server
|
|
130
|
+
app.post('/api/sync/push', verifyBearerToken, validateContentLength(MAX_SYNC_SIZE_BYTES), async (req, res) => {
|
|
131
|
+
try {
|
|
132
|
+
const { syncId, encryptedData, timestamp } = req.body;
|
|
133
|
+
|
|
134
|
+
// Validate syncId using centralized utility
|
|
135
|
+
const syncIdResult = sanitizeSyncId(syncId);
|
|
136
|
+
if (!syncIdResult.valid) {
|
|
137
|
+
return sendError(res, syncIdResult.error, 400);
|
|
138
|
+
}
|
|
139
|
+
const safeSyncId = syncIdResult.sanitized;
|
|
140
|
+
|
|
141
|
+
// Validate encrypted data structure
|
|
142
|
+
const encryptionResult = validateEncryptedData(encryptedData);
|
|
143
|
+
if (!encryptionResult.valid) {
|
|
144
|
+
return sendError(res, encryptionResult.error, 400);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Double-check size limit (defense in depth - body parser may have already limited)
|
|
148
|
+
const dataSize = typeof encryptedData === 'string'
|
|
149
|
+
? encryptedData.length
|
|
150
|
+
: JSON.stringify(encryptedData).length;
|
|
151
|
+
if (dataSize > MAX_SYNC_SIZE_BYTES) {
|
|
152
|
+
log('warn', `[Sync] Rejected push: size ${(dataSize / 1024 / 1024).toFixed(2)}MB exceeds limit`);
|
|
153
|
+
return sendError(res, `Sync data too large (max ${MAX_SYNC_SIZE_BYTES / 1024 / 1024}MB)`, 413);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const syncFile = path.join(syncDir, `${safeSyncId}.json`);
|
|
157
|
+
|
|
158
|
+
// Use atomic write with file locking to prevent concurrent write corruption
|
|
159
|
+
let release;
|
|
160
|
+
try {
|
|
161
|
+
// Create file if it doesn't exist (needed for lockfile)
|
|
162
|
+
try {
|
|
163
|
+
await fs.access(syncFile);
|
|
164
|
+
} catch {
|
|
165
|
+
await fs.writeFile(syncFile, JSON.stringify({ encryptedData: null, timestamp: 0, updatedAt: Date.now() }));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
release = await lockfile.lock(syncFile, {
|
|
169
|
+
retries: { retries: 5, minTimeout: 50, maxTimeout: 500 }
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await fs.writeFile(syncFile, JSON.stringify({
|
|
173
|
+
encryptedData,
|
|
174
|
+
timestamp: timestamp || Date.now(),
|
|
175
|
+
updatedAt: Date.now()
|
|
176
|
+
}, null, 2));
|
|
177
|
+
|
|
178
|
+
log('debug', `[Sync] Pushed data for syncId: ${safeSyncId.substring(0, 8)}...`);
|
|
179
|
+
return sendSuccess(res, { timestamp: Date.now() });
|
|
180
|
+
} finally {
|
|
181
|
+
if (release) {
|
|
182
|
+
await release();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} catch (pushError) {
|
|
186
|
+
log('error', '[Sync] Push error:', pushError.message);
|
|
187
|
+
return sendError(res, 'Sync push failed', 500);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Pull encrypted history from server
|
|
192
|
+
app.get('/api/sync/pull', verifyBearerToken, async (req, res) => {
|
|
193
|
+
try {
|
|
194
|
+
const { syncId } = req.query;
|
|
195
|
+
|
|
196
|
+
// Validate syncId using centralized utility
|
|
197
|
+
const syncIdResult = sanitizeSyncId(syncId);
|
|
198
|
+
if (!syncIdResult.valid) {
|
|
199
|
+
return sendError(res, syncIdResult.error, 400);
|
|
200
|
+
}
|
|
201
|
+
const safeSyncId = syncIdResult.sanitized;
|
|
202
|
+
|
|
203
|
+
const syncFile = path.join(syncDir, `${safeSyncId}.json`);
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const fileContent = await fs.readFile(syncFile, 'utf8');
|
|
207
|
+
const syncData = JSON.parse(fileContent);
|
|
208
|
+
return sendSuccess(res, syncData);
|
|
209
|
+
} catch (readError) {
|
|
210
|
+
return sendNotFoundError(res, 'No sync data found');
|
|
211
|
+
}
|
|
212
|
+
} catch (pullError) {
|
|
213
|
+
log('error', '[Sync] Pull error:', pullError.message);
|
|
214
|
+
return sendError(res, 'Sync pull failed', 500);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Check if sync data exists
|
|
219
|
+
app.get('/api/sync/check', verifyBearerToken, async (req, res) => {
|
|
220
|
+
try {
|
|
221
|
+
const { syncId } = req.query;
|
|
222
|
+
|
|
223
|
+
// Validate syncId using centralized utility
|
|
224
|
+
const syncIdResult = sanitizeSyncId(syncId);
|
|
225
|
+
if (!syncIdResult.valid) {
|
|
226
|
+
return sendError(res, syncIdResult.error, 400);
|
|
227
|
+
}
|
|
228
|
+
const safeSyncId = syncIdResult.sanitized;
|
|
229
|
+
|
|
230
|
+
const syncFile = path.join(syncDir, `${safeSyncId}.json`);
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const stat = await fs.stat(syncFile);
|
|
234
|
+
return sendSuccess(res, { exists: true, updatedAt: stat.mtimeMs });
|
|
235
|
+
} catch (statError) {
|
|
236
|
+
return sendSuccess(res, { exists: false });
|
|
237
|
+
}
|
|
238
|
+
} catch (checkError) {
|
|
239
|
+
return sendError(res, 'Sync check failed', 500);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export default { setupSyncRoutes };
|