/** * POST /api/chat/voice (multipart/form-data) * fields: to, clientMsgId, duration, fromName, lang + file: audio * * Stores a voice message: the audio blob goes into chat.db, a normal chat * message is inserted carrying meta { kind:'voice', audioId, duration, … } and * is fanned out over the socket to both parties (so it appears instantly and is * playable). Transcription runs in the BACKGROUND (whisper.cpp) and, when ready, * patches the message (body = transcript, for search) and emits a * `message-update` frame so the bubble fills in its transcript live. * * Gated to staff. Returns 404 when chat is disabled (kill-switch). */ import { gateFromEvent, isStaffUser } from '../../utils/chatAccess' import { insertMessage, insertVoiceNote, linkVoiceNoteMessage, updateMessageContent } from '../../utils/chatDb' import { sendToUser, isConnected } from '../../utils/chatPresence' import { sendPushToUser, buildChatMessagePayload } from '../../utils/pushNotifier' import { sendFcmToUser } from '../../utils/fcmNotifier' import { transcribeAudio } from '../../utils/chatTranscribe' const VOICE_LABEL = '🎤 Sprachnachricht' export default defineEventHandler(async (event) => { const config = useRuntimeConfig() if (config.chatEnabled !== true) { setResponseStatus(event, 404) return { error: 'chat disabled' } } let userId: number, token: string try { ({ userId, token } = await gateFromEvent(event)) } catch (err: any) { setResponseStatus(event, err?.statusCode || 403) return { error: 'forbidden' } } const parts = await readMultipartFormData(event) if (!parts) { setResponseStatus(event, 400); return { error: 'multipart required' } } let to = 0, clientMsgId = '', duration = 0, fromName = '', lang = 'auto' let audio: Buffer | null = null let mime = 'audio/webm' for (const p of parts) { if (p.name === 'to') to = Number(p.data.toString()) else if (p.name === 'clientMsgId') clientMsgId = p.data.toString() else if (p.name === 'duration') duration = Number(p.data.toString()) || 0 else if (p.name === 'fromName') fromName = p.data.toString() else if (p.name === 'lang') lang = p.data.toString() || 'de' else if (p.name === 'audio') { audio = p.data; if (p.type) mime = p.type } } if (!to || to === userId) { setResponseStatus(event, 400); return { error: 'bad recipient' } } if (!audio || !audio.length) { setResponseStatus(event, 400); return { error: 'audio required' } } if (audio.length > 25 * 1024 * 1024) { setResponseStatus(event, 413); return { error: 'audio too large' } } // Recipient must be staff (mirror the WS message guard). Only reject on a hard "no". if (!isConnected(to)) { const staff = await isStaffUser(to, token) if (staff === false) { setResponseStatus(event, 403); return { error: 'recipient not allowed' } } } const voice = insertVoiceNote({ senderUserId: userId, recipientUserId: to, mime, duration: duration || null, bytes: audio }) if (!voice) { setResponseStatus(event, 500); return { error: 'storage failed' } } const meta: any = { kind: 'voice', audioId: voice.id, duration: duration || 0, mime, transcript: null, transcribing: true } const createdAt = Date.now() const saved = insertMessage({ senderUserId: userId, recipientUserId: to, body: VOICE_LABEL, createdAt, clientMsgId: clientMsgId || null, meta }) const msg = saved ?? { id: null, senderUserId: userId, recipientUserId: to, body: VOICE_LABEL, createdAt, readAt: null, clientMsgId: clientMsgId || null, meta } if (saved?.id) linkVoiceNoteMessage(voice.id, saved.id) const out = { type: 'message', message: msg } sendToUser(to, out) sendToUser(userId, out) // Native FCM to ALL the recipient's devices regardless of presence (a backgrounded 2nd device gets // nothing from the live socket while another keeps the user "online"). Foreground devices ignore it // silently, so no double-notify. sendFcmToUser(to, { title: fromName || 'Neue Nachricht', body: VOICE_LABEL, data: { senderUserId: String(userId), url: `/chat?chat=${userId}` }, }).catch((e: any) => console.error('[ChatVoice] FCM failed:', e?.message)) // Web push (browsers/PWA) stays offline-only. if (!isConnected(to)) { const payload = buildChatMessagePayload(fromName || '', VOICE_LABEL, userId) sendPushToUser(to, payload).catch((e: any) => console.error('[ChatVoice] offline push failed:', e?.message)) } // Background transcription → patch message + live update both parties. if (saved?.id) { transcribeAudio(audio, 'voice.webm', lang) .then((text) => { const finalMeta = { ...meta, transcript: text || '', transcribing: false } // body becomes the transcript so spoken words are searchable; keep the // label if nothing was recognised. const newBody = text ? text : VOICE_LABEL updateMessageContent(saved.id, { body: newBody, meta: finalMeta }) const updated = { ...msg, body: newBody, meta: finalMeta } const upd = { type: 'message-update', message: updated } sendToUser(to, upd) sendToUser(userId, upd) }) .catch((e: any) => console.error('[ChatVoice] transcription error:', e?.message)) } return { status: 200, message: msg } })