Multi-tenant platform: per-streamer overlay/sender links + CI/CD
Some checks failed
CI/CD Pipeline / build-and-deploy (push) Failing after 2s

Streamers log in with Google on a new landing page and get a
dashboard with two auto-generated, permanent links: one for their
OBS/StreamLabs Browser Source, one to share with viewers. Messages
are now scoped to the right streamer via Socket.IO rooms instead of
broadcasting globally. Streamer records and tokens are persisted in
SQLite (node:sqlite) so links survive restarts/redeploys.

Also adds a Forgejo Actions pipeline mirroring t2sTelegramBot's:
build, smoke-test, push to the local registry, then deploy to the
prod host over SSH.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
vrubelroman 2026-07-01 15:53:07 +00:00
parent 7882db1f37
commit 9c91966730
14 changed files with 444 additions and 34 deletions

View file

@ -7,11 +7,12 @@ const { createServer } = require('http');
const { Server } = require('socket.io');
const { OAuth2Client } = require('google-auth-library');
const { execFile } = require('child_process');
const { findStreamerByOverlayToken, findStreamerBySenderToken, getOrCreateStreamerByGoogle } = 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 TTS_VOICE = process.env.TTS_VOICE || 'ru-RU-DmitryNeural';
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)');
@ -42,7 +43,6 @@ const sessionMiddleware = session({
app.use(sessionMiddleware);
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
// Share the express session with socket.io connections.
const wrap = (middleware) => (socket, next) => middleware(socket.request, {}, next);
@ -70,6 +70,7 @@ app.post('/auth/google', async (req, res) => {
const payload = ticket.getPayload();
req.session.user = {
id: payload.sub,
email: payload.email,
name: payload.name,
picture: payload.picture,
@ -83,14 +84,53 @@ app.post('/auth/google', async (req, res) => {
}
});
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('/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) {
function synthesizeSpeech(text, voice) {
return new Promise((resolve, reject) => {
execFile(
'edge-tts',
['-t', text, '-v', TTS_VOICE],
['-t', text, '-v', voice],
{ encoding: 'buffer', maxBuffer: 10 * 1024 * 1024 },
(err, stdout) => {
if (err) return reject(err);
@ -100,26 +140,39 @@ function synthesizeSpeech(text) {
});
}
app.post('/auth/logout', (req, res) => {
req.session.destroy(() => res.json({ ok: true }));
});
io.on('connection', (socket) => {
const { role, token } = socket.handshake.query;
const connectedUser = socket.request.session.user;
console.log(`[socket] connected id=${socket.id} user=${connectedUser ? connectedUser.email : '(none)'}`);
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', (text) => {
socket.on('send_message', ({ token: senderToken, text } = {}) => {
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} user=${user ? user.email : '(none)'} text=${JSON.stringify(text)}`);
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');
@ -130,9 +183,9 @@ io.on('connection', (socket) => {
}
const message = text.trim().slice(0, 500);
const clientCount = io.engine.clientsCount;
console.log(`[broadcast] "${message}" -> ${clientCount} connected client(s)`);
io.emit('display_message', {
const room = `streamer-${streamer.id}`;
console.log(`[broadcast] streamer=${streamer.id} "${message}"`);
io.to(room).emit('display_message', {
text: message,
from: user.name,
at: Date.now(),
@ -140,9 +193,9 @@ io.on('connection', (socket) => {
// 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)
synthesizeSpeech(message, streamer.tts_voice || DEFAULT_TTS_VOICE)
.then((audioBuffer) => {
io.emit('display_audio', {
io.to(room).emit('display_audio', {
audio: audioBuffer.toString('base64'),
mimeType: 'audio/mpeg',
});