diff --git a/.env.example b/.env.example index 43bc130..a52e95e 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,13 @@ SMTP_FROM=noreply@example.com # App URL (used for verification links, auto-detected if not set) # APP_URL=https://your-domain.com +# Recording Transcription / AI Summarization +# When the per-room "AI summary / transcript" setting is enabled, Redlight will POST to +# ${TRANSCRIPTION_API_URL}/prompt with header `X-Api-Key: ${TRANSCRIPTION_API_KEY}` as soon as +# the "video" format of the recording is available. Leave empty to disable the integration entirely. +# TRANSCRIPTION_API_URL= +# TRANSCRIPTION_API_KEY= + # Reverse Proxy trust depth (express 'trust proxy' setting) # loopback = trust only 127.0.0.1 / ::1 (default) # Use a number for proxy hops (e.g. 1), or a specific IP/CIDR. diff --git a/package-lock.json b/package-lock.json index 39ff8b0..c8fa1cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "redlight", - "version": "2.3.0", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "redlight", - "version": "2.3.0", + "version": "2.4.0", "license": "GPL-3.0-or-later", "dependencies": { "axios": "^1.7.0", diff --git a/package.json b/package.json index 89151f1..6680355 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "redlight", "private": true, - "version": "2.3.0", + "version": "2.4.0", "license": "GPL-3.0-or-later", "type": "module", "scripts": { diff --git a/server/config/bbb.js b/server/config/bbb.js index c787761..2ab8c5a 100644 --- a/server/config/bbb.js +++ b/server/config/bbb.js @@ -2,6 +2,7 @@ import xml2js from 'xml2js'; import { log, fmtDuration, fmtStatus, fmtMethod, fmtReturncode, sanitizeBBBParams } from './logger.js'; import { t } from './emaili18n.js'; +import { isTranscriptionEnabled } from './transcription.js'; const BBB_URL = process.env.BBB_URL || 'https://your-bbb-server.com/bigbluebutton/api/'; const BBB_SECRET = process.env.BBB_SECRET || ''; @@ -115,6 +116,11 @@ export async function createMeeting(room, logoutURL, loginURL = null, presentati if (analyticsCallbackURL) { params['meta_analytics-callback-url'] = analyticsCallbackURL; } + // BBB posts a signed recording-ready callback there once recording formats + // are processed; used to trigger the AI summary/transcript integration. + if (logoutURL && room.recording_transcript && isTranscriptionEnabled()) { + params['meta_bbb-recording-ready-url'] = `${logoutURL}/api/recordings/recording-ready`; + } // Build optional presentation XML body - escape URL to prevent XML injection let xmlBody = null; diff --git a/server/config/database.js b/server/config/database.js index 25155fe..2362ed3 100644 --- a/server/config/database.js +++ b/server/config/database.js @@ -879,6 +879,18 @@ export async function initDatabase() { `); } + // ── Recording transcription / AI summary ──────────────────────────────── + if (!(await db.columnExists('rooms', 'recording_transcript'))) { + await db.exec('ALTER TABLE rooms ADD COLUMN recording_transcript INTEGER DEFAULT 0'); + } + if (!(await db.columnExists('recordings', 'transcript_requested_at'))) { + if (isPostgres) { + await db.exec('ALTER TABLE recordings ADD COLUMN transcript_requested_at TIMESTAMP DEFAULT NULL'); + } else { + await db.exec('ALTER TABLE recordings ADD COLUMN transcript_requested_at DATETIME DEFAULT NULL'); + } + } + // ── Default admin (only on very first start) ──────────────────────────── const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'"); if (!adminAlreadySeeded) { diff --git a/server/config/transcription.js b/server/config/transcription.js new file mode 100644 index 0000000..27969fd --- /dev/null +++ b/server/config/transcription.js @@ -0,0 +1,68 @@ +import { log } from './logger.js'; + +/** + * Recording transcription / AI summarization integration. + * + * When the per-room "recording transcript" setting is enabled, Redlight POSTs + * to ${TRANSCRIPTION_API_URL}/prompt with header `X-Api-Key: ${TRANSCRIPTION_API_KEY}` + * as soon as the "video" format of a recording becomes available (delivered via + * BBB's recording-ready callback). Leave both env vars empty to disable the + * integration entirely. + */ + +const TRANSCRIPTION_API_URL = process.env.TRANSCRIPTION_API_URL || ''; +const TRANSCRIPTION_API_KEY = process.env.TRANSCRIPTION_API_KEY || ''; + +const DEFAULT_PROMPT = 'Summarise the key decisions made in this meeting.'; + +export function isTranscriptionEnabled() { + return !!(TRANSCRIPTION_API_URL && TRANSCRIPTION_API_KEY); +} + +/** + * Request a summary/transcript for a recording. Never throws — failures are + * logged and reported via the boolean return value. + * + * @param {object} opts + * @param {string} opts.meetingId Room UID / BBB meeting ID + * @param {string} opts.recordingId BBB record ID + * @param {string} [opts.prompt] Optional custom prompt + * @returns {Promise} true if the request was accepted + */ +export async function requestTranscription({ meetingId, recordingId, prompt }) { + if (!isTranscriptionEnabled()) { + log.server.warn('Transcription request skipped: TRANSCRIPTION_API_URL or TRANSCRIPTION_API_KEY is not configured'); + return false; + } + + const base = TRANSCRIPTION_API_URL.endsWith('/') ? TRANSCRIPTION_API_URL : `${TRANSCRIPTION_API_URL}/`; + const url = new URL('prompt', base).toString(); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Api-Key': TRANSCRIPTION_API_KEY, + }, + body: JSON.stringify({ + prompt: prompt || DEFAULT_PROMPT, + meeting_id: meetingId, + recording_id: recordingId, + }), + signal: AbortSignal.timeout(15_000), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + log.server.error(`Transcription request failed: status=${response.status} body=${body.slice(0, 200)}`); + return false; + } + + log.server.info(`Transcription requested for recording ${recordingId} (meeting: ${meetingId})`); + return true; + } catch (err) { + log.server.error(`Transcription request error: ${err.message}`); + return false; + } +} diff --git a/server/routes/branding.js b/server/routes/branding.js index 3a8e5f9..ef7a913 100644 --- a/server/routes/branding.js +++ b/server/routes/branding.js @@ -7,6 +7,7 @@ import { getDb } from '../config/database.js'; import { authenticateToken, requireAdmin } from '../middleware/auth.js'; import { log } from '../config/logger.js'; import { getOAuthConfig } from '../config/oauth.js'; +import { isTranscriptionEnabled } from '../config/transcription.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -121,6 +122,7 @@ router.get('/', async (req, res) => { oauthEnabled, oauthDisplayName, hideAppName: hideAppName === 'true', + transcriptionEnabled: isTranscriptionEnabled(), }); } catch (err) { log.branding.error('Get branding error:', err); diff --git a/server/routes/federation.js b/server/routes/federation.js index 5b90d33..333f244 100644 --- a/server/routes/federation.js +++ b/server/routes/federation.js @@ -41,7 +41,7 @@ export function wellKnownHandler(req, res) { federation_api: '/api/federation', public_key: getPublicKey(), software: 'Redlight', - version: '2.3.0', + version: '2.4.0', }); } diff --git a/server/routes/recordings.js b/server/routes/recordings.js index 5bda5e1..7b46866 100644 --- a/server/routes/recordings.js +++ b/server/routes/recordings.js @@ -1,7 +1,9 @@ -import { Router } from 'express'; +import { Router, urlencoded } from 'express'; +import jwt from 'jsonwebtoken'; import { authenticateToken } from '../middleware/auth.js'; import { getDb } from '../config/database.js'; import { log } from '../config/logger.js'; +import { isTranscriptionEnabled, requestTranscription } from '../config/transcription.js'; import { getRecordings, getRecordingByRecordId, @@ -9,6 +11,8 @@ import { publishRecording, } from '../config/bbb.js'; +const BBB_SECRET = process.env.BBB_SECRET || ''; + const router = Router(); /** @@ -176,6 +180,65 @@ async function fetchAndSyncRecordings(meetingID, fallbackName) { } } +// POST /api/recordings/recording-ready - BBB recording-ready callback (unauthenticated) +// BBB posts `signed_parameters`, a JWT signed with the shared BBB secret. +// Fires once per playback format; the transcription request is only sent when +// the "video" format first becomes available and the room has the setting on. +router.post('/recording-ready', urlencoded({ extended: false }), async (req, res) => { + try { + const signed = req.body?.signed_parameters; + if (!signed || typeof signed !== 'string') { + return res.status(400).json({ error: 'signed_parameters is required' }); + } + + let payload; + try { + payload = jwt.verify(signed, BBB_SECRET, { algorithms: ['HS256'] }); + } catch { + return res.status(403).json({ error: 'Invalid signature' }); + } + + const recordId = payload.record_id; + const meetingId = payload.meeting_id; + if (!recordId || !meetingId) { + return res.status(400).json({ error: 'record_id and meeting_id are required' }); + } + + // Always answer 200 from here on so BBB does not keep retrying — + // "nothing to do" is a valid outcome for this callback. + const db = getDb(); + const room = await db.get('SELECT id, uid, name, recording_transcript FROM rooms WHERE uid = ?', [meetingId]); + if (!room || !room.recording_transcript || !isTranscriptionEnabled()) { + return res.json({}); + } + + const rec = await getRecordingByRecordId(recordId).catch(() => null); + if (!rec) return res.json({}); + + const formatted = formatBbbRecording(rec, room.name); + const hasVideo = formatted.formats.some(f => f.type === 'video'); + + // Cache the recording so the dedupe marker below has a row to live on + await upsertRecording(db, formatted); + + if (!hasVideo) return res.json({}); + + // Dedupe: BBB fires the callback once per format + const cached = await db.get('SELECT transcript_requested_at FROM recordings WHERE record_id = ?', [recordId]); + if (cached?.transcript_requested_at) return res.json({}); + + const ok = await requestTranscription({ meetingId, recordingId: recordId }); + if (ok) { + await db.run('UPDATE recordings SET transcript_requested_at = CURRENT_TIMESTAMP WHERE record_id = ?', [recordId]); + } + + res.json({}); + } catch (err) { + log.recordings.error(`Recording-ready callback error: ${err.message}`); + res.status(500).json({ error: 'Error processing recording-ready callback' }); + } +}); + // GET /api/recordings - Get recordings for a room (by meetingID/uid) router.get('/', authenticateToken, async (req, res) => { try { diff --git a/server/routes/rooms.js b/server/routes/rooms.js index 5cff940..abd921a 100644 --- a/server/routes/rooms.js +++ b/server/routes/rooms.js @@ -193,6 +193,7 @@ router.post('/', authenticateToken, async (req, res) => { record_meeting, guest_access, moderator_code, + recording_transcript, } = req.body; if (!name || name.trim().length === 0) { @@ -227,8 +228,8 @@ router.post('/', authenticateToken, async (req, res) => { const db = getDb(); const result = await db.run(` - INSERT INTO rooms (uid, name, user_id, welcome_message, max_participants, access_code, mute_on_join, require_approval, anyone_can_start, all_join_moderator, record_meeting, guest_access, moderator_code) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO rooms (uid, name, user_id, welcome_message, max_participants, access_code, mute_on_join, require_approval, anyone_can_start, all_join_moderator, record_meeting, guest_access, moderator_code, recording_transcript) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ uid, name.trim(), @@ -243,6 +244,7 @@ router.post('/', authenticateToken, async (req, res) => { record_meeting !== false ? 1 : 0, guest_access ? 1 : 0, moderator_code || null, + recording_transcript ? 1 : 0, ]); const room = await db.get('SELECT * FROM rooms WHERE id = ?', [result.lastInsertRowid]); @@ -277,6 +279,7 @@ router.put('/:uid', authenticateToken, async (req, res) => { moderator_code, learning_analytics, analytics_visibility, + recording_transcript, } = req.body; // M12: field length limits (same as create) @@ -318,6 +321,7 @@ router.put('/:uid', authenticateToken, async (req, res) => { moderator_code = ?, learning_analytics = COALESCE(?, learning_analytics), analytics_visibility = COALESCE(?, analytics_visibility), + recording_transcript = COALESCE(?, recording_transcript), updated_at = CURRENT_TIMESTAMP WHERE uid = ? `, [ @@ -334,6 +338,7 @@ router.put('/:uid', authenticateToken, async (req, res) => { moderator_code !== undefined ? (moderator_code || null) : room.moderator_code, learning_analytics !== undefined ? (learning_analytics ? 1 : 0) : null, analytics_visibility && ['owner', 'shared'].includes(analytics_visibility) ? analytics_visibility : null, + recording_transcript !== undefined ? (recording_transcript ? 1 : 0) : null, req.params.uid, ]); diff --git a/src/i18n/de.json b/src/i18n/de.json index cfe3ed5..defaf07 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -209,6 +209,7 @@ "anyoneCanStart": "Jeder kann das Meeting starten", "allJoinModerator": "Alle Teilnehmer als Moderator", "allowRecording": "Aufnahme erlauben", + "enableTranscript": "KI-Zusammenfassung / Transkript für Aufnahmen", "noAccessCode": "Kein Zugangscode", "emptyNoCode": "Leer = kein Code", "settingsSaved": "Einstellungen gespeichert", diff --git a/src/i18n/en.json b/src/i18n/en.json index fb9b069..21969d1 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -209,6 +209,7 @@ "anyoneCanStart": "Anyone can start the meeting", "allJoinModerator": "All participants as moderator", "allowRecording": "Allow recording", + "enableTranscript": "AI summary / transcript for recordings", "noAccessCode": "No access code", "emptyNoCode": "Empty = no code", "settingsSaved": "Settings saved", diff --git a/src/pages/RoomDetail.jsx b/src/pages/RoomDetail.jsx index e08e604..07f83f8 100644 --- a/src/pages/RoomDetail.jsx +++ b/src/pages/RoomDetail.jsx @@ -10,6 +10,7 @@ import Modal from '../components/Modal'; import api from '../services/api'; import { isCurrentThemeDark } from '../themes'; import { useAuth } from '../contexts/AuthContext'; +import { useBranding } from '../contexts/BrandingContext'; import { useLanguage } from '../contexts/LanguageContext'; import RecordingList from '../components/RecordingList'; import AnalyticsList from '../components/AnalyticsList'; @@ -19,6 +20,7 @@ export default function RoomDetail() { const { uid } = useParams(); const navigate = useNavigate(); const { user } = useAuth(); + const { transcriptionEnabled } = useBranding(); const { t } = useLanguage(); const [room, setRoom] = useState(null); @@ -203,6 +205,7 @@ export default function RoomDetail() { moderator_code: editRoom.moderator_code, learning_analytics: !!editRoom.learning_analytics, analytics_visibility: editRoom.analytics_visibility || 'owner', + recording_transcript: !!editRoom.recording_transcript, }); setRoom(res.data.room); setEditRoom(res.data.room); @@ -676,6 +679,18 @@ export default function RoomDetail() { /> {t('room.allowRecording')} + {transcriptionEnabled && ( + + )}