godcrm/backend/routes/v3/ai-agents/voiceTranscriptionController.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

208 lines
8 KiB
JavaScript

/**
* Voice Transcription & Operators Controller
* ADR-027: Voice transcription API
* GET /operators, POST /transcribe
* GET/PATCH /spaces/:spaceId/transcription
*/
import { Router } from 'express';
import { authenticate } from '../../../middleware/auth.js';
import { dbGet, dbAll, dbRun, isPostgres } from '../../../database/connection.js';
import { apiLogger } from '../../../utils/logger.js';
import { success, notFound, badRequest, error } from '../../../utils/response.js';
import { getSecret } from '../../../services/secrets/getSecret.js';
import { safeParseJSON } from './shared.js';
const router = Router();
/**
* GET /operators
*/
router.get('/operators', authenticate, async (req, res) => {
try {
const { space_id, capability } = req.query;
let operatorsTable;
if (space_id) {
operatorsTable = await dbGet(`
SELECT ut.id FROM universal_tables ut JOIN projects p ON ut.project_id = p.id
WHERE p.space_id = ? AND ut.name LIKE '%Operators%' LIMIT 1
`, [space_id]);
}
if (!operatorsTable) {
operatorsTable = await dbGet(`SELECT id FROM universal_tables WHERE name LIKE '%Operators%' LIMIT 1`);
}
if (!operatorsTable) return success(res, []);
const operators = await dbAll(`
SELECT id, base_id, data, created_at FROM table_rows WHERE table_id = ? ORDER BY created_at ASC
`, [operatorsTable.id]);
let result = operators.map(row => {
const data = safeParseJSON(row.data, {});
return {
id: row.id, base_id: row.base_id, name: data.name || 'Unknown',
provider: data.provider || 'openai', status: data.status || 'inactive',
api_url: data.api_url || '', models: data.models || '',
capabilities: data.capabilities || { chat: true }, description: data.description || ''
};
});
if (capability) {
result = result.filter(op => op.capabilities && op.capabilities[capability] === true);
}
return success(res, result);
} catch (err) {
apiLogger.error({ err, context: 'GET operators' }, 'Error fetching operators');
return error(res, 'FETCH_OPERATORS_ERROR', 'Failed to fetch operators', 500);
}
});
/**
* POST /transcribe
*/
router.post('/transcribe', authenticate, async (req, res) => {
try {
const { audio, format = 'webm', operator_id, space_id, language } = req.body;
if (!audio) return badRequest(res, 'Missing required field: audio');
let operatorData = null;
let apiKey = null;
let apiUrl = 'https://api.openai.com/v1';
if (operator_id) {
const operatorRow = await dbGet(`
SELECT tr.data FROM table_rows tr JOIN universal_tables ut ON tr.table_id = ut.id
WHERE tr.id = ? AND ut.name LIKE '%Operators%'
`, [operator_id]);
if (operatorRow) operatorData = safeParseJSON(operatorRow.data, {});
}
if (!operatorData && space_id) {
const space = await dbGet(`SELECT settings FROM spaces WHERE id = ?`, [space_id]);
if (space) {
const settings = safeParseJSON(space.settings, {});
if (settings.transcription?.operator_id) {
const operatorRow = await dbGet(`
SELECT tr.data FROM table_rows tr JOIN universal_tables ut ON tr.table_id = ut.id
WHERE tr.id = ? AND ut.name LIKE '%Operators%'
`, [settings.transcription.operator_id]);
if (operatorRow) operatorData = safeParseJSON(operatorRow.data, {});
}
}
}
if (!operatorData) {
const operatorRow = await dbGet(`
SELECT tr.data FROM table_rows tr JOIN universal_tables ut ON tr.table_id = ut.id
WHERE ut.name LIKE '%Operators%'
AND (
${isPostgres()
? "(tr.data::jsonb->'capabilities'->>'transcription')::boolean = true OR tr.data::jsonb->>'provider' = 'openai'"
: "json_extract(tr.data, '$.capabilities.transcription') = 1 OR json_extract(tr.data, '$.provider') = 'openai'"
}
)
AND ${isPostgres()
? "tr.data::jsonb->>'status' = 'active'"
: "json_extract(tr.data, '$.status') = 'active'"
}
ORDER BY tr.created_at ASC LIMIT 1
`);
if (operatorRow) operatorData = safeParseJSON(operatorRow.data, {});
}
if (operatorData) {
apiKey = operatorData.api_key;
apiUrl = operatorData.api_url || 'https://api.openai.com/v1';
}
if (!apiKey) apiKey = await getSecret('openai_api_key', 'OPENAI_API_KEY');
if (!apiKey) return badRequest(res, 'No API key configured for transcription');
const audioBuffer = Buffer.from(audio, 'base64');
const maxSize = 25 * 1024 * 1024;
if (audioBuffer.length > maxSize) return badRequest(res, 'Audio file too large. Maximum size is 25MB.');
const mimeTypes = {
'webm': 'audio/webm', 'mp3': 'audio/mpeg', 'wav': 'audio/wav',
'm4a': 'audio/m4a', 'ogg': 'audio/ogg', 'flac': 'audio/flac'
};
const mimeType = mimeTypes[format] || 'audio/webm';
const FormData = (await import('form-data')).default;
const formData = new FormData();
formData.append('file', audioBuffer, { filename: `audio.${format}`, contentType: mimeType });
formData.append('model', 'whisper-1');
if (language) formData.append('language', language);
const fetch = (await import('node-fetch')).default;
const whisperUrl = `${apiUrl.replace(/\/$/, '')}/audio/transcriptions`;
const response = await fetch(whisperUrl, {
method: 'POST',
headers: { 'Authorization': `Bearer ${apiKey}`, ...formData.getHeaders() },
body: formData
});
if (!response.ok) {
const errorText = await response.text();
apiLogger.error({ status: response.status, error: errorText, context: 'Whisper API' }, 'Whisper API error');
return error(res, 'WHISPER_API_ERROR', `Transcription failed: ${response.statusText}`, response.status);
}
const result = await response.json();
return success(res, { data: { text: result.text, language: language || 'auto' } });
} catch (err) {
apiLogger.error({ err, context: 'Transcribe' }, 'Error transcribing audio');
return error(res, 'TRANSCRIBE_ERROR', 'Failed to transcribe audio', 500);
}
});
/**
* GET /spaces/:spaceId/transcription
*/
router.get('/spaces/:spaceId/transcription', authenticate, async (req, res) => {
try {
const { spaceId } = req.params;
const space = await dbGet(`SELECT settings FROM spaces WHERE id = ?`, [spaceId]);
if (!space) return notFound(res, 'Space not found');
const settings = safeParseJSON(space.settings, {});
const transcription = settings.transcription || { enabled: false, operator_id: null, language: 'auto' };
return success(res, transcription);
} catch (err) {
apiLogger.error({ err, context: 'GET transcription settings' }, 'Error fetching settings');
return error(res, 'FETCH_TRANSCRIPTION_SETTINGS_ERROR', 'Failed to fetch transcription settings', 500);
}
});
/**
* PATCH /spaces/:spaceId/transcription
*/
router.patch('/spaces/:spaceId/transcription', authenticate, async (req, res) => {
try {
const { spaceId } = req.params;
const { enabled, operator_id, language } = req.body;
const space = await dbGet(`SELECT settings FROM spaces WHERE id = ?`, [spaceId]);
if (!space) return notFound(res, 'Space not found');
const settings = safeParseJSON(space.settings, {});
settings.transcription = {
enabled: enabled !== undefined ? enabled : settings.transcription?.enabled || false,
operator_id: operator_id !== undefined ? operator_id : settings.transcription?.operator_id || null,
language: language !== undefined ? language : settings.transcription?.language || 'auto'
};
await dbRun(`UPDATE spaces SET settings = ?, updated_at = datetime('now') WHERE id = ?`,
[JSON.stringify(settings), spaceId]);
return success(res, { data: settings.transcription });
} catch (err) {
apiLogger.error({ err, context: 'PATCH transcription settings' }, 'Error updating settings');
return error(res, 'UPDATE_TRANSCRIPTION_SETTINGS_ERROR', 'Failed to update transcription settings', 500);
}
});
export default router;