All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 30s
Previously the viewer's message displayed immediately and the AI reply showed up whenever DeepSeek happened to respond, landing with an awkward gap. Now, when the AI checkbox is checked, both texts are fetched/prepared before anything is broadcast, so the original message and the AI reply queue back-to-back on the overlay. Falls back to just showing the original message if the AI call fails. Behavior without the checkbox is unchanged (still immediate). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
323 lines
9.9 KiB
JavaScript
323 lines
9.9 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 broadcastMessage(room, id, text, from, durationMs, ttsText, voice) {
|
|
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() {
|
|
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}`;
|
|
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}. ${message}`,
|
|
streamer.tts_voice || DEFAULT_TTS_VOICE
|
|
);
|
|
};
|
|
|
|
if (aiReply && DEEPSEEK_API_KEY) {
|
|
// 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.
|
|
getAiReply(message)
|
|
.then((replyText) => {
|
|
broadcastOriginal();
|
|
|
|
const aiMessageId = crypto.randomUUID();
|
|
console.log(`[ai] streamer=${streamer.id} id=${aiMessageId} reply="${replyText}"`);
|
|
broadcastMessage(
|
|
room, aiMessageId, replyText, '🤖 ИИ', durationMs,
|
|
`Ответ от ИИ. ${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}`);
|
|
});
|