Some checks failed
CI/CD Pipeline / build-and-deploy (push) Failing after 3s
Viewers can check a box on the send page to get a short AI-generated
reply to their message, voiced with a distinct TTS voice and queued
on the overlay after the original message. The system prompt lives
in ai-prompt.txt (editable without touching code) instead of being
hardcoded. TTS for both viewer messages and AI replies now announces
who it's from ("Сообщение от X." / "Ответ от ИИ.") before the text.
No-ops entirely if DEEPSEEK_API_KEY isn't set.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
327 lines
10 KiB
JavaScript
327 lines
10 KiB
JavaScript
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,
|
|
} = 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';
|
|
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,
|
|
});
|
|
});
|
|
|
|
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);
|
|
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('/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);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
function getAiSystemPrompt() {
|
|
try {
|
|
const content = fs.readFileSync(AI_PROMPT_PATH, 'utf8').trim();
|
|
return content || FALLBACK_AI_PROMPT;
|
|
} catch {
|
|
return FALLBACK_AI_PROMPT;
|
|
}
|
|
}
|
|
|
|
async function getAiReply(text) {
|
|
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() },
|
|
{ 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();
|
|
}
|
|
|
|
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, 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;
|
|
}
|
|
|
|
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 messageId = crypto.randomUUID();
|
|
const room = `streamer-${streamer.id}`;
|
|
console.log(`[broadcast] streamer=${streamer.id} id=${messageId} from="${displayName}" "${message}"`);
|
|
io.to(room).emit('display_message', {
|
|
id: messageId,
|
|
text: message,
|
|
from: displayName,
|
|
at: Date.now(),
|
|
durationMs: (streamer.display_duration_seconds || 10) * 1000,
|
|
});
|
|
|
|
// Voice is generated after the fact so the text shows up immediately
|
|
// instead of waiting on the round trip to the TTS service. The overlay
|
|
// queues messages and matches this to the right one by id, since it may
|
|
// arrive before that message's turn to display.
|
|
synthesizeSpeech(`Сообщение от ${displayName}. ${message}`, streamer.tts_voice || DEFAULT_TTS_VOICE)
|
|
.then((audioBuffer) => {
|
|
io.to(room).emit('display_audio', {
|
|
id: messageId,
|
|
audio: audioBuffer.toString('base64'),
|
|
mimeType: 'audio/mpeg',
|
|
});
|
|
})
|
|
.catch((err) => {
|
|
console.error('[tts] synthesis failed:', err);
|
|
});
|
|
|
|
if (aiReply && DEEPSEEK_API_KEY) {
|
|
getAiReply(message)
|
|
.then((replyText) => {
|
|
const aiMessageId = crypto.randomUUID();
|
|
console.log(`[ai] streamer=${streamer.id} id=${aiMessageId} reply="${replyText}"`);
|
|
io.to(room).emit('display_message', {
|
|
id: aiMessageId,
|
|
text: replyText,
|
|
from: '🤖 ИИ',
|
|
at: Date.now(),
|
|
durationMs: (streamer.display_duration_seconds || 10) * 1000,
|
|
});
|
|
|
|
synthesizeSpeech(`Ответ от ИИ. ${replyText}`, AI_VOICE)
|
|
.then((audioBuffer) => {
|
|
io.to(room).emit('display_audio', {
|
|
id: aiMessageId,
|
|
audio: audioBuffer.toString('base64'),
|
|
mimeType: 'audio/mpeg',
|
|
});
|
|
})
|
|
.catch((err) => {
|
|
console.error('[tts] AI reply synthesis failed:', err);
|
|
});
|
|
})
|
|
.catch((err) => {
|
|
console.error('[ai] DeepSeek reply failed:', err);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
httpServer.listen(PORT, () => {
|
|
console.log(`Listening on http://localhost:${PORT}`);
|
|
});
|