require('dotenv').config(); 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 { findStreamerByOverlayToken, findStreamerBySenderToken, getOrCreateStreamerByGoogle, getViewerName, setViewerName, } = require('./db'); 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'; 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, }); }); 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('/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); } ); }); } 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}`); } } socket.on('disconnect', (reason) => { console.log(`[socket] disconnected id=${socket.id} reason=${reason}`); }); socket.on('send_message', ({ token: senderToken, text, name } = {}) => { 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; } const message = text.trim().slice(0, 500); const displayName = (typeof name === 'string' && name.trim().slice(0, 60)) || user.name; setViewerName(streamer.id, user.id, displayName); const room = `streamer-${streamer.id}`; console.log(`[broadcast] streamer=${streamer.id} from="${displayName}" "${message}"`); io.to(room).emit('display_message', { text: message, from: displayName, at: Date.now(), }); // Voice is generated after the fact so the text shows up immediately // instead of waiting on the round trip to the TTS service. synthesizeSpeech(message, streamer.tts_voice || DEFAULT_TTS_VOICE) .then((audioBuffer) => { io.to(room).emit('display_audio', { audio: audioBuffer.toString('base64'), mimeType: 'audio/mpeg', }); }) .catch((err) => { console.error('[tts] synthesis failed:', err); }); }); }); }); httpServer.listen(PORT, () => { console.log(`Listening on http://localhost:${PORT}`); });