diff --git a/bot.js b/bot.js index 987ea4f..3b70b82 100644 --- a/bot.js +++ b/bot.js @@ -4,6 +4,7 @@ require('dotenv').config(); const { loadConfig } = require('./src/config'); const { applyMigrations } = require('./src/db/migrations'); const { normalizeIncomingCall } = require('./src/ingestion/normalizeCall'); +const { getRuntimeConfig, getSetupStatus } = require('./src/settings/settingsService'); const { JOB_TYPES, createProcessingJob, @@ -81,56 +82,28 @@ const { const startupConfig = loadConfig(process.env); if (!startupConfig.isValid) { - console.error('FATAL: Invalid configuration:'); + console.warn('WARNING: Configuration has issues. Setup mode will remain available:'); for (const error of startupConfig.errors) { - console.error(`- ${error.key}: ${error.message}`); + console.warn(`- ${error.key}: ${error.message}`); } - process.exit(1); } -// --- VALIDATE AI-RELATED ENV VARS --- -if (!AI_PROVIDER) { - console.error("FATAL: AI_PROVIDER is not set in the .env file. Please specify 'ollama' or 'openai'."); - process.exit(1); -} - -if (AI_PROVIDER.toLowerCase() === 'openai') { - if (!OPENAI_API_KEY || !OPENAI_MODEL) { - console.error("FATAL: AI_PROVIDER is 'openai', but OPENAI_API_KEY or OPENAI_MODEL is missing in the .env file."); - process.exit(1); - } -} else if (AI_PROVIDER.toLowerCase() === 'ollama') { - if (!OLLAMA_URL || !OLLAMA_MODEL) { - console.error("FATAL: AI_PROVIDER is 'ollama', but OLLAMA_URL or OLLAMA_MODEL is missing in the .env file."); - process.exit(1); - } -} else { - console.error(`FATAL: Invalid AI_PROVIDER specified in .env file: '${AI_PROVIDER}'. Must be 'openai' or 'ollama'.`); - process.exit(1); -} -// --- END VALIDATION --- - // --- VALIDATE TRANSCRIPTION-RELATED ENV VARS --- const effectiveTranscriptionMode = TRANSCRIPTION_MODE || 'local'; // Keep this to ensure a default if (!['local', 'remote', 'openai', 'icad'].includes(effectiveTranscriptionMode)) { - console.error(`FATAL: Invalid TRANSCRIPTION_MODE specified in .env file: '${TRANSCRIPTION_MODE}'. Must be 'local', 'remote', 'openai', or 'icad'.`); - process.exit(1); + console.warn(`WARNING: Invalid TRANSCRIPTION_MODE specified in .env file: '${TRANSCRIPTION_MODE}'. Use /setup to choose local, remote, openai, or icad.`); } if (effectiveTranscriptionMode === 'local' && !TRANSCRIPTION_DEVICE) { - console.error("FATAL: TRANSCRIPTION_MODE is 'local', but TRANSCRIPTION_DEVICE is missing in the .env file. Please set it to 'cuda' for a GPU or 'cpu' for CPU."); - process.exit(1); + console.warn("WARNING: TRANSCRIPTION_MODE is 'local', but TRANSCRIPTION_DEVICE is missing. Use /setup to choose cpu or cuda."); } if (effectiveTranscriptionMode === 'remote' && !FASTER_WHISPER_SERVER_URL) { - console.error("FATAL: TRANSCRIPTION_MODE is 'remote', but FASTER_WHISPER_SERVER_URL is missing in the .env file."); - process.exit(1); + console.warn("WARNING: TRANSCRIPTION_MODE is 'remote', but FASTER_WHISPER_SERVER_URL is missing. Use /setup to configure it."); } if (effectiveTranscriptionMode === 'openai' && !OPENAI_API_KEY) { - console.error("FATAL: TRANSCRIPTION_MODE is 'openai', but OPENAI_API_KEY is missing in the .env file. This is required for OpenAI transcriptions."); - process.exit(1); + console.warn("WARNING: TRANSCRIPTION_MODE is 'openai', but OPENAI_API_KEY is missing. Use /setup to configure it."); } if (effectiveTranscriptionMode === 'icad' && !ICAD_URL) { - console.error("FATAL: TRANSCRIPTION_MODE is 'icad', but ICAD_URL is missing in the .env file. Please set it to your ICAD API endpoint URL."); - process.exit(1); + console.warn("WARNING: TRANSCRIPTION_MODE is 'icad', but ICAD_URL is missing. Use /setup to configure it."); } // --- END VALIDATION --- @@ -189,9 +162,7 @@ if (ENABLE_TWO_TONE_MODE && ENABLE_TWO_TONE_MODE.toLowerCase() === 'true') { const missingVars = requiredTwoToneVars.filter(varName => !process.env[varName]); if (missingVars.length > 0) { - console.error(`FATAL: Two-tone mode is enabled but missing required environment variables: ${missingVars.join(', ')}`); - console.error('Please add these variables to your .env file. See TWO_TONE_ENV_ADDITIONS.txt for the complete list.'); - process.exit(1); + console.warn(`WARNING: Two-tone mode is enabled but missing required environment variables: ${missingVars.join(', ')}. Use /setup or .env to complete this before enabling bot services.`); } } @@ -411,8 +382,8 @@ function cleanStaleQueueEntries() { function detectTwoTone(audioFilePath, transcriptionId, talkGroupID, callback) { // For non-local modes, use a separate Python process for tone detection - if (effectiveTranscriptionMode !== 'local') { - logger.info(`Using standalone tone detection for ${effectiveTranscriptionMode} mode`); + if (activeTranscriptionMode !== 'local') { + logger.info(`Using standalone tone detection for ${activeTranscriptionMode} mode`); return detectTwoToneStandalone(audioFilePath, transcriptionId, talkGroupID, callback); } @@ -837,24 +808,7 @@ const logger = winston.createLogger({ // --- NEW: Add S3 Client Setup --- const AWS = require('aws-sdk'); -let s3 = null; -if (STORAGE_MODE === 's3') { - if (!S3_ENDPOINT || !S3_BUCKET_NAME || !S3_ACCESS_KEY_ID || !S3_SECRET_ACCESS_KEY) { - logger.error('FATAL: STORAGE_MODE is s3, but required S3 environment variables are missing! Check bot .env'); - process.exit(1); // Exit if S3 config is incomplete - } - AWS.config.update({ - accessKeyId: S3_ACCESS_KEY_ID, - secretAccessKey: S3_SECRET_ACCESS_KEY, - endpoint: S3_ENDPOINT, - s3ForcePathStyle: true, // Necessary for MinIO/non-AWS S3 - signatureVersion: 'v4' - }); - s3 = new AWS.S3(); - logger.info(`[Bot] Storage mode set to S3. Endpoint: ${S3_ENDPOINT}, Bucket: ${S3_BUCKET_NAME}`); -} else { - logger.info('[Bot] Storage mode set to local.'); -} +logger.info(`[Bot] Startup storage mode from .env: ${STORAGE_MODE || 'local'}. Runtime settings may override this after database initialization.`); // --- END S3 Client Setup --- // --- INITIALIZATION FUNCTIONS --- @@ -976,6 +930,80 @@ async function initializeDatabase() { } } +async function getBotRuntimeConfig() { + return getRuntimeConfig(db, process.env); +} + +async function getBotTranscriptionConfig() { + const runtime = await getBotRuntimeConfig(); + const mode = (runtime.settings.transcriptionMode || TRANSCRIPTION_MODE || 'local').toLowerCase(); + + return { + mode: ['local', 'remote', 'openai', 'icad'].includes(mode) ? mode : 'local', + device: (runtime.settings.transcriptionDevice || TRANSCRIPTION_DEVICE || 'cpu').toLowerCase(), + fasterWhisperServerUrl: runtime.settings.fasterWhisperServerUrl || FASTER_WHISPER_SERVER_URL || '', + whisperModel: runtime.settings.whisperModel || WHISPER_MODEL || 'large-v3', + openaiApiKey: runtime.secrets.openaiApiKey || OPENAI_API_KEY || '', + openaiTranscriptionPrompt: runtime.settings.openaiTranscriptionPrompt || OPENAI_TRANSCRIPTION_PROMPT || '', + openaiTranscriptionModel: runtime.settings.openaiTranscriptionModel || OPENAI_TRANSCRIPTION_MODEL || 'whisper-1', + openaiTranscriptionTemperature: runtime.settings.openaiTranscriptionTemperature || OPENAI_TRANSCRIPTION_TEMPERATURE || '0.0', + icadUrl: runtime.settings.icadUrl || ICAD_URL || '', + icadProfile: runtime.settings.icadProfile || ICAD_PROFILE || 'whisper-1', + icadApiKey: runtime.secrets.icadApiKey || ICAD_API_KEY || '' + }; +} + +async function getBotStorageConfig() { + const runtime = await getBotRuntimeConfig(); + const mode = (runtime.settings.storageMode || STORAGE_MODE || 'local').toLowerCase(); + + return { + mode: mode === 's3' ? 's3' : 'local', + s3Endpoint: runtime.settings.s3Endpoint || S3_ENDPOINT || '', + s3BucketName: runtime.settings.s3BucketName || S3_BUCKET_NAME || '', + s3AccessKeyId: runtime.secrets.s3AccessKeyId || S3_ACCESS_KEY_ID || '', + s3SecretAccessKey: runtime.secrets.s3SecretAccessKey || S3_SECRET_ACCESS_KEY || '' + }; +} + +function createS3Client(storageConfig) { + return new AWS.S3({ + accessKeyId: storageConfig.s3AccessKeyId, + secretAccessKey: storageConfig.s3SecretAccessKey, + endpoint: storageConfig.s3Endpoint, + s3ForcePathStyle: true, + signatureVersion: 'v4' + }); +} + +function isS3Ready(storageConfig) { + return Boolean( + storageConfig.mode === 's3' && + storageConfig.s3Endpoint && + storageConfig.s3BucketName && + storageConfig.s3AccessKeyId && + storageConfig.s3SecretAccessKey + ); +} + +function getToneAudioPath(storageConfig, audioFilePath) { + if (storageConfig.mode === 's3' && storageConfig.s3Endpoint && storageConfig.s3BucketName) { + return `https://${storageConfig.s3Endpoint.replace('https://', '').replace('http://', '')}/${storageConfig.s3BucketName}/${audioFilePath}`; + } + return path.join(__dirname, 'audio', audioFilePath); +} + +async function getPublicAudioUrl(audioId) { + let publicDomain = PUBLIC_DOMAIN || 'localhost'; + try { + const runtime = await getBotRuntimeConfig(); + publicDomain = runtime.settings.publicDomain || publicDomain; + } catch (error) { + logger.warn(`Could not load runtime public domain; falling back to startup config: ${error.message}`); + } + return `http://${publicDomain}/audio/${audioId}`; +} + // Function to create admin user if authentication is enabled function createAdminUser() { return new Promise((resolve, reject) => { @@ -1091,16 +1119,23 @@ async function initializeBot() { // Step 6: Create admin user for webserver if auth is enabled await createAdminUser(); - // Step 7: Start bot services (Discord and Express API) - await startBotServices(); - - // Step 8: Start webserver last - if (WEBSERVER_PORT && (GOOGLE_MAPS_API_KEY || LOCATIONIQ_API_KEY)) { + // Step 7: Start webserver before Discord so setup can run even when bot settings are incomplete + if (WEBSERVER_PORT) { await startWebserver(); } else { - logger.warn('Webserver not started: WEBSERVER_PORT or geocoding API key (GOOGLE_MAPS_API_KEY or LOCATIONIQ_API_KEY) not configured'); + logger.warn('Webserver not started: WEBSERVER_PORT is not configured'); } + // Step 8: In setup mode, keep the browser console available without forcing Discord login + const setupStatus = await getSetupStatus(db, process.env); + if (setupStatus.setupRequired) { + logger.warn(`Setup is incomplete (${setupStatus.missing.join(', ') || 'unknown requirements'}). Discord bot services will start after setup is completed and the app is restarted.`); + return true; + } + + // Step 9: Start bot services (Discord and Express API) + await startBotServices(); + logger.info('Bot initialization completed successfully!'); return true; } catch (error) { @@ -1166,6 +1201,7 @@ let isBootComplete = false; const messageCache = new Map(); // Stores the latest message for each channel const MESSAGE_COOLDOWN = 15000; // 15 seconds in milliseconds let transcriptionProcess = null; +let activeTranscriptionMode = effectiveTranscriptionMode; let isProcessingTranscription = false; let currentTranscriptionId = null; // Track current transcription for timeout let transcriptionTimeout = null; // Timeout for current transcription @@ -1818,12 +1854,12 @@ app.get('/audio/:id', (req, res) => { // Function to start the transcription process // Function to start the transcription process async function startTranscriptionProcess() { - // *** ADD THIS CHECK AT THE TOP *** - if (effectiveTranscriptionMode !== 'local') { + const transcriptionConfig = await getBotTranscriptionConfig(); + activeTranscriptionMode = transcriptionConfig.mode; + if (transcriptionConfig.mode !== 'local') { logger.info('Transcription mode is not local, skipping Python process start.'); - return; // Don't start if mode is remote + return; } - // *** END ADDED CHECK *** // Clean up existing process if it exists if (transcriptionProcess) { @@ -2169,7 +2205,7 @@ async function startTranscriptionProcess() { transcriptionProcess.on('error', (err) => { logger.error(`Failed to start local transcription process: ${err.message}`); cleanupTranscriptionProcess(); - if (effectiveTranscriptionMode === 'local') { + if (activeTranscriptionMode === 'local') { logger.info('Will attempt to restart local transcription process in 10 seconds due to spawn error...'); setTimeout(startTranscriptionProcess, 10000); } @@ -2347,7 +2383,7 @@ async function startTranscriptionProcess() { cleanupTranscriptionProcess(); // Only restart if not too many recent failures - if (effectiveTranscriptionMode === 'local') { + if (activeTranscriptionMode === 'local') { if (code === null) { // For null exit codes (startup crashes), wait longer and provide guidance logger.error('STARTUP CRASH DETECTED - Will NOT automatically restart to prevent loop'); @@ -2466,14 +2502,14 @@ function startProcessHealthCheck() { if (timeSinceActivity > 600000 && queueSize > 0) { // 10 minutes + queue items = real problem logger.error(`Transcription process appears stuck (no activity for 10 minutes with ${queueSize} items queued). Restarting...`); cleanupTranscriptionProcess(); - if (effectiveTranscriptionMode === 'local') { + if (activeTranscriptionMode === 'local') { setTimeout(startTranscriptionProcess, 5000); } return; } else if (timeSinceActivity > 1800000) { // 30 minutes with no activity at all (safety net) logger.warn(`Very long radio silence detected (30+ minutes). Performing health check restart as precaution...`); cleanupTranscriptionProcess(); - if (effectiveTranscriptionMode === 'local') { + if (activeTranscriptionMode === 'local') { setTimeout(startTranscriptionProcess, 5000); } return; @@ -2499,7 +2535,7 @@ function startProcessHealthCheck() { if (queueSize > 15 && !isProcessingTranscription && timeSinceActivity > 300000) { // 5 minutes + 15+ items = real stuck logger.error(`Queue definitely stuck with ${queueSize} items and no processing for 5 minutes. Force restarting transcription process...`); cleanupTranscriptionProcess(); - if (effectiveTranscriptionMode === 'local') { + if (activeTranscriptionMode === 'local') { setTimeout(startTranscriptionProcess, 2000); } } @@ -2566,7 +2602,7 @@ function processNextTranscription() { logger.error(`Transcription timeout for ID ${currentTranscriptionId}. Restarting process...`); // Force restart the process on timeout cleanupTranscriptionProcess(); - if (effectiveTranscriptionMode === 'local') { + if (activeTranscriptionMode === 'local') { setTimeout(startTranscriptionProcess, 5000); } }, TRANSCRIPTION_TIMEOUT_MS); @@ -2600,8 +2636,10 @@ function processNextTranscription() { // *** NEW FUNCTION for Remote Transcription *** async function transcribeAudioRemotely(filePath, callback) { + const transcriptionConfig = await getBotTranscriptionConfig(); + // Ensure URL is configured for remote mode - if (!FASTER_WHISPER_SERVER_URL) { + if (!transcriptionConfig.fasterWhisperServerUrl) { logger.error('FATAL: FASTER_WHISPER_SERVER_URL is not configured for remote mode.'); if (callback) callback(""); // Fail gracefully return; @@ -2633,14 +2671,14 @@ async function transcribeAudioRemotely(filePath, callback) { const form = new FormData(); form.append('file', fs.createReadStream(filePath)); // Append model if specified in environment - if (WHISPER_MODEL) { - form.append('model', WHISPER_MODEL); - logger.info(`Requesting remote model: ${WHISPER_MODEL}`); + if (transcriptionConfig.whisperModel) { + form.append('model', transcriptionConfig.whisperModel); + logger.info(`Requesting remote model: ${transcriptionConfig.whisperModel}`); } // Add language parameter if needed // form.append('language', 'en'); - const apiEndpoint = `${FASTER_WHISPER_SERVER_URL}/v1/audio/transcriptions`; + const apiEndpoint = `${transcriptionConfig.fasterWhisperServerUrl}/v1/audio/transcriptions`; const filenameForLog = path.basename(filePath); logger.info(`Sending remote transcription request for ${filenameForLog} to ${apiEndpoint}`); @@ -2702,8 +2740,10 @@ async function transcribeAudioRemotely(filePath, callback) { } async function transcribeWithOpenAIAPI(filePath, callback) { + const transcriptionConfig = await getBotTranscriptionConfig(); + // Check for API Key - if (!OPENAI_API_KEY) { + if (!transcriptionConfig.openaiApiKey) { logger.error('FATAL: TRANSCRIPTION_MODE is openai, but OPENAI_API_KEY is not configured.'); if (callback) callback(""); // Fail gracefully return; @@ -2721,21 +2761,21 @@ async function transcribeWithOpenAIAPI(filePath, callback) { form.append('file', fs.createReadStream(filePath)); // Use the model from environment variable, fallback to whisper-1 if not set - const modelToUse = OPENAI_TRANSCRIPTION_MODEL || 'whisper-1'; + const modelToUse = transcriptionConfig.openaiTranscriptionModel; form.append('model', modelToUse); // Force language to English for better scanner audio transcription form.append('language', 'en'); // Add temperature parameter for transcription consistency (if supported) - const temperature = OPENAI_TRANSCRIPTION_TEMPERATURE || '0.0'; + const temperature = transcriptionConfig.openaiTranscriptionTemperature; form.append('temperature', temperature); const filenameForLog = path.basename(filePath); // Add custom prompt if configured to improve scanner audio transcription - if (OPENAI_TRANSCRIPTION_PROMPT) { - form.append('prompt', OPENAI_TRANSCRIPTION_PROMPT); + if (transcriptionConfig.openaiTranscriptionPrompt) { + form.append('prompt', transcriptionConfig.openaiTranscriptionPrompt); logger.info(`Using custom OpenAI transcription prompt for ${filenameForLog}`); } @@ -2754,7 +2794,7 @@ async function transcribeWithOpenAIAPI(filePath, callback) { method: 'POST', body: form, headers: { - 'Authorization': `Bearer ${OPENAI_API_KEY}`, + 'Authorization': `Bearer ${transcriptionConfig.openaiApiKey}`, ...form.getHeaders() }, signal: controller.signal @@ -2792,8 +2832,10 @@ async function transcribeWithOpenAIAPI(filePath, callback) { } async function transcribeWithICADAPI(filePath, callback) { + const transcriptionConfig = await getBotTranscriptionConfig(); + // Check for ICAD URL - if (!ICAD_URL) { + if (!transcriptionConfig.icadUrl) { logger.error('FATAL: TRANSCRIPTION_MODE is icad, but ICAD_URL is not configured.'); if (callback) callback(""); // Fail gracefully return; @@ -2811,7 +2853,7 @@ async function transcribeWithICADAPI(filePath, callback) { form.append('file', fs.createReadStream(filePath)); // Set model based on ICAD_PROFILE if provided, otherwise use default - const modelToUse = ICAD_PROFILE || 'whisper-1'; + const modelToUse = transcriptionConfig.icadProfile; form.append('model', modelToUse); // Add standard OpenAI Whisper API parameters that ICAD should understand @@ -2821,9 +2863,9 @@ async function transcribeWithICADAPI(filePath, callback) { // Explicitly disable clip_timestamps to override any profile settings form.append('clip_timestamps', ''); - const apiEndpoint = `${ICAD_URL}/v1/audio/transcriptions`; + const apiEndpoint = `${transcriptionConfig.icadUrl}/v1/audio/transcriptions`; const filenameForLog = path.basename(filePath); - const authStatus = ICAD_API_KEY ? 'with authentication' : 'without authentication'; + const authStatus = transcriptionConfig.icadApiKey ? 'with authentication' : 'without authentication'; logger.info(`Sending ICAD transcription request for ${filenameForLog} to ${apiEndpoint} using model/profile: ${modelToUse} (${authStatus})`); const controller = new AbortController(); @@ -2837,8 +2879,8 @@ async function transcribeWithICADAPI(filePath, callback) { }; // Add authorization header if ICAD_API_KEY is provided - if (ICAD_API_KEY) { - headers['Authorization'] = `Bearer ${ICAD_API_KEY}`; + if (transcriptionConfig.icadApiKey) { + headers['Authorization'] = `Bearer ${transcriptionConfig.icadApiKey}`; } const response = await fetch(apiEndpoint, { @@ -2986,7 +3028,7 @@ function handleNewAudio(audioData) { } // Read file into buffer (This is needed for DB blob AND for S3->Local transcription) - fs.readFile(tempPath, (err, fileBuffer) => { + fs.readFile(tempPath, async (err, fileBuffer) => { if (err) { logger.error(`Error reading audio file ${tempPath}:`, err); // Clean up temp file if read fails @@ -2996,10 +3038,12 @@ function handleNewAudio(audioData) { return; } + const storageConfig = await getBotStorageConfig(); + // --- Start DB Operations --- Miminized changes here // Determine the storage path/key based on STORAGE_MODE let storagePath; - if (STORAGE_MODE === 's3') { + if (storageConfig.mode === 's3') { // For S3, we store the filename as the key (assuming it's unique enough) // You might want a more structured path like 'audio/YYYY/MM/DD/filename' storagePath = filename; @@ -3036,6 +3080,7 @@ function handleNewAudio(audioData) { const transcriptionId = this.lastID; // Get the ID from the database insert let transcriptionJobId = null; + const transcriptionConfig = await getBotTranscriptionConfig(); logger.info(`Created transcription record ID ${transcriptionId} using storage path: ${storagePath}`); try { @@ -3045,8 +3090,8 @@ function handleNewAudio(audioData) { payload: { filename, talkGroupID, - transcriptionMode: effectiveTranscriptionMode, - storageMode: STORAGE_MODE + transcriptionMode: transcriptionConfig.mode, + storageMode: storageConfig.mode } }); logger.info(`Created transcription job ${transcriptionJobId} for transcription ID ${transcriptionId}`); @@ -3055,7 +3100,7 @@ function handleNewAudio(audioData) { } // Conditionally insert audio blob for Listen Live feature (local storage only) - if (STORAGE_MODE !== 's3') { + if (storageConfig.mode !== 's3') { db.run( `INSERT INTO audio_files (transcription_id, audio_data) VALUES (?, ?)`, [transcriptionId, fileBuffer], @@ -3148,13 +3193,14 @@ function handleNewAudio(audioData) { storagePath, audioPathForSplitting, tempPath, - finalPathIfLocal + finalPathIfLocal, + storageConfig.mode ); } }; // Transcribe based on mode (use the same mode as the main call) - const segmentTranscriptionMode = effectiveTranscriptionMode || 'local'; + const segmentTranscriptionMode = transcriptionConfig.mode; if (segmentTranscriptionMode === 'openai') { transcribeWithOpenAIAPI(segment.audioPath, segmentCallback); } else if (segmentTranscriptionMode === 'remote') { @@ -3207,9 +3253,9 @@ function handleNewAudio(audioData) { logger.info(`Checking for two-tone in talk group ${talkGroupID} (ID: ${transcriptionId}) - empty transcription`); // Use the audio file path for tone detection - const audioPathForTones = STORAGE_MODE === 's3' ? - `https://${S3_ENDPOINT.replace('https://', '').replace('http://', '')}/${S3_BUCKET_NAME}/${filename}` : - (finalPathIfLocal || path.join(__dirname, 'audio', filename)); + const audioPathForTones = storageConfig.mode === 's3' + ? getToneAudioPath(storageConfig, filename) + : (finalPathIfLocal || path.join(__dirname, 'audio', filename)); // Wait for tone detection to complete before continuing await new Promise((resolve) => { @@ -3223,7 +3269,7 @@ function handleNewAudio(audioData) { } // Clean up temp file only if storage was S3 - if (STORAGE_MODE === 's3') { + if (storageConfig.mode === 's3') { // Use setImmediate to avoid file handle race conditions setImmediate(() => { fs.unlink(tempPath, (errUnlink) => { @@ -3254,11 +3300,12 @@ function handleNewAudio(audioData) { emergency, priority, encrypted, call_length, // <-- Pass call metadata freq_error, signalQuality, // <-- Pass signal quality frequency, start_time, stop_time, // <-- Pass timing/frequency - tdma_slot, phase2_tdma, color_code // <-- Pass TDMA/color code + tdma_slot, phase2_tdma, color_code, // <-- Pass TDMA/color code + storageConfig ); // Clean up temp file only if storage was S3 - if (STORAGE_MODE === 's3') { + if (storageConfig.mode === 's3') { // Use setImmediate to avoid file handle race conditions setImmediate(() => { fs.unlink(tempPath, (errUnlink) => { @@ -3282,28 +3329,28 @@ function handleNewAudio(audioData) { // --- End common callback definition --- // --- Choose transcription method based on mode --- - logger.info(`Initiating transcription for ID ${transcriptionId} using mode: ${effectiveTranscriptionMode}`); + logger.info(`Initiating transcription for ID ${transcriptionId} using mode: ${transcriptionConfig.mode}`); if (transcriptionJobId) { safelyUpdateProcessingJob('mark transcription job processing', () => markJobProcessing(db, transcriptionJobId)); } - if (effectiveTranscriptionMode === 'openai') { + if (transcriptionConfig.mode === 'openai') { // OpenAI API transcription mode - const pathToUse = (STORAGE_MODE === 'local') ? finalPathIfLocal : tempPath; + const pathToUse = (storageConfig.mode === 'local') ? finalPathIfLocal : tempPath; transcribeWithOpenAIAPI(pathToUse, processingCallback); - } else if (effectiveTranscriptionMode === 'remote') { + } else if (transcriptionConfig.mode === 'remote') { // Use the remote function for faster-whisper server - const pathToUseForRemote = (STORAGE_MODE === 'local') ? finalPathIfLocal : tempPath; + const pathToUseForRemote = (storageConfig.mode === 'local') ? finalPathIfLocal : tempPath; transcribeAudioRemotely(pathToUseForRemote, processingCallback); - } else if (effectiveTranscriptionMode === 'icad') { + } else if (transcriptionConfig.mode === 'icad') { // ICAD API transcription mode (OpenAI-compatible interface) - const pathToUse = (STORAGE_MODE === 'local') ? finalPathIfLocal : tempPath; + const pathToUse = (storageConfig.mode === 'local') ? finalPathIfLocal : tempPath; transcribeWithICADAPI(pathToUse, processingCallback); } else { // 'local' transcription mode const localRequestId = uuidv4(); let payload; - if (STORAGE_MODE === 's3') { + if (storageConfig.mode === 's3') { // S3 Storage + Local Transcription: Send buffer logger.info(`Queueing local transcription (ID: ${localRequestId}) for DB ID ${transcriptionId} using BASE64 BUFFER`); @@ -3397,22 +3444,35 @@ function handleNewAudio(audioData) { // --- End afterStorageComplete function definition --- // --- Handle Audio Storage based on Mode --- - if (STORAGE_MODE === 's3') { + if (storageConfig.mode === 's3') { + if (!isS3Ready(storageConfig)) { + const error = new Error('S3 storage mode is selected, but S3 endpoint, bucket, or credentials are incomplete.'); + logger.error(error.message); + if (transcriptionJobId) { + safelyUpdateProcessingJob('mark transcription job failed', () => markJobFailed(db, transcriptionJobId, error)); + } + db.run('DELETE FROM transcriptions WHERE id = ?', [transcriptionId], () => {}); + fs.unlink(tempPath, (errUnlink) => { + if (errUnlink) logger.error(`Error deleting temp file after incomplete S3 config ${tempPath}:`, errUnlink); + }); + return; + } + const s3Client = createS3Client(storageConfig); // Upload the buffer to S3 const s3Params = { - Bucket: S3_BUCKET_NAME, + Bucket: storageConfig.s3BucketName, Key: storagePath, // Use the determined S3 key Body: fileBuffer, // ContentType: 'audio/mpeg', // Or determine dynamically }; - s3.upload(s3Params, (s3Err, data) => { + s3Client.upload(s3Params, (s3Err, data) => { if (s3Err) { // Check for specific MinIO storage threshold error const errorMessage = s3Err.message || s3Err.toString() || ''; if (errorMessage.includes('minimum free drive threshold') || errorMessage.includes('free drive threshold')) { logger.error(`[MINIO STORAGE FULL] MinIO server has reached its minimum free drive threshold.`); logger.error(`[MINIO STORAGE FULL] Transcription ID ${transcriptionId} could not be uploaded.`); - logger.error(`[MINIO STORAGE FULL] Action required: Free up disk space on MinIO server or delete old objects from bucket: ${S3_BUCKET_NAME}`); + logger.error(`[MINIO STORAGE FULL] Action required: Free up disk space on MinIO server or delete old objects from bucket: ${storageConfig.s3BucketName}`); logger.error(`[MINIO STORAGE FULL] Full error: ${errorMessage}`); } else { logger.error(`Error uploading audio to S3 for transcription ID ${transcriptionId} (key: ${storagePath}):`, s3Err); @@ -3904,7 +3964,8 @@ async function processMergedCallSegments( storagePath, audioPathForSplitting, tempPath, - finalPathIfLocal + finalPathIfLocal, + storageMode = STORAGE_MODE ) { logger.info(`Processing ${segmentTranscriptions.length} segments for merged call ID ${transcriptionId}`); @@ -3920,7 +3981,7 @@ async function processMergedCallSegments( .trim(); // Build combined transcription lines for Discord (all segments in one message) - const audioUrl = `http://${PUBLIC_DOMAIN}/audio/${transcriptionId}`; + const audioUrl = await getPublicAudioUrl(transcriptionId); const transcriptionLines = []; for (const segment of sortedSegments) { @@ -3970,7 +4031,7 @@ async function processMergedCallSegments( ); // Clean up temp file only if storage was S3 - if (STORAGE_MODE === 's3') { + if (storageMode === 's3') { setImmediate(() => { fs.unlink(tempPath, (errUnlink) => { if (errUnlink && errUnlink.code !== 'ENOENT') { @@ -4009,11 +4070,13 @@ async function handleNewTranscription( stop_time, tdma_slot, phase2_tdma, - color_code + color_code, + storageConfig = null ) { logger.info(`Starting handleNewTranscription for ID ${id}`); logger.info(`Transcription text length: ${transcriptionText.length} characters`); logger.info(`Talk Group: ${talkGroupID} - ${talkGroupName}`); + const resolvedStorageConfig = storageConfig || await getBotStorageConfig(); // Auto-queue calls after two-tone detection (if in two-tone mode) if (IS_TWO_TONE_MODE_ENABLED && lastTwoToneTime > 0 && lastDetectedToneGroup) { @@ -4061,9 +4124,7 @@ async function handleNewTranscription( logger.info(`Checking for two-tone in talk group ${talkGroupID} (ID: ${id})`); // Construct the proper audio path for tone detection - const audioPathForTones = STORAGE_MODE === 's3' ? - `https://${S3_ENDPOINT.replace('https://', '').replace('http://', '')}/${S3_BUCKET_NAME}/${audioFilePath}` : - path.join(__dirname, 'audio', audioFilePath); // Construct full local path + const audioPathForTones = getToneAudioPath(resolvedStorageConfig, audioFilePath); // Wait for tone detection to complete before continuing await new Promise((resolve) => { @@ -4274,12 +4335,12 @@ function sendAlertMessage( callback ) { // Look up the audio_id from the database for this transcription - db.get('SELECT id FROM audio_files WHERE transcription_id = ?', [audioID], (err, row) => { + db.get('SELECT id FROM audio_files WHERE transcription_id = ?', [audioID], async (err, row) => { // Use transcription ID as fallback if audio ID not found const actualAudioID = (err || !row) ? audioID : row.id; // Create a URL for the audio file - const audioUrl = `http://${PUBLIC_DOMAIN}/audio/${actualAudioID}`; + const audioUrl = await getPublicAudioUrl(actualAudioID); // Log the IDs for debugging logger.info(`Alert - Transcription ID: ${audioID}, Audio ID: ${actualAudioID}, URL: ${audioUrl}`); @@ -4514,7 +4575,7 @@ function sendTranscriptionMessage( } // Get or create the channel within the category - getOrCreateChannel(channelName, category.id, (channel) => { + getOrCreateChannel(channelName, category.id, async (channel) => { if (!channel) { logger.error('Failed to get or create channel.'); if (callback) callback(); // Ensure callback is called even on error @@ -4525,7 +4586,7 @@ function sendTranscriptionMessage( // Note: We use transcription ID (`audioID` parameter) for the URL now // as audio_files might get cleaned up. // The audio server route /audio/:id expects the transcription ID. - const audioUrl = `http://${PUBLIC_DOMAIN}/audio/${audioID}`; + const audioUrl = await getPublicAudioUrl(audioID); // Log the ID and URL for debugging logger.info(`Creating link for Transcription ID: ${audioID}, Audio URL: ${audioUrl}`); @@ -5045,26 +5106,32 @@ Focus on providing insightful analysis of each transmission. The "description" f Include no other text besides this JSON.`; // Call the AI provider with a timeout + const runtime = await getBotRuntimeConfig(); + const aiProvider = (runtime.settings.aiProvider || AI_PROVIDER || 'ollama').toLowerCase(); + const openaiApiKey = runtime.secrets.openaiApiKey || OPENAI_API_KEY || ''; + const openaiModel = runtime.settings.openaiModel || OPENAI_MODEL || 'gpt-4o-mini'; + const ollamaUrl = runtime.settings.ollamaUrl || OLLAMA_URL || 'http://localhost:11434'; + const ollamaModel = runtime.settings.ollamaModel || OLLAMA_MODEL || 'llama3.1:8b'; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 30000); // 30 second timeout let resultText = ''; try { - if (AI_PROVIDER.toLowerCase() === 'openai') { - if (!OPENAI_API_KEY) { + if (aiProvider === 'openai') { + if (!openaiApiKey) { logger.error("[Bot] FATAL: AI_PROVIDER is set to openai, but OPENAI_API_KEY is not configured!"); throw new Error("OpenAI API key is not configured."); } - logger.info(`[Bot] Generating summary with OpenAI model: ${OPENAI_MODEL}`); + logger.info(`[Bot] Generating summary with OpenAI model: ${openaiModel}`); const response = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${OPENAI_API_KEY}` + 'Authorization': `Bearer ${openaiApiKey}` }, body: JSON.stringify({ - model: OPENAI_MODEL, + model: openaiModel, messages: [{ role: 'user', content: commonPrompt }], temperature: 0.3, response_format: { type: "json_object" } // Request JSON output @@ -5082,13 +5149,13 @@ Include no other text besides this JSON.`; } } else { // Default to Ollama - logger.info(`[Bot] Generating summary with Ollama model: ${OLLAMA_MODEL}`); + logger.info(`[Bot] Generating summary with Ollama model: ${ollamaModel}`); - const response = await fetch(`${OLLAMA_URL}/api/generate`, { + const response = await fetch(`${ollamaUrl}/api/generate`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ - model: OLLAMA_MODEL, + model: ollamaModel, prompt: commonPrompt, stream: false }), @@ -5347,7 +5414,7 @@ async function updateSummaryEmbed() { if (summary.highlights && summary.highlights.length > 0) { for (const highlight of summary.highlights) { // Get audio URL - const audioUrl = `http://${PUBLIC_DOMAIN}/audio/${highlight.id}`; + const audioUrl = await getPublicAudioUrl(highlight.id); // Fix timestamp display - use timestamp directly from database let timestampDisplay; @@ -5576,7 +5643,7 @@ function playAudioForTalkGroup(talkGroupID, transcriptionId) { } } -function processAudioQueue(talkGroupID) { +async function processAudioQueue(talkGroupID) { talkGroupID = talkGroupID.toString(); const talkGroupData = activeVoiceChannels.get(talkGroupID); if (!talkGroupData || !talkGroupData.player || !talkGroupData.queue) { @@ -5622,14 +5689,21 @@ function processAudioQueue(talkGroupID) { }); }; - if (STORAGE_MODE === 's3') { + const storageConfig = await getBotStorageConfig(); + if (storageConfig.mode === 's3') { + if (!isS3Ready(storageConfig)) { + logger.error(`S3 Mode: storage configuration incomplete for Discord playback (ID ${transcriptionId})`); + processAudioQueue(talkGroupID); + return; + } + const s3Client = createS3Client(storageConfig); db.get('SELECT audio_file_path FROM transcriptions WHERE id = ?', [transcriptionId], (err, row) => { if (err || !row || !row.audio_file_path) { logger.error(`S3 Mode: Could not find audio_file_path for transcription ID ${transcriptionId}`, err); processAudioQueue(talkGroupID); return; } - const s3Stream = s3.getObject({ Bucket: S3_BUCKET_NAME, Key: row.audio_file_path }).createReadStream(); + const s3Stream = s3Client.getObject({ Bucket: storageConfig.s3BucketName, Key: row.audio_file_path }).createReadStream(); s3Stream.on('error', s3Err => { logger.error(`Error streaming from S3 for Discord playback (ID ${transcriptionId}):`, s3Err); processAudioQueue(talkGroupID); @@ -5977,11 +6051,12 @@ client.once('ready', async () => { startSummaryScheduler(); // Start transcription process if needed - if (effectiveTranscriptionMode === 'local') { + const transcriptionConfig = await getBotTranscriptionConfig(); + if (transcriptionConfig.mode === 'local') { logger.info('Initializing local transcription process...'); startTranscriptionProcess(); } else { - logger.info(`Transcription mode set to '${effectiveTranscriptionMode}'. Local Python process will not be started.`); + logger.info(`Transcription mode set to '${transcriptionConfig.mode}'. Local Python process will not be started.`); } isBootComplete = true; @@ -6226,8 +6301,13 @@ client.on('interactionCreate', async (interaction) => { const userQuestion = interaction.fields.getTextInputValue('ai_question'); try { - // --- Read lookback from .env, default to 8 hours --- - const askAiLookbackHours = parseFloat(ASK_AI_LOOKBACK_HOURS) || 8; + const runtime = await getBotRuntimeConfig(); + const askAiLookbackHours = parseFloat(runtime.settings.askAiLookbackHours || ASK_AI_LOOKBACK_HOURS) || 8; + const aiProvider = (runtime.settings.aiProvider || AI_PROVIDER || 'ollama').toLowerCase(); + const openaiApiKey = runtime.secrets.openaiApiKey || OPENAI_API_KEY || ''; + const openaiModel = runtime.settings.openaiModel || OPENAI_MODEL || 'gpt-4o-mini'; + const ollamaUrl = runtime.settings.ollamaUrl || OLLAMA_URL || 'http://localhost:11434'; + const ollamaModel = runtime.settings.ollamaModel || OLLAMA_MODEL || 'llama3.1:8b'; const now = new Date(); const queryStartDate = new Date(now.getTime() - askAiLookbackHours * 60 * 60 * 1000); // Convert start date to Unix seconds for the query @@ -6328,20 +6408,20 @@ User Question: ${userQuestion} let aiResponseText = 'Error: Could not get response from AI.'; try { - if (AI_PROVIDER.toLowerCase() === 'openai') { - if (!OPENAI_API_KEY) { + if (aiProvider === 'openai') { + if (!openaiApiKey) { throw new Error("OpenAI API key is not configured."); } - logger.info(`[Bot] Answering question with OpenAI model: ${OPENAI_MODEL}`); + logger.info(`[Bot] Answering question with OpenAI model: ${openaiModel}`); const response = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${OPENAI_API_KEY}` + 'Authorization': `Bearer ${openaiApiKey}` }, body: JSON.stringify({ - model: OPENAI_MODEL, + model: openaiModel, messages: [{ role: 'user', content: commonPrompt }], temperature: 0.5, max_tokens: 500 @@ -6358,13 +6438,13 @@ User Question: ${userQuestion} } } else { // Default to Ollama - logger.info(`[Bot] Answering question with Ollama model: ${OLLAMA_MODEL}`); + logger.info(`[Bot] Answering question with Ollama model: ${ollamaModel}`); - const response = await fetch(`${OLLAMA_URL}/api/generate`, { + const response = await fetch(`${ollamaUrl}/api/generate`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ - model: OLLAMA_MODEL, + model: ollamaModel, prompt: commonPrompt, stream: false, options: { num_ctx: 35000 } diff --git a/public/settings.html b/public/settings.html index 89224b5..ac2c6f0 100644 --- a/public/settings.html +++ b/public/settings.html @@ -48,6 +48,10 @@