require('dotenv').config(); const fs = require('fs'); const path = require('path'); const express = require('express'); const session = require('express-session'); const { createServer } = require('http'); const { Server } = require('socket.io'); const { OAuth2Client } = require('google-auth-library'); const { execFile } = require('child_process'); const crypto = require('crypto'); const { findStreamerByOverlayToken, findStreamerBySenderToken, getOrCreateStreamerByGoogle, getViewerName, setViewerName, updateDisplayDuration, updateAiName, recordVisit, recordMessage, getStreamerStats, } = require('./db'); const { censorText } = require('./profanity'); const PORT = process.env.PORT || 3000; const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID; const SESSION_SECRET = process.env.SESSION_SECRET; const DEFAULT_TTS_VOICE = process.env.TTS_VOICE || 'ru-RU-DmitryNeural'; const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY; const AI_VOICE = process.env.AI_VOICE || 'ru-RU-SvetlanaNeural'; const AI_PROMPT_PATH = path.join(__dirname, 'ai-prompt.txt'); const FALLBACK_AI_PROMPT = 'Ты дружелюбный ИИ-ассистент в чате стрима. Отвечай коротко и по делу.'; if (!GOOGLE_CLIENT_ID) { console.error('Missing GOOGLE_CLIENT_ID in .env (see .env.example)'); process.exit(1); } if (!SESSION_SECRET) { console.error('Missing SESSION_SECRET in .env (see .env.example)'); process.exit(1); } const oauthClient = new OAuth2Client(GOOGLE_CLIENT_ID); const app = express(); const httpServer = createServer(app); const io = new Server(httpServer); const sessionMiddleware = session({ secret: SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days // set to true once served over HTTPS in production secure: false, sameSite: 'lax', }, }); app.use(sessionMiddleware); app.use(express.json()); // Share the express session with socket.io connections. const wrap = (middleware) => (socket, next) => middleware(socket.request, {}, next); io.use(wrap(sessionMiddleware)); app.get('/config', (req, res) => { res.json({ googleClientId: GOOGLE_CLIENT_ID }); }); app.get('/me', (req, res) => { res.json({ user: req.session.user || null }); }); app.post('/auth/google', async (req, res) => { const { credential } = req.body; if (!credential) { return res.status(400).json({ error: 'missing credential' }); } try { const ticket = await oauthClient.verifyIdToken({ idToken: credential, audience: GOOGLE_CLIENT_ID, }); const payload = ticket.getPayload(); req.session.user = { id: payload.sub, email: payload.email, name: payload.name, picture: payload.picture, }; console.log('[auth] logged in:', payload.email); res.json({ user: req.session.user }); } catch (err) { console.error('Google token verification failed:', err.message); res.status(401).json({ error: 'invalid token' }); } }); app.post('/auth/logout', (req, res) => { req.session.destroy(() => res.json({ ok: true })); }); function requireAuth(req, res, next) { if (!req.session.user) { return res.status(401).json({ error: 'not authenticated' }); } next(); } app.get('/api/dashboard', requireAuth, (req, res) => { const user = req.session.user; const streamer = getOrCreateStreamerByGoogle({ googleId: user.id, email: user.email, name: user.name, picture: user.picture, }); const origin = `${req.protocol}://${req.get('host')}`; res.json({ name: streamer.name, overlayUrl: `${origin}/overlay/${streamer.overlay_token}`, senderUrl: `${origin}/s/${streamer.sender_token}`, ttsVoice: streamer.tts_voice, displayDurationSeconds: streamer.display_duration_seconds, aiName: streamer.ai_name, stats: getStreamerStats(streamer.id), }); }); app.post('/api/settings', requireAuth, (req, res) => { const user = req.session.user; const streamer = getOrCreateStreamerByGoogle({ googleId: user.id, email: user.email, name: user.name, picture: user.picture, }); const seconds = Number(req.body.displayDurationSeconds); if (!Number.isInteger(seconds) || seconds < 1 || seconds > 120) { return res.status(400).json({ error: 'displayDurationSeconds must be an integer between 1 and 120' }); } updateDisplayDuration(streamer.id, seconds); if (typeof req.body.aiName === 'string') { const aiName = req.body.aiName.trim().slice(0, 60); if (!aiName) { return res.status(400).json({ error: 'aiName must not be empty' }); } updateAiName(streamer.id, aiName); } res.json({ displayDurationSeconds: seconds }); }); app.get('/api/viewer-name', requireAuth, (req, res) => { const streamer = findStreamerBySenderToken(req.query.token); if (!streamer) { return res.status(404).json({ error: 'unknown link' }); } const user = req.session.user; const name = getViewerName(streamer.id, user.id) || user.name; res.json({ name }); }); app.get('/api/streamer-info', (req, res) => { const streamer = findStreamerBySenderToken(req.query.token); if (!streamer) { return res.status(404).json({ error: 'unknown link' }); } res.json({ aiName: streamer.ai_name }); }); app.get('/overlay/:token', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'overlay.html')); }); app.get('/s/:token', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'send.html')); }); app.use(express.static(path.join(__dirname, 'public'))); // Shells out to the `edge-tts` Python CLI (same engine as t2sTelegramBot uses) // instead of the edge-tts-node npm package, whose request-signing algorithm // currently gets rejected by Microsoft's servers with a 403. function synthesizeSpeech(text, voice) { return new Promise((resolve, reject) => { execFile( 'edge-tts', ['-t', text, '-v', voice], { encoding: 'buffer', maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => { if (err) return reject(err); resolve(stdout); } ); }); } // edge-tts streams MP3 frames without a proper duration header (no Xing/VBRI // frame), which makes some players — including OBS's embedded CEF browser — // fire the audio element's 'ended' event unreliably (sometimes early). So we // don't trust that event for queue timing at all; instead we estimate how // long the text will take to read aloud and use that as the display floor, // which is deterministic regardless of the browser's audio-decoding quirks. function estimateSpeechMs(text) { const CHARS_PER_SECOND = 15; // conservative reading speed for ru/en TTS voices const STARTUP_BUFFER_MS = 800; // covers synthesis/playback startup latency return STARTUP_BUFFER_MS + Math.ceil(text.length / CHARS_PER_SECOND) * 1000; } function broadcastMessage(room, id, text, from, minDurationMs, ttsText, voice) { const durationMs = Math.max(minDurationMs, estimateSpeechMs(ttsText)); io.to(room).emit('display_message', { id, text, from, at: Date.now(), durationMs }); synthesizeSpeech(ttsText, voice) .then((audioBuffer) => { io.to(room).emit('display_audio', { id, audio: audioBuffer.toString('base64'), mimeType: 'audio/mpeg', }); }) .catch((err) => { console.error('[tts] synthesis failed:', err); }); } function getAiSystemPrompt(aiName) { let basePrompt; try { basePrompt = fs.readFileSync(AI_PROMPT_PATH, 'utf8').trim() || FALLBACK_AI_PROMPT; } catch { basePrompt = FALLBACK_AI_PROMPT; } return `Тебя зовут ${aiName}. ${basePrompt}`; } async function getAiReply(text, aiName) { const res = await fetch('https://api.deepseek.com/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${DEEPSEEK_API_KEY}`, }, body: JSON.stringify({ model: 'deepseek-chat', messages: [ { role: 'system', content: getAiSystemPrompt(aiName) }, { role: 'user', content: text }, ], max_tokens: 150, temperature: 0.8, }), }); if (!res.ok) { throw new Error(`DeepSeek API error: ${res.status} ${await res.text()}`); } const data = await res.json(); return data.choices[0].message.content.trim(); } // Per-streamer chain of pending broadcasts. Without this, a message waiting // on an AI reply (a network round-trip, ~1-3s) could be overtaken by a later // message from a different viewer that doesn't need one — this makes // broadcast order match send order instead of "whichever finished first". const broadcastQueues = new Map(); function enqueueBroadcast(streamerId, task) { const previous = broadcastQueues.get(streamerId) || Promise.resolve(); const next = previous.then(task, task).catch((err) => { console.error('[broadcast-queue] task failed:', err); }); broadcastQueues.set(streamerId, next); } io.on('connection', (socket) => { const { role, token } = socket.handshake.query; const connectedUser = socket.request.session.user; console.log(`[socket] connected id=${socket.id} role=${role} user=${connectedUser ? connectedUser.email : '(none)'}`); if (role === 'overlay') { const streamer = findStreamerByOverlayToken(token); if (!streamer) { console.log(`[socket] id=${socket.id} unknown overlay token=${token}`); socket.emit('invalid_link'); } else { socket.join(`streamer-${streamer.id}`); } } else if (role === 'sender' && connectedUser) { // Only reached on the reconnect that happens right after login (see // socket.disconnect()/connect() in send.js) — the initial anonymous // connection has no session.user yet, so no visit is recorded for it. const streamer = findStreamerBySenderToken(token); if (streamer) { recordVisit(streamer.id, connectedUser.id); } } socket.on('disconnect', (reason) => { console.log(`[socket] disconnected id=${socket.id} reason=${reason}`); }); socket.on('send_message', ({ token: senderToken, text, name, aiReply } = {}) => { const streamer = findStreamerBySenderToken(senderToken); if (!streamer) { socket.emit('send_error', 'Unknown link'); return; } // The session snapshot on socket.request is captured once, at connect time. // If the socket connected before login (page load opens it immediately), // it never sees a session updated later by the separate /auth/google request // unless we explicitly reload it from the store here. socket.request.session.reload((err) => { const user = !err && socket.request.session.user; console.log(`[send_message] id=${socket.id} streamer=${streamer.id} user=${user ? user.email : '(none)'} text=${JSON.stringify(text)}`); if (!user) { socket.emit('send_error', 'Not authenticated'); return; } if (typeof text !== 'string' || !text.trim()) { return; } recordMessage(streamer.id, user.id); const rawMessage = text.trim().slice(0, 500); const message = censorText(rawMessage, '...'); const speechMessage = censorText(rawMessage, 'бип'); const displayName = (typeof name === 'string' && name.trim().slice(0, 60)) || user.name; setViewerName(streamer.id, user.id, displayName); const messageId = crypto.randomUUID(); const room = `streamer-${streamer.id}`; const durationMs = (streamer.display_duration_seconds || 10) * 1000; console.log(`[broadcast] streamer=${streamer.id} id=${messageId} from="${displayName}" "${message}"`); const broadcastOriginal = () => { broadcastMessage( room, messageId, message, displayName, durationMs, `${displayName}. ${speechMessage}`, streamer.tts_voice || DEFAULT_TTS_VOICE ); }; // Broadcasting is enqueued per-streamer so it happens in send order — // otherwise a message waiting on an AI reply could be overtaken by a // later message from another viewer that doesn't need one. enqueueBroadcast(streamer.id, async () => { if (aiReply && DEEPSEEK_API_KEY) { const aiName = streamer.ai_name || 'Альтушка Ирина'; // Wait for the AI reply text before showing anything, so the viewer's // message and the AI reply land in the overlay queue back-to-back // instead of the reply trailing in later with an awkward gap. try { const replyText = await getAiReply(message, aiName); broadcastOriginal(); const aiMessageId = crypto.randomUUID(); console.log(`[ai] streamer=${streamer.id} id=${aiMessageId} reply="${replyText}"`); broadcastMessage( room, aiMessageId, replyText, `${aiName} 🤖`, durationMs, `${aiName} отвечает. ${replyText}`, AI_VOICE ); } catch (err) { console.error('[ai] DeepSeek reply failed:', err); broadcastOriginal(); } } else { broadcastOriginal(); } }); }); }); }); httpServer.listen(PORT, () => { console.log(`Listening on http://localhost:${PORT}`); });