Multi-tenant platform: per-streamer overlay/sender links + CI/CD
Some checks failed
CI/CD Pipeline / build-and-deploy (push) Failing after 2s
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:
parent
7882db1f37
commit
9c91966730
14 changed files with 444 additions and 34 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
node_modules
|
node_modules
|
||||||
.env
|
.env
|
||||||
.git
|
.git
|
||||||
|
data
|
||||||
|
|
|
||||||
16
.env.example
16
.env.example
|
|
@ -1,15 +1,17 @@
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
# OAuth 2.0 Client ID (Web application) from https://console.cloud.google.com/apis/credentials
|
# OAuth 2.0 Client ID (Web application) from https://console.cloud.google.com/apis/credentials
|
||||||
# Authorized JavaScript origins must include the origin you open sender.html from,
|
# Authorized JavaScript origins must include every origin the app is served from
|
||||||
# e.g. http://localhost:3000 for local dev.
|
# (e.g. http://localhost:3000 for local dev, plus your production domain).
|
||||||
GOOGLE_CLIENT_ID=
|
# The value below is a placeholder so `docker-compose build` / CI smoke tests can
|
||||||
|
# boot the container; replace with the real Client ID for actual logins to work.
|
||||||
|
GOOGLE_CLIENT_ID=placeholder-client-id.apps.googleusercontent.com
|
||||||
|
|
||||||
# Random long string, used to sign the session cookie. Generate with:
|
# Random long string, used to sign the session cookie. Generate with:
|
||||||
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
SESSION_SECRET=
|
SESSION_SECRET=placeholder-session-secret-change-me
|
||||||
|
|
||||||
# Microsoft Edge Read Aloud voice used for TTS (free, no API key needed).
|
# Default Microsoft Edge Read Aloud voice used for TTS (free, no API key needed,
|
||||||
# Same engine as t2sTelegramBot uses. List of voices: `npx edge-tts-node --list-voices` (if available)
|
# same engine as t2sTelegramBot). Used for newly created streamers; each
|
||||||
# or see https://github.com/Migushthe2nd/MsEdgeTTS
|
# streamer's own voice is stored in the database and can be changed later.
|
||||||
TTS_VOICE=ru-RU-DmitryNeural
|
TTS_VOICE=ru-RU-DmitryNeural
|
||||||
|
|
|
||||||
49
.forgejo/workflows/deploy.yml
Normal file
49
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
name: CI/CD Pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: shell
|
||||||
|
steps:
|
||||||
|
- name: Clone repository
|
||||||
|
run: git clone --depth 1 "http://192.168.8.174:3000/${GITHUB_REPOSITORY}.git" .
|
||||||
|
env:
|
||||||
|
GIT_TERMINAL_PROMPT: '0'
|
||||||
|
|
||||||
|
- name: Ensure Docker CLI
|
||||||
|
run: |
|
||||||
|
apk add --no-cache docker-cli docker-cli-compose openssh-client || true
|
||||||
|
grep -q '^vrubel:' /etc/passwd || echo 'vrubel:x:1000:1000::/data:/bin/sh' >> /etc/passwd
|
||||||
|
|
||||||
|
- name: Setup .env for CI/CD
|
||||||
|
run: cp .env.example .env
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: docker-compose build
|
||||||
|
|
||||||
|
- name: Start container for test
|
||||||
|
run: docker-compose up -d
|
||||||
|
|
||||||
|
- name: Verify service running
|
||||||
|
run: |
|
||||||
|
sleep 5
|
||||||
|
docker-compose logs --tail=20
|
||||||
|
|
||||||
|
- name: Stop container
|
||||||
|
run: docker-compose down
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login http://192.168.8.174:3000 -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Push image to registry
|
||||||
|
run: docker push 192.168.8.174:3000/vrubel/send2streamer:latest
|
||||||
|
|
||||||
|
- name: Copy docker-compose to prod host
|
||||||
|
run: cat docker-compose.prod.yml | ssh -i /data/.ssh/id_ed25519 -o StrictHostKeyChecking=no vrubel@192.168.8.171 "mkdir -p ~/services/sendMessageToStreamer && cat > ~/services/sendMessageToStreamer/docker-compose.yml"
|
||||||
|
|
||||||
|
- name: Deploy on prod host
|
||||||
|
run: ssh -i /data/.ssh/id_ed25519 -o StrictHostKeyChecking=no vrubel@192.168.8.171 "cd ~/services/sendMessageToStreamer && docker pull 192.168.8.174:3000/vrubel/send2streamer:latest && docker compose up -d --remove-orphans"
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
.env
|
.env
|
||||||
|
/data/
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
COPY server.js ./
|
COPY server.js db.js ./
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
|
||||||
59
db.js
Normal file
59
db.js
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
|
|
||||||
|
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'app.db');
|
||||||
|
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||||
|
|
||||||
|
const db = new DatabaseSync(DB_PATH);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS streamers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
google_id TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
picture TEXT,
|
||||||
|
overlay_token TEXT UNIQUE NOT NULL,
|
||||||
|
sender_token TEXT UNIQUE NOT NULL,
|
||||||
|
tts_voice TEXT NOT NULL DEFAULT 'ru-RU-DmitryNeural',
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
function generateToken() {
|
||||||
|
return crypto.randomBytes(16).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function findStreamerByGoogleId(googleId) {
|
||||||
|
return db.prepare('SELECT * FROM streamers WHERE google_id = ?').get(googleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findStreamerByOverlayToken(token) {
|
||||||
|
return db.prepare('SELECT * FROM streamers WHERE overlay_token = ?').get(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findStreamerBySenderToken(token) {
|
||||||
|
return db.prepare('SELECT * FROM streamers WHERE sender_token = ?').get(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStreamer({ googleId, email, name, picture }) {
|
||||||
|
const overlayToken = generateToken();
|
||||||
|
const senderToken = generateToken();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO streamers (google_id, email, name, picture, overlay_token, sender_token, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
).run(googleId, email, name, picture || null, overlayToken, senderToken, Date.now());
|
||||||
|
return findStreamerByGoogleId(googleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateStreamerByGoogle({ googleId, email, name, picture }) {
|
||||||
|
return findStreamerByGoogleId(googleId) || createStreamer({ googleId, email, name, picture });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
findStreamerByOverlayToken,
|
||||||
|
findStreamerBySenderToken,
|
||||||
|
getOrCreateStreamerByGoogle,
|
||||||
|
};
|
||||||
10
docker-compose.prod.yml
Normal file
10
docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: 192.168.8.174:3000/vrubel/send2streamer:latest
|
||||||
|
container_name: send2streamer
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
|
image: 192.168.8.174:3000/vrubel/send2streamer:latest
|
||||||
|
container_name: send2streamer
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
env_file: .env
|
||||||
- PORT=3000
|
volumes:
|
||||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
- ./data:/app/data
|
||||||
- SESSION_SECRET=${SESSION_SECRET}
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
||||||
115
public/dashboard.html
Normal file
115
public/dashboard.html
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Ваши ссылки — Send2Streamer</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 60px auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
h1 { font-size: 28px; }
|
||||||
|
.card {
|
||||||
|
background: #f6f6f6;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.card h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
.card p {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.url-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.url-row input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.url-row button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#logout {
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>Ваши ссылки</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Ссылка для OBS / StreamLabs</h2>
|
||||||
|
<p>Вставьте в свойства Browser Source (URL).</p>
|
||||||
|
<div class="url-row">
|
||||||
|
<input id="overlay-url" readonly />
|
||||||
|
<button data-copy="overlay-url">Копировать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Ссылка для зрителей</h2>
|
||||||
|
<p>Опубликуйте её — зрители авторизуются через Google и смогут слать вам сообщения на стрим.</p>
|
||||||
|
<div class="url-row">
|
||||||
|
<input id="sender-url" readonly />
|
||||||
|
<button data-copy="sender-url">Копировать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="logout">выйти</p>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function init() {
|
||||||
|
const meRes = await fetch('/me');
|
||||||
|
const { user } = await meRes.json();
|
||||||
|
if (!user) {
|
||||||
|
window.location.href = '/';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/dashboard');
|
||||||
|
if (!res.ok) {
|
||||||
|
window.location.href = '/';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('overlay-url').value = data.overlayUrl;
|
||||||
|
document.getElementById('sender-url').value = data.senderUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-copy]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const input = document.getElementById(btn.dataset.copy);
|
||||||
|
input.select();
|
||||||
|
navigator.clipboard.writeText(input.value);
|
||||||
|
btn.textContent = 'Скопировано';
|
||||||
|
setTimeout(() => { btn.textContent = 'Копировать'; }, 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('logout').addEventListener('click', async () => {
|
||||||
|
await fetch('/auth/logout', { method: 'POST' });
|
||||||
|
window.location.href = '/';
|
||||||
|
});
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
100
public/index.html
Normal file
100
public/index.html
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Send2Streamer — сообщения от зрителей прямо на стрим</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 60px auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: #666;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.steps {
|
||||||
|
background: #f6f6f6;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.steps li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
#login-container {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
#status {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>Send2Streamer</h1>
|
||||||
|
<p class="subtitle">Зрители пишут сообщения — они всплывают у вас на стриме голосом.</p>
|
||||||
|
|
||||||
|
<div class="steps">
|
||||||
|
<p><strong>Как это работает:</strong></p>
|
||||||
|
<ol>
|
||||||
|
<li>Вы входите через Google — сервис выдаёт вам две ссылки: для OBS/StreamLabs и для зрителей.</li>
|
||||||
|
<li>Первую вставляете в Browser Source в OBS/StreamLabs.</li>
|
||||||
|
<li>Вторую публикуете для зрителей (например, в описании канала или в чате).</li>
|
||||||
|
<li>Зритель авторизуется через Google по этой ссылке и пишет вам сообщение — оно появляется на стриме с текстом и озвучкой.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><strong>Войдите как стример, чтобы получить свои ссылки:</strong></p>
|
||||||
|
<div id="login-container"></div>
|
||||||
|
<div id="status"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function handleCredentialResponse(response) {
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const res = await fetch('/auth/google', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ credential: response.credential }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.href = '/dashboard.html';
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = 'Не удалось войти, попробуйте ещё раз';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const meRes = await fetch('/me');
|
||||||
|
const { user } = await meRes.json();
|
||||||
|
if (user) {
|
||||||
|
window.location.href = '/dashboard.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configRes = await fetch('/config');
|
||||||
|
const { googleClientId } = await configRes.json();
|
||||||
|
|
||||||
|
google.accounts.id.initialize({
|
||||||
|
client_id: googleClientId,
|
||||||
|
callback: handleCredentialResponse,
|
||||||
|
});
|
||||||
|
google.accounts.id.renderButton(document.getElementById('login-container'), { theme: 'outline', size: 'large' });
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
const box = document.getElementById('box');
|
const box = document.getElementById('box');
|
||||||
const fromEl = document.getElementById('from');
|
const fromEl = document.getElementById('from');
|
||||||
const textEl = document.getElementById('text');
|
const textEl = document.getElementById('text');
|
||||||
const socket = io();
|
|
||||||
|
const token = location.pathname.split('/').filter(Boolean).pop();
|
||||||
|
const socket = io({ query: { role: 'overlay', token } });
|
||||||
|
|
||||||
let hideTimer = null;
|
let hideTimer = null;
|
||||||
let audioCtx = null;
|
let audioCtx = null;
|
||||||
|
|
@ -70,3 +72,7 @@ socket.on('display_message', (payload) => {
|
||||||
socket.on('display_audio', (payload) => {
|
socket.on('display_audio', (payload) => {
|
||||||
playSpeech(payload.audio, payload.mimeType);
|
playSpeech(payload.audio, payload.mimeType);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on('invalid_link', () => {
|
||||||
|
console.error('Invalid overlay link — check the URL from your dashboard.');
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>Отправить сообщение стримеру</title>
|
<title>Отправить сообщение на стрим</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -12,6 +12,15 @@
|
||||||
margin: 60px auto;
|
margin: 60px auto;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
}
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
#user-bar {
|
#user-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -56,6 +65,9 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<h1>Сообщение на стрим</h1>
|
||||||
|
<p class="subtitle">Войдите через Google и напишите сообщение — оно появится на стриме с озвучкой.</p>
|
||||||
|
|
||||||
<div id="login-container"></div>
|
<div id="login-container"></div>
|
||||||
|
|
||||||
<div id="hidden-until-login">
|
<div id="hidden-until-login">
|
||||||
|
|
@ -65,13 +77,13 @@
|
||||||
<span id="logout">выйти</span>
|
<span id="logout">выйти</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea id="message" placeholder="Текст для оверлея..." maxlength="500"></textarea>
|
<textarea id="message" placeholder="Текст для стрима..." maxlength="500"></textarea>
|
||||||
<br />
|
<br />
|
||||||
<button id="send">Отправить</button>
|
<button id="send">Отправить</button>
|
||||||
<div id="status"></div>
|
<div id="status"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
<script src="/sender.js"></script>
|
<script src="/send.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -7,7 +7,8 @@ const messageInput = document.getElementById('message');
|
||||||
const sendBtn = document.getElementById('send');
|
const sendBtn = document.getElementById('send');
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
|
|
||||||
const socket = io();
|
const token = location.pathname.split('/').filter(Boolean).pop();
|
||||||
|
const socket = io({ query: { role: 'sender', token } });
|
||||||
|
|
||||||
function showLoggedIn(user) {
|
function showLoggedIn(user) {
|
||||||
loginContainer.style.display = 'none';
|
loginContainer.style.display = 'none';
|
||||||
|
|
@ -62,7 +63,7 @@ async function init() {
|
||||||
sendBtn.addEventListener('click', () => {
|
sendBtn.addEventListener('click', () => {
|
||||||
const text = messageInput.value.trim();
|
const text = messageInput.value.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
socket.emit('send_message', text);
|
socket.emit('send_message', { token, text });
|
||||||
messageInput.value = '';
|
messageInput.value = '';
|
||||||
statusEl.textContent = 'Отправлено';
|
statusEl.textContent = 'Отправлено';
|
||||||
setTimeout(() => { statusEl.textContent = ''; }, 1500);
|
setTimeout(() => { statusEl.textContent = ''; }, 1500);
|
||||||
85
server.js
85
server.js
|
|
@ -7,11 +7,12 @@ const { createServer } = require('http');
|
||||||
const { Server } = require('socket.io');
|
const { Server } = require('socket.io');
|
||||||
const { OAuth2Client } = require('google-auth-library');
|
const { OAuth2Client } = require('google-auth-library');
|
||||||
const { execFile } = require('child_process');
|
const { execFile } = require('child_process');
|
||||||
|
const { findStreamerByOverlayToken, findStreamerBySenderToken, getOrCreateStreamerByGoogle } = require('./db');
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
|
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
|
||||||
const SESSION_SECRET = process.env.SESSION_SECRET;
|
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) {
|
if (!GOOGLE_CLIENT_ID) {
|
||||||
console.error('Missing GOOGLE_CLIENT_ID in .env (see .env.example)');
|
console.error('Missing GOOGLE_CLIENT_ID in .env (see .env.example)');
|
||||||
|
|
@ -42,7 +43,6 @@ const sessionMiddleware = session({
|
||||||
|
|
||||||
app.use(sessionMiddleware);
|
app.use(sessionMiddleware);
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
|
||||||
|
|
||||||
// Share the express session with socket.io connections.
|
// Share the express session with socket.io connections.
|
||||||
const wrap = (middleware) => (socket, next) => middleware(socket.request, {}, next);
|
const wrap = (middleware) => (socket, next) => middleware(socket.request, {}, next);
|
||||||
|
|
@ -70,6 +70,7 @@ app.post('/auth/google', async (req, res) => {
|
||||||
const payload = ticket.getPayload();
|
const payload = ticket.getPayload();
|
||||||
|
|
||||||
req.session.user = {
|
req.session.user = {
|
||||||
|
id: payload.sub,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
picture: payload.picture,
|
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)
|
// 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
|
// instead of the edge-tts-node npm package, whose request-signing algorithm
|
||||||
// currently gets rejected by Microsoft's servers with a 403.
|
// currently gets rejected by Microsoft's servers with a 403.
|
||||||
function synthesizeSpeech(text) {
|
function synthesizeSpeech(text, voice) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
execFile(
|
execFile(
|
||||||
'edge-tts',
|
'edge-tts',
|
||||||
['-t', text, '-v', TTS_VOICE],
|
['-t', text, '-v', voice],
|
||||||
{ encoding: 'buffer', maxBuffer: 10 * 1024 * 1024 },
|
{ encoding: 'buffer', maxBuffer: 10 * 1024 * 1024 },
|
||||||
(err, stdout) => {
|
(err, stdout) => {
|
||||||
if (err) return reject(err);
|
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) => {
|
io.on('connection', (socket) => {
|
||||||
|
const { role, token } = socket.handshake.query;
|
||||||
const connectedUser = socket.request.session.user;
|
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) => {
|
socket.on('disconnect', (reason) => {
|
||||||
console.log(`[socket] disconnected id=${socket.id} reason=${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.
|
// The session snapshot on socket.request is captured once, at connect time.
|
||||||
// If the socket connected before login (page load opens it immediately),
|
// If the socket connected before login (page load opens it immediately),
|
||||||
// it never sees a session updated later by the separate /auth/google request
|
// it never sees a session updated later by the separate /auth/google request
|
||||||
// unless we explicitly reload it from the store here.
|
// unless we explicitly reload it from the store here.
|
||||||
socket.request.session.reload((err) => {
|
socket.request.session.reload((err) => {
|
||||||
const user = !err && socket.request.session.user;
|
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) {
|
if (!user) {
|
||||||
socket.emit('send_error', 'Not authenticated');
|
socket.emit('send_error', 'Not authenticated');
|
||||||
|
|
@ -130,9 +183,9 @@ io.on('connection', (socket) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = text.trim().slice(0, 500);
|
const message = text.trim().slice(0, 500);
|
||||||
const clientCount = io.engine.clientsCount;
|
const room = `streamer-${streamer.id}`;
|
||||||
console.log(`[broadcast] "${message}" -> ${clientCount} connected client(s)`);
|
console.log(`[broadcast] streamer=${streamer.id} "${message}"`);
|
||||||
io.emit('display_message', {
|
io.to(room).emit('display_message', {
|
||||||
text: message,
|
text: message,
|
||||||
from: user.name,
|
from: user.name,
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
|
|
@ -140,9 +193,9 @@ io.on('connection', (socket) => {
|
||||||
|
|
||||||
// Voice is generated after the fact so the text shows up immediately
|
// Voice is generated after the fact so the text shows up immediately
|
||||||
// instead of waiting on the round trip to the TTS service.
|
// instead of waiting on the round trip to the TTS service.
|
||||||
synthesizeSpeech(message)
|
synthesizeSpeech(message, streamer.tts_voice || DEFAULT_TTS_VOICE)
|
||||||
.then((audioBuffer) => {
|
.then((audioBuffer) => {
|
||||||
io.emit('display_audio', {
|
io.to(room).emit('display_audio', {
|
||||||
audio: audioBuffer.toString('base64'),
|
audio: audioBuffer.toString('base64'),
|
||||||
mimeType: 'audio/mpeg',
|
mimeType: 'audio/mpeg',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue