залил

This commit is contained in:
2026-04-15 08:00:15 +03:00
commit 5549b3545e
51 changed files with 8073 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
{{define "content"}}
<h2>Настройки нейросетей</h2>
<div class="card">
<form id="ai-form">
<label>Провайдер:</label>
<select id="provider" name="provider">
<option value="ollama">Ollama (локальная)</option>
<option value="chatgpt">ChatGPT (OpenAI)</option>
<option value="gigachat">GigaChat (Сбер)</option>
</select>
<div id="ollama-fields" class="provider-fields">
<label>Endpoint:</label>
<input type="text" id="endpoint" name="endpoint" placeholder="http://localhost:11434">
<label>Модель:</label>
<input type="text" id="model" name="model" placeholder="llama2, mistral, ...">
</div>
<div id="chatgpt-fields" class="provider-fields" style="display:none;">
<label>API Key:</label>
<input type="password" id="api_key" name="api_key" placeholder="sk-...">
<label>Модель:</label>
<input type="text" id="model_gpt" name="model_gpt" placeholder="gpt-3.5-turbo">
</div>
<div id="gigachat-fields" class="provider-fields" style="display:none;">
<label>Client ID:</label>
<input type="text" id="client_id" name="client_id" placeholder="ваш client_id">
<label>Client Secret:</label>
<input type="password" id="client_secret" name="client_secret" placeholder="ваш client_secret">
<label>Endpoint (опционально):</label>
<input type="text" id="endpoint_giga" name="endpoint_giga" placeholder="https://gigachat.devices.sberbank.ru/api/v1">
<label>Модель (опционально):</label>
<input type="text" id="model_giga" name="model_giga" placeholder="GigaChat">
</div>
<label>Системный промпт (префикс):</label>
<textarea id="system_prompt" name="system_prompt" rows="3">ты в чате твитча, ответь одним предложением.</textarea>
<button type="submit">Сохранить</button>
</form>
</div>
<div class="card">
<h3>Тестирование</h3>
<input type="text" id="test-prompt" placeholder="Введите вопрос для нейросети" style="width: 70%;">
<button id="test-btn">Отправить</button>
<div id="test-result" class="test-result-block"></div>
</div>
<script>
const providerSelect = document.getElementById('provider');
const ollamaFields = document.getElementById('ollama-fields');
const chatgptFields = document.getElementById('chatgpt-fields');
const gigachatFields = document.getElementById('gigachat-fields');
function showProviderFields() {
const prov = providerSelect.value;
ollamaFields.style.display = 'none';
chatgptFields.style.display = 'none';
gigachatFields.style.display = 'none';
if (prov === 'ollama') ollamaFields.style.display = 'block';
else if (prov === 'chatgpt') chatgptFields.style.display = 'block';
else if (prov === 'gigachat') gigachatFields.style.display = 'block';
}
providerSelect.addEventListener('change', showProviderFields);
async function loadConfig() {
const res = await fetch('/api/ai/config');
const cfg = await res.json();
providerSelect.value = cfg.provider || 'ollama';
document.getElementById('endpoint').value = cfg.endpoint || '';
document.getElementById('model').value = cfg.model || '';
document.getElementById('api_key').value = cfg.api_key || '';
document.getElementById('model_gpt').value = cfg.model || '';
document.getElementById('client_id').value = cfg.client_id || '';
document.getElementById('client_secret').value = cfg.client_secret || '';
document.getElementById('endpoint_giga').value = cfg.endpoint || '';
document.getElementById('model_giga').value = cfg.model || '';
document.getElementById('system_prompt').value = cfg.system_prompt || 'ты в чате твитча, ответь одним предложением.';
showProviderFields();
}
document.getElementById('ai-form').addEventListener('submit', async (e) => {
e.preventDefault();
const provider = providerSelect.value;
let apiKey = '', endpoint = '', model = '', clientId = '', clientSecret = '';
if (provider === 'ollama') {
endpoint = document.getElementById('endpoint').value;
model = document.getElementById('model').value;
} else if (provider === 'chatgpt') {
apiKey = document.getElementById('api_key').value;
model = document.getElementById('model_gpt').value;
} else if (provider === 'gigachat') {
clientId = document.getElementById('client_id').value;
clientSecret = document.getElementById('client_secret').value;
endpoint = document.getElementById('endpoint_giga').value;
model = document.getElementById('model_giga').value;
}
const systemPrompt = document.getElementById('system_prompt').value;
const body = {
provider,
api_key: apiKey,
endpoint,
model,
system_prompt: systemPrompt,
client_id: clientId,
client_secret: clientSecret
};
try {
const res = await fetch('/api/ai/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (res.ok) {
alert('Настройки сохранены');
await loadConfig();
} else {
const err = await res.text();
alert('Ошибка сохранения: ' + err);
}
} catch (err) {
alert('Ошибка соединения: ' + err.message);
}
});
document.getElementById('test-btn').addEventListener('click', async () => {
const prompt = document.getElementById('test-prompt').value;
if (!prompt) return;
try {
const res = await fetch('/api/ai/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt })
});
if (res.ok) {
const data = await res.json();
document.getElementById('test-result').innerHTML = `<strong>Ответ:</strong> ${escapeHtml(data.answer)}`;
} else {
const err = await res.text();
document.getElementById('test-result').innerHTML = `<span style="color:red;">Ошибка: ${escapeHtml(err)}</span>`;
}
} catch (err) {
document.getElementById('test-result').innerHTML = `<span style="color:red;">Ошибка соединения: ${err.message}</span>`;
}
});
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
loadConfig();
</script>
{{end}}
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<title>TTW_Bot</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<nav>
<div>
<a href="/">Главная</a>
<a href="/platforms">Платформы</a>
<a href="/commands">Команды</a>
<a href="/events">События</a>
<a href="/hotkeys">Горячие клавиши</a>
<a href="/webservices">Веб-сервисы</a>
<a href="/logs">Логи</a>
<a href="/ai">Нейросети</a>
<a href="/notifications">Уведомления</a>
</div>
<div class="theme-switch" id="theme-toggle">🌙 Тёмная тема</div>
</nav>
<div class="container">
{{block "content" .}}{{end}}
</div>
<footer style="text-align: center; margin-top: 20px; font-size: 0.8em; color: gray;">
TTW_Bot версия 11.0.43
</footer>
<script>
const themeToggle = document.getElementById('theme-toggle');
const currentTheme = localStorage.getItem('theme');
if (currentTheme === 'dark') {
document.body.classList.add('dark');
themeToggle.textContent = '☀️ Светлая тема';
}
themeToggle.addEventListener('click', () => {
document.body.classList.toggle('dark');
const isDark = document.body.classList.contains('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
themeToggle.textContent = isDark ? '☀️ Светлая тема' : '🌙 Тёмная тема';
});
</script>
</body>
</html>
+434
View File
@@ -0,0 +1,434 @@
{{define "content"}}
<h2>Команды чата</h2>
<div class="card">
<h3>Добавить / редактировать команду</h3>
<form id="command-form">
<input type="hidden" id="command-id" value="0">
<label>Триггер (без !):</label>
<input type="text" id="trigger" required placeholder="ping">
<label>Шаблон ответа:</label>
<textarea id="template" rows="5" required placeholder="Pong! &lt;USERNAME/&gt;"></textarea>
<!-- Панель кнопок для вставки тегов -->
<div style="margin: 8px 0; display: flex; flex-wrap: wrap; gap: 6px;">
<button type="button" class="tag-btn" data-tag="&lt;USERNAME/&gt;">👤 USERNAME</button>
<button type="button" class="tag-btn" data-tag="&lt;ARG/&gt;">📝 ARG</button>
<button type="button" class="tag-btn" data-tag="&lt;AI/&gt;">🤖 AI</button>
<button type="button" class="tag-btn" data-tag="&lt;RANDOMUSER/&gt;">🎲 RANDOMUSER</button>
<button type="button" id="random-btn">🔢 Случайное число</button>
<button type="button" id="song-btn">🎵 Вставить звук</button>
<button type="button" id="group-btn">📦 Обертка group</button>
<button type="button" id="timeout-btn">⏱ Отстранение</button>
</div>
<div>
<button type="button" id="test-btn">🧪 Тестировать</button>
</div>
<label>
<input type="checkbox" id="enabled" checked> Включена
</label>
<label>Кулдаун (секунды):</label>
<input type="number" id="cooldown" value="0" min="0">
<label>Права доступа:</label>
<select id="permission">
<option value="everyone">Все</option>
<option value="moderator">Модераторы</option>
<option value="subscriber">Подписчики</option>
<option value="vip">VIP</option>
<option value="broadcaster">Стример</option>
</select>
<button type="submit">Сохранить</button>
<button type="button" id="cancel-edit" style="background: #6c757d;">Отмена</button>
</form>
</div>
<div class="card">
<h3>Список команд</h3>
<table style="width:100%; border-collapse: collapse;">
<thead>
<tr><th>Триггер</th><th>Шаблон</th><th>Кулдаун</th><th>Права</th><th>Статус</th><th>Действия</th></tr>
</thead>
<tbody id="commands-table">
<tr><td colspan="6">Загрузка...</td></tr>
</tbody>
</table>
</div>
<!-- Модальное окно для тестирования -->
<div id="test-modal" class="modal">
<div class="modal-content">
<h3>Результат тестирования</h3>
<p><strong>Текст для чата:</strong></p>
<pre id="test-result" style="background:#f0f0f0; padding:10px; border-radius:4px; white-space:pre-wrap;"></pre>
<p><strong>Звуковые файлы:</strong> <span id="test-sounds"></span></p>
<button id="close-modal" style="margin-top:10px;">Закрыть</button>
</div>
</div>
<!-- Модальное окно для случайного числа -->
<div id="random-modal" class="modal">
<div class="modal-content">
<h3>Вставить случайное число</h3>
<label>Минимум:</label>
<input type="number" id="random-min" value="1">
<label>Максимум:</label>
<input type="number" id="random-max" value="100">
<div style="margin-top:15px;">
<button id="insert-random-btn">Вставить</button>
<button id="cancel-random-btn">Отмена</button>
</div>
</div>
</div>
<!-- Модальное окно для звука -->
<div id="song-modal" class="modal">
<div class="modal-content">
<h3>Вставить звук</h3>
<label>Выберите звуковой файл:</label>
<select id="song-select" style="width:100%;">
<option value="">-- нет звуков --</option>
</select>
<div style="margin-top:15px;">
<button id="insert-song-btn">Вставить</button>
<button id="cancel-song-btn">Отмена</button>
</div>
</div>
</div>
<!-- Модальное окно для группы -->
<div id="group-modal" class="modal">
<div class="modal-content">
<h3>Создать группу вариантов</h3>
<div id="group-variants">
<div class="group-variant" style="margin-bottom:8px;">
<input type="text" class="variant-input" placeholder="Вариант 1" style="width:80%;">
</div>
<div class="group-variant" style="margin-bottom:8px;">
<input type="text" class="variant-input" placeholder="Вариант 2" style="width:80%;">
</div>
</div>
<button type="button" id="add-variant-btn">+ Добавить вариант</button>
<button type="button" id="remove-variant-btn" style="margin-left:10px;"> Удалить последний</button>
<div style="margin-top:15px;">
<button id="insert-group-btn">Вставить</button>
<button id="cancel-group-btn">Отмена</button>
</div>
</div>
</div>
<!-- Модальное окно для отстранения (timeout) -->
<div id="timeout-modal" class="modal">
<div class="modal-content">
<h3>Отстранение (таймаут)</h3>
<label>Количество минут:</label>
<input type="number" id="timeout-minutes" value="5" min="1" max="1440">
<div style="margin-top:15px;">
<button id="insert-timeout-btn">Вставить</button>
<button id="cancel-timeout-btn">Отмена</button>
</div>
</div>
</div>
<script>
let commands = [];
let soundList = [];
async function loadSoundList() {
try {
const res = await fetch('/api/sounds/list');
if (res.ok) {
soundList = await res.json();
const select = document.getElementById('song-select');
select.innerHTML = '<option value="">-- выберите звук --</option>';
soundList.forEach(sound => {
const option = document.createElement('option');
option.value = sound;
option.textContent = sound;
select.appendChild(option);
});
}
} catch(e) { console.error('Failed to load sounds', e); }
}
function insertAtCursor(textareaId, text) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = textarea.value;
textarea.value = value.slice(0, start) + text + value.slice(end);
textarea.selectionStart = textarea.selectionEnd = start + text.length;
textarea.focus();
}
// Простые теги
document.querySelectorAll('.tag-btn').forEach(btn => {
btn.addEventListener('click', () => {
insertAtCursor('template', btn.dataset.tag);
});
});
// Random
document.getElementById('random-btn').addEventListener('click', () => {
document.getElementById('random-modal').style.display = 'flex';
});
document.getElementById('insert-random-btn').addEventListener('click', () => {
const min = document.getElementById('random-min').value;
const max = document.getElementById('random-max').value;
const tag = `<random s=${min} e=${max}/>`;
insertAtCursor('template', tag);
document.getElementById('random-modal').style.display = 'none';
});
document.getElementById('cancel-random-btn').addEventListener('click', () => {
document.getElementById('random-modal').style.display = 'none';
});
// Song
document.getElementById('song-btn').addEventListener('click', async () => {
await loadSoundList();
document.getElementById('song-modal').style.display = 'flex';
});
document.getElementById('insert-song-btn').addEventListener('click', () => {
const selected = document.getElementById('song-select').value;
if (!selected) {
alert('Выберите звуковой файл');
return;
}
const tag = `<song f="data/sounds/${selected}"/>`;
insertAtCursor('template', tag);
document.getElementById('song-modal').style.display = 'none';
});
document.getElementById('cancel-song-btn').addEventListener('click', () => {
document.getElementById('song-modal').style.display = 'none';
});
// Group
document.getElementById('group-btn').addEventListener('click', () => {
const container = document.getElementById('group-variants');
container.innerHTML = `
<div class="group-variant" style="margin-bottom:8px;">
<input type="text" class="variant-input" placeholder="Вариант 1" style="width:80%;">
</div>
<div class="group-variant" style="margin-bottom:8px;">
<input type="text" class="variant-input" placeholder="Вариант 2" style="width:80%;">
</div>
`;
document.getElementById('group-modal').style.display = 'flex';
});
document.getElementById('add-variant-btn').addEventListener('click', () => {
const container = document.getElementById('group-variants');
const newDiv = document.createElement('div');
newDiv.className = 'group-variant';
newDiv.style.marginBottom = '8px';
newDiv.innerHTML = `<input type="text" class="variant-input" placeholder="Новый вариант" style="width:80%;">`;
container.appendChild(newDiv);
});
document.getElementById('remove-variant-btn').addEventListener('click', () => {
const container = document.getElementById('group-variants');
if (container.children.length > 2) {
container.removeChild(container.lastChild);
} else {
alert('Должно быть хотя бы два варианта');
}
});
document.getElementById('insert-group-btn').addEventListener('click', () => {
const inputs = document.querySelectorAll('#group-variants .variant-input');
let variants = [];
inputs.forEach(inp => {
let val = inp.value.trim();
if (val !== '') variants.push(val);
});
if (variants.length < 2) {
alert('Введите хотя бы два непустых варианта');
return;
}
let groupHtml = '<group>\n';
variants.forEach(v => {
groupHtml += ` <g>${escapeHtmlForGroup(v)}</g>\n`;
});
groupHtml += '</group>';
insertAtCursor('template', groupHtml);
document.getElementById('group-modal').style.display = 'none';
});
document.getElementById('cancel-group-btn').addEventListener('click', () => {
document.getElementById('group-modal').style.display = 'none';
});
// Timeout
document.getElementById('timeout-btn').addEventListener('click', () => {
document.getElementById('timeout-modal').style.display = 'flex';
});
document.getElementById('insert-timeout-btn').addEventListener('click', () => {
const minutes = document.getElementById('timeout-minutes').value;
const tag = `<timeout minutes="${minutes}"/>`;
insertAtCursor('template', tag);
document.getElementById('timeout-modal').style.display = 'none';
});
document.getElementById('cancel-timeout-btn').addEventListener('click', () => {
document.getElementById('timeout-modal').style.display = 'none';
});
function escapeHtmlForGroup(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
// Закрытие модалок по клику вне области
window.addEventListener('click', (e) => {
const modals = ['random-modal', 'song-modal', 'group-modal', 'test-modal', 'timeout-modal'];
modals.forEach(id => {
const modal = document.getElementById(id);
if (e.target === modal) modal.style.display = 'none';
});
});
// --- Остальной код команд (без изменений) ---
async function loadCommands() {
const res = await fetch('/api/commands');
commands = await res.json();
renderTable();
}
function renderTable() {
const tbody = document.getElementById('commands-table');
if (!commands.length) {
tbody.innerHTML = '<tr><td colspan="6">Нет команд. Добавьте первую!</td></tr>';
return;
}
tbody.innerHTML = commands.map(cmd => `
<tr>
<td>!${escapeHtml(cmd.Trigger)}</td>
<td style="max-width: 300px; overflow-x: auto;">${escapeHtml(cmd.Template)}</td>
<td>${cmd.CooldownSec}</td>
<td>${cmd.Permission}</td>
<td>${cmd.Enabled ? '✅ Вкл' : '❌ Выкл'}</td>
<td>
<button onclick="editCommand(${cmd.ID})">✏️</button>
<button onclick="deleteCommand(${cmd.ID})">🗑️</button>
</td>
</tr>
`).join('');
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
function editCommand(id) {
const cmd = commands.find(c => c.ID === id);
if (!cmd) return;
document.getElementById('command-id').value = cmd.ID;
document.getElementById('trigger').value = cmd.Trigger;
document.getElementById('template').value = cmd.Template;
document.getElementById('enabled').checked = cmd.Enabled;
document.getElementById('cooldown').value = cmd.CooldownSec;
document.getElementById('permission').value = cmd.Permission;
document.querySelector('.card').scrollIntoView({ behavior: 'smooth' });
}
async function deleteCommand(id) {
if (!confirm('Удалить команду?')) return;
await fetch(`/api/commands?id=${id}`, { method: 'DELETE' });
loadCommands();
if (document.getElementById('command-id').value == id) resetForm();
}
function resetForm() {
document.getElementById('command-id').value = 0;
document.getElementById('trigger').value = '';
document.getElementById('template').value = '';
document.getElementById('enabled').checked = true;
document.getElementById('cooldown').value = 0;
document.getElementById('permission').value = 'everyone';
}
document.getElementById('cancel-edit').addEventListener('click', resetForm);
document.getElementById('command-form').addEventListener('submit', async (e) => {
e.preventDefault();
const id = parseInt(document.getElementById('command-id').value);
const trigger = document.getElementById('trigger').value.trim();
const template = document.getElementById('template').value;
const enabled = document.getElementById('enabled').checked;
const cooldown = parseInt(document.getElementById('cooldown').value);
const permission = document.getElementById('permission').value;
if (!trigger || !template) {
alert('Заполните триггер и шаблон');
return;
}
const method = id === 0 ? 'POST' : 'PUT';
const url = '/api/commands';
const body = JSON.stringify({ ID: id, Trigger: trigger, Template: template, Enabled: enabled, CooldownSec: cooldown, Permission: permission });
try {
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body });
if (res.ok) {
loadCommands();
resetForm();
} else {
const err = await res.text();
alert('Ошибка: ' + err);
}
} catch (err) {
alert('Ошибка соединения: ' + err.message);
}
});
document.getElementById('test-btn').addEventListener('click', async () => {
const template = document.getElementById('template').value;
if (!template) {
alert('Введите шаблон для тестирования');
return;
}
const username = prompt('Введите имя пользователя для подстановки (без @):', 'TestUser');
if (!username) return;
try {
const res = await fetch('/api/commands/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template, username })
});
if (!res.ok) {
const err = await res.text();
alert('Ошибка тестирования: ' + err);
return;
}
const data = await res.json();
document.getElementById('test-result').textContent = data.result;
const soundsSpan = document.getElementById('test-sounds');
if (data.soundFiles && data.soundFiles.length) {
soundsSpan.innerHTML = data.soundFiles.join('<br>');
} else {
soundsSpan.textContent = 'нет';
}
document.getElementById('test-modal').style.display = 'flex';
} catch (err) {
alert('Ошибка: ' + err.message);
}
});
document.getElementById('close-modal').addEventListener('click', () => {
document.getElementById('test-modal').style.display = 'none';
});
loadCommands();
loadSoundList();
</script>
{{end}}
+142
View File
@@ -0,0 +1,142 @@
{{define "content"}}
<h2>Пользователи чата</h2>
<div style="overflow-x: auto;">
<table id="users-table">
<thead>
<tr>
<th>Пользователь</th>
<th>Сообщений</th>
<th>Последняя активность</th>
<th>Модератор</th>
<th>VIP</th>
<th>Подписчик</th>
<th>Действия</th>
<th>Отмечать</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<script>
let users = [];
let refreshInterval = null;
async function loadUsers() {
try {
const res = await fetch('/api/users');
users = await res.json();
renderTable();
} catch(err) {
console.error('Failed to load users', err);
}
}
function renderTable() {
const tbody = document.querySelector('#users-table tbody');
if (!users.length) {
tbody.innerHTML = '<tr><td colspan="8">Нет активных пользователей</td></tr>';
return;
}
tbody.innerHTML = users.map(user => `
<tr data-username="${escapeHtml(user.username)}">
<td>${escapeHtml(user.username)}</td>
<td>${user.message_count}</td>
<td>${formatRelativeTime(user.last_active)}</td>
<td><input type="checkbox" class="mod-checkbox" ${user.is_mod ? 'checked' : ''} data-username="${escapeHtml(user.username)}"></td>
<td><input type="checkbox" class="vip-checkbox" ${user.is_vip ? 'checked' : ''} data-username="${escapeHtml(user.username)}"></td>
<td><input type="checkbox" disabled ${user.is_subscriber ? 'checked' : ''}></td>
<td>
<button class="warn-btn" data-user="${escapeHtml(user.username)}">Предупредить</button>
<button class="timeout10s-btn" data-user="${escapeHtml(user.username)}">10 сек</button>
<button class="timeout10m-btn" data-user="${escapeHtml(user.username)}">10 мин</button>
<button class="ban-btn" data-user="${escapeHtml(user.username)}">Забанить</button>
<button class="unban-btn" data-user="${escapeHtml(user.username)}">Разбанить</button>
</td>
<td><input type="checkbox" class="mark-checkbox" ${user.is_marked ? 'checked' : ''} data-username="${escapeHtml(user.username)}"></td>
</tr>
`).join('');
document.querySelectorAll('.mod-checkbox').forEach(cb => {
cb.addEventListener('change', (e) => toggleMod(cb.dataset.username, cb.checked));
});
document.querySelectorAll('.vip-checkbox').forEach(cb => {
cb.addEventListener('change', (e) => toggleVip(cb.dataset.username, cb.checked));
});
document.querySelectorAll('.warn-btn').forEach(btn => {
btn.addEventListener('click', () => userAction(btn.dataset.user, 'warn'));
});
document.querySelectorAll('.timeout10s-btn').forEach(btn => {
btn.addEventListener('click', () => userAction(btn.dataset.user, 'timeout10sec'));
});
document.querySelectorAll('.timeout10m-btn').forEach(btn => {
btn.addEventListener('click', () => userAction(btn.dataset.user, 'timeout10min'));
});
document.querySelectorAll('.ban-btn').forEach(btn => {
btn.addEventListener('click', () => userAction(btn.dataset.user, 'ban'));
});
document.querySelectorAll('.unban-btn').forEach(btn => {
btn.addEventListener('click', () => userAction(btn.dataset.user, 'unban'));
});
document.querySelectorAll('.mark-checkbox').forEach(cb => {
cb.addEventListener('change', (e) => toggleMark(cb.dataset.username, cb.checked));
});
}
async function toggleMod(username, isMod) {
const action = isMod ? 'set_mod' : 'unset_mod';
await userAction(username, action);
}
async function toggleVip(username, isVip) {
const action = isVip ? 'set_vip' : 'unset_vip';
await userAction(username, action);
}
async function toggleMark(username, isMarked) {
await userAction(username, 'toggle_mark');
}
async function userAction(username, action) {
try {
const res = await fetch('/api/users/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ username, action, platform: 'twitch' })
});
if (!res.ok) throw new Error(await res.text());
await loadUsers();
} catch(err) {
alert('Ошибка: ' + err.message);
}
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
function formatRelativeTime(timestamp) {
if (!timestamp) return 'никогда';
const date = new Date(timestamp);
const now = new Date();
const seconds = Math.floor((now - date) / 1000);
if (seconds < 60) return 'только что';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} мин. назад`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} ч. назад`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days} дн. назад`;
const months = Math.floor(days / 30);
if (months < 12) return `${months} мес. назад`;
const years = Math.floor(months / 12);
return `${years} г. назад`;
}
loadUsers();
refreshInterval = setInterval(loadUsers, 3000);
</script>
{{end}}
+296
View File
@@ -0,0 +1,296 @@
{{define "content"}}
<h2>События Twitch</h2>
<p>Настройте цепочку действий, которые будут выполняться при наступлении события (подписка, рейд, награда и т.д.)</p>
<div class="card">
<label for="event-select">Выберите событие:</label>
<select id="event-select">
<option value="follow">Подписка на канал (follow)</option>
<option value="subscribe">Подписка (subscribe)</option>
<option value="gift_sub">Подарочная подписка (gift_sub)</option>
<option value="raid">Рейд (raid)</option>
<option value="reward_redemption">Награда за баллы (reward_redemption)</option>
</select>
<button id="load-actions">Загрузить действия</button>
</div>
<div class="card" id="actions-editor" style="display: none;">
<h3>Цепочка действий</h3>
<div id="actions-list"></div>
<button id="add-action"> Добавить действие</button>
<button id="save-actions">💾 Сохранить</button>
</div>
<!-- Модальное окно для создания/редактирования действия -->
<div id="action-modal" class="modal">
<div class="modal-content">
<h3 id="modal-title">Действие</h3>
<form id="action-form">
<input type="hidden" id="action-index" value="-1">
<label>Тип действия:</label>
<select id="action-type">
<option value="send_message">Отправить сообщение</option>
<option value="play_sound">Воспроизвести звук</option>
<option value="press_hotkey">Нажать горячую клавишу</option>
<option value="http_request">HTTP запрос (GET)</option>
<option value="run_program">Запустить программу</option>
<option value="send_alert">Отправить уведомление (alert)</option>
</select>
<div id="fields-container"></div>
<div style="margin-top: 15px;">
<button type="submit">Сохранить</button>
<button type="button" id="close-action-modal">Отмена</button>
</div>
</form>
</div>
</div>
<script>
let currentPlatform = "twitch";
let currentEvent = "";
let actions = [];
let editingIndex = -1;
// Списки для выпадающих полей
let soundFiles = [];
let imageFiles = [];
const eventSelect = document.getElementById('event-select');
const loadBtn = document.getElementById('load-actions');
const actionsDiv = document.getElementById('actions-list');
const editorDiv = document.getElementById('actions-editor');
const addBtn = document.getElementById('add-action');
const saveBtn = document.getElementById('save-actions');
const actionModal = document.getElementById('action-modal');
const actionTypeSelect = document.getElementById('action-type');
const fieldsContainer = document.getElementById('fields-container');
const actionForm = document.getElementById('action-form');
const actionIndexInput = document.getElementById('action-index');
const closeModalBtn = document.getElementById('close-action-modal');
// Загрузка списков звуков и изображений
async function loadSoundList() {
const res = await fetch('/api/sounds/list');
if (res.ok) {
soundFiles = await res.json();
} else {
soundFiles = [];
}
}
async function loadImageList() {
const res = await fetch('/api/images/list');
if (res.ok) {
imageFiles = await res.json();
} else {
imageFiles = [];
}
}
// Функция отображения полей в зависимости от типа
function renderFieldsForType(type, data = {}) {
let html = '';
switch (type) {
case 'send_message':
html = `<label>Текст сообщения:</label><textarea id="action-text" rows="3" style="width:100%;">${escapeHtml(data.text || '')}</textarea>
<small>Доступны плейсхолдеры: {{.username}}, {{.reward_title}}, {{.tier}}, {{.viewers}} и др.</small>`;
break;
case 'play_sound':
html = `<label>Звуковой файл:</label>
<select id="action-sound-file" style="width:100%;">
<option value="">— без звука —</option>
${soundFiles.map(f => `<option value="data/sounds/${f}" ${data.sound_file === `data/sounds/${f}` ? 'selected' : ''}>${f}</option>`).join('')}
</select>`;
break;
case 'press_hotkey':
html = `<label>Комбинация клавиш:</label><input type="text" id="action-keys" style="width:100%;" value="${escapeHtml(data.keys || '')}" placeholder="CTRL+ALT+Q">
<small>Поддерживаемые модификаторы: CTRL, ALT, SHIFT, WIN. Основные клавиши: A-Z, 0-9, F1-F12, SPACE, ENTER и др.</small>`;
break;
case 'http_request':
html = `<label>URL (GET):</label><input type="text" id="action-url" style="width:100%;" value="${escapeHtml(data.url || '')}">`;
break;
case 'run_program':
html = `<label>Полный путь к исполняемому файлу:</label><input type="text" id="action-executable" style="width:100%;" value="${escapeHtml(data.executable || '')}">
<label>Аргументы (через пробел):</label><input type="text" id="action-args" style="width:100%;" value="${escapeHtml(data.args || '')}">`;
break;
case 'send_alert':
html = `<label>Заголовок:</label><input type="text" id="action-title" style="width:100%;" value="${escapeHtml(data.title || '')}">
<label>Текст:</label><textarea id="action-alert-text" rows="2" style="width:100%;">${escapeHtml(data.alert_text || '')}</textarea>
<label>Изображение:</label>
<select id="action-image" style="width:100%;">
<option value="">— без изображения —</option>
${imageFiles.map(f => `<option value="/static/${f}" ${data.image === `/static/${f}` ? 'selected' : ''}>${f}</option>`).join('')}
</select>
<label>Звук:</label>
<select id="action-sound" style="width:100%;">
<option value="">— без звука —</option>
${soundFiles.map(f => `<option value="/sounds/${f}" ${data.sound_file === `/sounds/${f}` ? 'selected' : ''}>${f}</option>`).join('')}
</select>
<label>Длительность (сек):</label><input type="number" id="action-duration" value="${data.duration || 5}" min="1" max="30">
<label>Целевой веб-сервис (ID):</label><input type="number" id="action-target-id" value="${data.target_web_service_id || 0}" min="0">
<small>0 = все alert-сервисы. Узнать ID можно на странице "Веб-сервисы".</small>`;
break;
default:
html = '<p>Неизвестный тип действия</p>';
}
fieldsContainer.innerHTML = html;
}
// Открыть модалку для создания/редактирования
async function openActionModal(index) {
editingIndex = index;
actionIndexInput.value = index;
let data = {};
if (index >= 0 && index < actions.length) {
data = actions[index];
actionTypeSelect.value = data.type;
} else {
actionTypeSelect.value = 'send_message';
}
// Загружаем списки, если ещё не загружены
if (soundFiles.length === 0) await loadSoundList();
if (imageFiles.length === 0) await loadImageList();
renderFieldsForType(actionTypeSelect.value, data);
actionModal.style.display = 'flex';
document.getElementById('modal-title').innerText = index >= 0 ? 'Редактировать действие' : 'Новое действие';
}
// Собрать данные из формы
function collectActionData() {
const type = actionTypeSelect.value;
const base = { type: type };
switch (type) {
case 'send_message':
base.text = document.getElementById('action-text')?.value || '';
break;
case 'play_sound':
base.sound_file = document.getElementById('action-sound-file')?.value || '';
break;
case 'press_hotkey':
base.keys = document.getElementById('action-keys')?.value || '';
break;
case 'http_request':
base.url = document.getElementById('action-url')?.value || '';
break;
case 'run_program':
base.executable = document.getElementById('action-executable')?.value || '';
base.args = document.getElementById('action-args')?.value || '';
break;
case 'send_alert':
base.title = document.getElementById('action-title')?.value || '';
base.alert_text = document.getElementById('action-alert-text')?.value || '';
base.image = document.getElementById('action-image')?.value || '';
base.sound_file = document.getElementById('action-sound')?.value || '';
base.duration = parseInt(document.getElementById('action-duration')?.value) || 5;
base.target_web_service_id = parseInt(document.getElementById('action-target-id')?.value) || 0;
break;
}
return base;
}
// Отобразить список действий
function renderActions() {
actionsDiv.innerHTML = '';
if (actions.length === 0) {
actionsDiv.innerHTML = '<p>Нет действий. Нажмите "Добавить действие".</p>';
return;
}
actions.forEach((act, idx) => {
const div = document.createElement('div');
div.className = 'action-item';
let typeName = '';
switch (act.type) {
case 'send_message': typeName = '📨 Отправить сообщение'; break;
case 'play_sound': typeName = '🔊 Воспроизвести звук'; break;
case 'press_hotkey': typeName = '⌨️ Нажать клавиши'; break;
case 'http_request': typeName = '🌐 HTTP запрос'; break;
case 'run_program': typeName = '⚙️ Запустить программу'; break;
case 'send_alert': typeName = '🔔 Уведомление (alert)'; break;
default: typeName = act.type;
}
let summary = '';
if (act.type === 'send_message') summary = act.text;
else if (act.type === 'play_sound') summary = act.sound_file;
else if (act.type === 'press_hotkey') summary = act.keys;
else if (act.type === 'http_request') summary = act.url;
else if (act.type === 'run_program') summary = act.executable + (act.args ? ' ' + act.args : '');
else if (act.type === 'send_alert') summary = act.title + ' / ' + act.alert_text;
div.innerHTML = `
<strong>${typeName}</strong><br>
<small>${escapeHtml(summary)}</small><br>
<button class="edit-action" data-idx="${idx}">✏️ Редактировать</button>
<button class="remove-action" data-idx="${idx}">🗑️ Удалить</button>
`;
actionsDiv.appendChild(div);
});
document.querySelectorAll('.edit-action').forEach(btn => {
btn.addEventListener('click', (e) => openActionModal(parseInt(btn.dataset.idx)));
});
document.querySelectorAll('.remove-action').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt(btn.dataset.idx);
actions.splice(idx, 1);
renderActions();
});
});
}
// Загрузка действий с сервера
async function loadEventActions() {
currentEvent = eventSelect.value;
const res = await fetch(`/api/events?platform=${currentPlatform}&event=${currentEvent}`);
if (res.ok) {
actions = await res.json();
if (!Array.isArray(actions)) actions = [];
renderActions();
editorDiv.style.display = 'block';
} else {
alert('Ошибка загрузки действий');
}
}
// Сохранение действий
async function saveEventActions() {
const res = await fetch(`/api/events?platform=${currentPlatform}&event=${currentEvent}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(actions)
});
if (res.ok) {
alert('Действия сохранены!');
} else {
alert('Ошибка сохранения');
}
}
// Обработчики UI
loadBtn.addEventListener('click', loadEventActions);
addBtn.addEventListener('click', () => openActionModal(-1));
saveBtn.addEventListener('click', saveEventActions);
actionForm.addEventListener('submit', (e) => {
e.preventDefault();
const newAction = collectActionData();
if (editingIndex >= 0 && editingIndex < actions.length) {
actions[editingIndex] = newAction;
} else {
actions.push(newAction);
}
renderActions();
actionModal.style.display = 'none';
});
actionTypeSelect.addEventListener('change', () => {
const data = editingIndex >= 0 ? actions[editingIndex] : {};
renderFieldsForType(actionTypeSelect.value, data);
});
closeModalBtn.addEventListener('click', () => actionModal.style.display = 'none');
window.addEventListener('click', (e) => { if (e.target === actionModal) actionModal.style.display = 'none'; });
function escapeHtml(str) { if (!str) return ''; return str.replace(/[&<>]/g, function(m) { if (m === '&') return '&amp;'; if (m === '<') return '&lt;'; if (m === '>') return '&gt;'; return m; }); }
</script>
{{end}}
+28
View File
@@ -0,0 +1,28 @@
{{define "content"}}
<h2>Горячие клавиши по донатам</h2>
<p>Правила эмуляции нажатий клавиш при донатах.</p>
<div class="card">
<h3>Настройка эмуляции горячих клавиш</h3>
<label>
<input type="checkbox" id="hotkey-enable"> Включить эмуляцию горячих клавиш
</label>
<p class="help">При включении бот сможет имитировать нажатия клавиш (например, для управления OBS через донаты).</p>
</div>
<script>
async function loadHotkeySetting() {
const res = await fetch('/api/settings/hotkey');
const data = await res.json();
document.getElementById('hotkey-enable').checked = data.enabled;
}
document.getElementById('hotkey-enable').addEventListener('change', async (e) => {
await fetch('/api/settings/hotkey', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: e.target.checked })
});
});
loadHotkeySetting();
</script>
{{end}}
+60
View File
@@ -0,0 +1,60 @@
{{define "content"}}
<h2>Логи бота в реальном времени</h2>
<div style="margin-bottom: 10px;">
<button id="save-logs-btn">💾 Сохранить логи в файл</button>
</div>
<div id="log-container">
<div>Подключение к потоку логов...</div>
</div>
<script>
document.getElementById('save-logs-btn').addEventListener('click', () => {
window.location.href = '/api/logs/download';
});
const logContainer = document.getElementById('log-container');
let eventSource = null;
function addLogEntry(entry) {
const div = document.createElement('div');
const time = new Date(entry.time).toLocaleTimeString();
let levelClass = '';
switch (entry.level) {
case 'INFO': levelClass = 'color: #4ec9b0;'; break;
case 'WARN': levelClass = 'color: #dcdcaa;'; break;
case 'ERROR': levelClass = 'color: #f48771;'; break;
case 'FATAL': levelClass = 'color: #ff0000; font-weight: bold;'; break;
default: levelClass = '';
}
div.innerHTML = `<span style="color: #888;">[${time}]</span> <span style="${levelClass}">[${entry.level}]</span> ${escapeHtml(entry.message)}`;
logContainer.appendChild(div);
logContainer.scrollTop = logContainer.scrollHeight;
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
function connectLogStream() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/api/logs/stream');
eventSource.onmessage = function(event) {
const entry = JSON.parse(event.data);
addLogEntry(entry);
};
eventSource.onerror = function() {
logContainer.innerHTML += '<div style="color: red;">⚠️ Потеря соединения, переподключение через 3 секунды...</div>';
eventSource.close();
setTimeout(connectLogStream, 3000);
};
}
connectLogStream();
</script>
{{end}}
+174
View File
@@ -0,0 +1,174 @@
{{define "content"}}
<h2>Звуковые уведомления</h2>
<div class="card">
<h3>События чата и Twitch</h3>
<table id="notif-table">
<thead>
<tr><th>Событие</th><th>Звуковой файл</th><th>Громкость</th><th>Вкл.</th><th>Действия</th></tr>
</thead>
<tbody></tbody>
</table>
<button id="add-defaults">➕ Добавить события по умолчанию</button>
</div>
<div class="card">
<h3>Управление звуковыми файлами</h3>
<input type="file" id="sound-upload" accept=".mp3,.wav">
<button id="upload-btn">Загрузить</button>
<ul id="sound-list"></ul>
</div>
<div class="card">
<h3>Общие настройки звуков</h3>
<label>
<input type="checkbox" id="duplicate-sounds-checkbox"> Дублировать звуки из команд в локальное воспроизведение
</label>
<p class="help">При включении, звуки, отправляемые в веб-сервисы оповещений, также будут проигрываться на компьютере стримера.</p>
</div>
<script>
let settings = [];
let soundFiles = [];
async function loadSettings() {
const res = await fetch('/api/notifications');
settings = await res.json();
renderTable();
}
async function loadSounds() {
const res = await fetch('/api/sounds');
soundFiles = await res.json();
const list = document.getElementById('sound-list');
if (soundFiles.length === 0) {
list.innerHTML = '<li>Нет загруженных звуков. Загрузите MP3 или WAV.</li>';
} else {
list.innerHTML = soundFiles.map(f => `<li>${escapeHtml(f)} <button class="delete-sound" data-file="${f}">🗑️</button></li>`).join('');
document.querySelectorAll('.delete-sound').forEach(btn => {
btn.addEventListener('click', () => deleteSound(btn.dataset.file));
});
}
// Обновляем выпадающие списки в таблице
renderTable();
}
function renderTable() {
const tbody = document.querySelector('#notif-table tbody');
if (!settings.length) {
tbody.innerHTML = '<tr><td colspan="5">Нет настроек. Нажмите "Добавить события по умолчанию".</td></tr>';
return;
}
tbody.innerHTML = settings.map(s => `
<tr data-event="${s.event_name}">
<td>${escapeHtml(s.event_name)}</td>
<td>
<select class="sound-select" data-event="${s.event_name}">
<option value="">— без звука —</option>
${soundFiles.map(f => `<option value="data/sounds/${f}" ${s.sound_file === `data/sounds/${f}` ? 'selected' : ''}>${f}</option>`).join('')}
</select>
</td>
<td>
<input type="range" class="volume-slider" min="0" max="100" value="${s.volume}" data-event="${s.event_name}">
<span class="vol-value">${s.volume}</span>%
</td>
<td><input type="checkbox" class="enable-checkbox" ${s.enabled ? 'checked' : ''} data-event="${s.event_name}"></td>
<td><button class="test-btn" data-event="${s.event_name}">🔊 Тест</button></td>
</tr>
`).join('');
// Привязываем обработчики
document.querySelectorAll('.sound-select').forEach(sel => {
sel.addEventListener('change', (e) => updateSetting(e.target.dataset.event, 'sound_file', sel.value));
});
document.querySelectorAll('.volume-slider').forEach(slider => {
slider.addEventListener('input', (e) => {
const val = e.target.value;
const span = e.target.parentElement.querySelector('.vol-value');
span.innerText = val;
updateSetting(e.target.dataset.event, 'volume', parseInt(val));
});
});
document.querySelectorAll('.enable-checkbox').forEach(cb => {
cb.addEventListener('change', (e) => updateSetting(e.target.dataset.event, 'enabled', cb.checked));
});
document.querySelectorAll('.test-btn').forEach(btn => {
btn.addEventListener('click', () => testSound(btn.dataset.event));
});
}
async function updateSetting(eventName, field, value) {
const setting = settings.find(s => s.event_name === eventName);
if (!setting) return;
setting[field] = value;
const res = await fetch('/api/notifications', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(setting)
});
if (!res.ok) alert('Ошибка сохранения');
}
async function testSound(eventName) {
await fetch(`/api/notifications/test?event=${encodeURIComponent(eventName)}`);
}
async function deleteSound(filename) {
if (!confirm(`Удалить ${filename}?`)) return;
const res = await fetch(`/api/sounds?file=${encodeURIComponent(filename)}`, { method: 'DELETE' });
if (res.ok) {
await loadSounds();
await loadSettings();
} else {
alert('Ошибка удаления');
}
}
document.getElementById('upload-btn').onclick = async () => {
const fileInput = document.getElementById('sound-upload');
if (!fileInput.files.length) return;
const formData = new FormData();
formData.append('sound', fileInput.files[0]);
const res = await fetch('/api/sounds/upload', { method: 'POST', body: formData });
if (res.ok) {
await loadSounds();
await loadSettings();
fileInput.value = '';
} else {
alert('Ошибка загрузки');
}
};
document.getElementById('add-defaults').onclick = async () => {
const res = await fetch('/api/notifications/defaults', { method: 'POST' });
if (res.ok) {
await loadSettings();
await loadSounds();
} else {
alert('Ошибка добавления событий по умолчанию');
}
};
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
async function loadDuplicateSetting() {
const res = await fetch('/api/settings/duplicate_sounds');
const data = await res.json();
document.getElementById('duplicate-sounds-checkbox').checked = data.enabled;
}
document.getElementById('duplicate-sounds-checkbox').addEventListener('change', async (e) => {
await fetch('/api/settings/duplicate_sounds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: e.target.checked })
});
});
loadDuplicateSetting();
loadSettings();
loadSounds();
</script>
{{end}}
+176
View File
@@ -0,0 +1,176 @@
{{define "content"}}
<div class="card">
<h3>Twitch</h3>
<div>
<p>Статус бота: <span id="twitch-status" class="status">Загрузка...</span></p>
<p>Имя канала: <span id="twitch-channel"></span></p>
</div>
<div style="margin: 10px 0;">
<button id="auth-user-btn">🔐 Авторизовать стримера</button>
<button id="auth-bot-btn">🤖 Авторизовать бота</button>
</div>
<div id="twitch-tokens-info"></div>
<div id="token-expiry-info" style="margin-top: 10px; font-size: 0.9em;">
<p>🕒 Срок действия токенов:</p>
<p>👤 Стример: <span id="user-expiry"></span></p>
<p>🤖 Бот: <span id="bot-expiry"></span></p>
</div>
<!-- Блок для отображения ссылки авторизации БОТА -->
<div id="auth-link-container" style="margin-top: 15px; display: none;">
<label>Скопируйте ссылку и откройте её в браузере, где залогинен аккаунт БОТА:</label>
<div style="display: flex; gap: 10px; margin-top: 5px;">
<input type="text" id="auth-link" readonly style="flex: 1; padding: 8px; font-family: monospace;">
<button id="copy-link-btn">📋 Копировать</button>
</div>
<p id="auth-wait-message" style="color: #666; margin-top: 10px;">Ожидание подтверждения авторизации...</p>
</div>
<div id="notification" style="position: fixed; top: 20px; right: 20px; z-index: 1000; background: #333; color: white; padding: 10px 20px; border-radius: 5px; display: none;"></div>
</div>
<script>
let pollInterval = null;
async function loadSettings() {
try {
const statusRes = await fetch('/api/platforms/twitch/status');
const statusData = await statusRes.json();
const statusSpan = document.getElementById('twitch-status');
if (statusData.connected) {
statusSpan.textContent = 'Подключен';
statusSpan.className = 'status online';
} else {
statusSpan.textContent = 'Отключен';
statusSpan.className = 'status offline';
}
const authRes = await fetch('/api/platforms/twitch/auth');
const authData = await authRes.json();
document.getElementById('twitch-channel').textContent = authData.username || 'не указан';
const tokensDiv = document.getElementById('twitch-tokens-info');
if (authData.hasToken) {
tokensDiv.innerHTML = `<p>🔑 Токен бота: ${authData.maskedToken}</p>`;
loadTokenExpiry();
} else {
tokensDiv.innerHTML = '<p style="color: orange;">⚠️ Токен бота не настроен. Авторизуйте бота.</p>';
}
} catch (err) {
console.error('Ошибка загрузки настроек:', err);
document.getElementById('twitch-status').textContent = 'Ошибка';
}
}
async function updateStatus() {
try {
const res = await fetch('/api/platforms/twitch/status');
const data = await res.json();
const statusSpan = document.getElementById('twitch-status');
if (data.connected) {
statusSpan.textContent = 'Подключен';
statusSpan.className = 'status online';
} else {
statusSpan.textContent = 'Отключен';
statusSpan.className = 'status offline';
}
} catch (err) {}
}
async function authStreamer() {
try {
const res = await fetch('/api/platforms/twitch/auth/user');
const data = await res.json();
if (data.url) {
window.open(data.url, '_blank', 'width=600,height=700');
if (pollInterval) clearInterval(pollInterval);
pollInterval = setInterval(() => checkTokenUpdated('user'), 2000);
} else {
alert('Не удалось получить ссылку для авторизации');
}
} catch (err) {
alert('Ошибка: ' + err.message);
}
}
async function authBot() {
try {
const res = await fetch('/api/platforms/twitch/auth/bot');
const data = await res.json();
if (data.url) {
document.getElementById('auth-link').value = data.url;
document.getElementById('auth-link-container').style.display = 'block';
if (pollInterval) clearInterval(pollInterval);
pollInterval = setInterval(() => checkTokenUpdated('bot'), 2000);
} else {
alert('Не удалось получить ссылку для авторизации');
}
} catch (err) {
alert('Ошибка: ' + err.message);
}
}
async function loadTokenExpiry() {
try {
const res = await fetch('/api/platforms/twitch/token_expiry');
const data = await res.json();
const userSpan = document.getElementById('user-expiry');
const botSpan = document.getElementById('bot-expiry');
if (data.user_expiry_days !== null && data.user_expiry_days !== undefined) {
userSpan.textContent = data.user_expiry_days + ' дн.';
if (data.user_expiry_days < 1) userSpan.style.color = 'red';
else if (data.user_expiry_days < 3) userSpan.style.color = 'orange';
else userSpan.style.color = 'green';
} else {
userSpan.textContent = 'не авторизован';
}
if (data.bot_expiry_days !== null && data.bot_expiry_days !== undefined) {
botSpan.textContent = data.bot_expiry_days + ' дн.';
if (data.bot_expiry_days < 1) botSpan.style.color = 'red';
else if (data.bot_expiry_days < 3) botSpan.style.color = 'orange';
else botSpan.style.color = 'green';
} else {
botSpan.textContent = 'не авторизован';
}
} catch (err) {
console.error('Ошибка загрузки срока токенов:', err);
}
}
function showMessage(text, isError = false) {
const notif = document.getElementById('notification');
notif.textContent = text;
notif.style.backgroundColor = isError ? '#d9534f' : '#5cb85c';
notif.style.display = 'block';
setTimeout(() => { notif.style.display = 'none'; }, 3000);
}
async function checkTokenUpdated(type) {
try {
const res = await fetch(`/api/platforms/twitch/token_check?type=${type}`);
const data = await res.json();
if (data.updated) {
clearInterval(pollInterval);
pollInterval = null;
if (type === 'bot') {
document.getElementById('auth-link-container').style.display = 'none';
}
showMessage(`✅ Токен ${type === 'user' ? 'стримера' : 'бота'} сохранён!`, false);
loadSettings();
}
} catch (err) {
console.error('Ошибка проверки токена:', err);
}
}
document.getElementById('copy-link-btn').addEventListener('click', () => {
const linkInput = document.getElementById('auth-link');
linkInput.select();
document.execCommand('copy');
alert('Ссылка скопирована! Откройте её в другом браузере.');
});
document.getElementById('auth-user-btn').onclick = authStreamer;
document.getElementById('auth-bot-btn').onclick = authBot;
loadSettings();
setInterval(updateStatus, 10000);
</script>
{{end}}
+296
View File
@@ -0,0 +1,296 @@
{{define "content"}}
<h2>Веб-сервисы для OBS</h2>
<p>Создавайте несколько независимых оверлеев чата и оповещений. Каждый сервис работает на своём порту.</p>
<div class="card">
<button id="add-chat-btn" style="margin-right:10px;">➕ Добавить чат-оверлей</button>
<button id="add-alert-btn">🔔 Добавить оверлей оповещений</button>
</div>
<div class="card">
<h3>Тестирование оповещений</h3>
<label>Выберите alert-сервис:</label>
<select id="test-alert-service"></select>
<label>Тип события:</label>
<select id="test-alert-event">
<option value="follow">Фолловер</option>
<option value="subscribe">Подписка</option>
<option value="gift_sub">Подарочная подписка</option>
<option value="raid">Рейд</option>
<option value="reward_redemption">Награда</option>
</select>
<button id="test-alert-btn">🔔 Тестировать оповещение</button>
</div>
<div class="card">
<h3>Список сервисов</h3>
<table id="services-table" style="width:100%; border-collapse: collapse;">
<thead>
<tr><th>ID</th><th>Тип</th><th>Порт</th><th>Статус</th><th>Действия</th></tr>
</thead>
<tbody>
<tr><td colspan="5">Загрузка...</td></tr>
</tbody>
</table>
</div>
<!-- Модальное окно для создания/редактирования -->
<div id="service-modal" class="modal">
<div class="modal-content">
<h3 id="modal-title">Создание сервиса</h3>
<form id="service-form">
<input type="hidden" id="service-id" value="0">
<label>Порт: <input type="number" id="service-port" required style="width:100%;"></label>
<label><input type="checkbox" id="service-enabled" checked> Сервис активен</label>
<!-- Настройки чата (показываются только для типа chat) -->
<div id="chat-config-fields" style="display:none;">
<h4>Настройки чата</h4>
<label>Фон (цвет): <input type="color" id="chat-bg-color" value="#000000"></label>
<label>Цвет текста: <input type="color" id="chat-text-color" value="#ffffff"></label>
<label>Размер шрифта (px): <input type="number" id="chat-font-size" min="10" max="50" value="14"></label>
<label>Шрифт: <input type="text" id="chat-font-family" value="Arial, sans-serif"></label>
<label>Прозрачность (%): <input type="range" id="chat-opacity" min="0" max="100" value="80"> <span id="opacity-val">80</span></label>
<label>Таймаут сообщения (сек): <input type="number" id="chat-timeout" min="0" max="60" value="10"></label>
<label>Макс. сообщений: <input type="number" id="chat-max-msgs" min="1" max="100" value="20"></label>
<label><input type="checkbox" id="chat-show-badges" checked> Показывать значки</label>
<label><input type="checkbox" id="chat-show-timestamps"> Показывать время</label>
<div class="preview-box">
<strong>Превью:</strong>
<div id="chat-preview" class="chat-preview" style="background:#000; color:#fff; padding:8px; border-radius:4px; margin-top:6px;">
<span style="font-weight:bold;">TestUser:</span> Привет, мир!
</div>
</div>
</div>
<!-- Для alert-сервиса ничего лишнего не показываем -->
<div id="alert-config-fields" style="display:none;">
<p class="info">Оповещения настраиваются через HTTP-запросы к эндпоинту <code>/notify</code> этого сервиса. Все уведомления будут отображаться автоматически.</p>
</div>
<div style="margin-top:15px;">
<button type="submit">Сохранить</button>
<button type="button" id="close-modal">Отмена</button>
</div>
</form>
</div>
</div>
<script>
let currentType = 'chat';
let services = [];
async function loadServices() {
try {
const res = await fetch('/api/webservices');
if (!res.ok) throw new Error('HTTP ' + res.status);
services = await res.json();
if (!Array.isArray(services)) services = [];
const tbody = document.querySelector('#services-table tbody');
if (!services.length) {
tbody.innerHTML = '<tr><td colspan="5">Нет сервисов. Создайте первый.</td></tr>';
return;
}
tbody.innerHTML = services.map(s => `
<tr>
<td>${s.id}</td>
<td>${s.service_type === 'chat' ? 'Чат' : 'Оповещения'}</td>
<td>${s.port}</td>
<td class="status-${s.id}">${s.running ? '✅ Запущен' : (s.enabled ? '⏹ Остановлен' : '❌ Отключен')}</td>
<td>
<button onclick="startService(${s.id})">▶ Запустить</button>
<button onclick="stopService(${s.id})">⏹ Остановить</button>
<button onclick="editService(${s.id})">✏️ Редактировать</button>
<button onclick="deleteService(${s.id})">🗑 Удалить</button>
</td>
</tr>
`).join('');
} catch (err) {
console.error('loadServices error:', err);
document.querySelector('#services-table tbody').innerHTML = '<tr><td colspan="5">Ошибка загрузки</td></tr>';
}
}
async function startService(id) {
const res = await fetch(`/api/webservices/start?id=${id}`, { method: 'POST' });
if (res.ok) loadServices(); else alert('Ошибка запуска');
}
async function stopService(id) {
const res = await fetch(`/api/webservices/stop?id=${id}`, { method: 'POST' });
if (res.ok) loadServices(); else alert('Ошибка остановки');
}
async function deleteService(id) {
if (!confirm('Удалить сервис?')) return;
const res = await fetch(`/api/webservices/delete?id=${id}`, { method: 'DELETE' });
if (res.ok) loadServices(); else alert('Ошибка удаления');
}
async function editService(id) {
const service = services.find(s => s.id === id);
if (!service) return;
currentType = service.service_type;
document.getElementById('service-id').value = service.id;
document.getElementById('service-port').value = service.port;
document.getElementById('service-enabled').checked = service.enabled;
if (currentType === 'chat') {
const cfg = service.config_json ? JSON.parse(service.config_json) : {};
document.getElementById('chat-bg-color').value = cfg.bg_color || '#000000';
document.getElementById('chat-text-color').value = cfg.text_color || '#ffffff';
document.getElementById('chat-font-size').value = cfg.font_size || 14;
document.getElementById('chat-font-family').value = cfg.font_family || 'Arial, sans-serif';
document.getElementById('chat-opacity').value = cfg.opacity || 80;
document.getElementById('opacity-val').innerText = cfg.opacity || 80;
document.getElementById('chat-timeout').value = cfg.message_timeout_sec || 10;
document.getElementById('chat-max-msgs').value = cfg.max_messages || 20;
document.getElementById('chat-show-badges').checked = cfg.show_badges !== undefined ? cfg.show_badges : true;
document.getElementById('chat-show-timestamps').checked = cfg.show_timestamps || false;
updateChatPreview();
}
showModal();
}
function updateChatPreview() {
const bg = document.getElementById('chat-bg-color').value;
const color = document.getElementById('chat-text-color').value;
const fontSize = document.getElementById('chat-font-size').value;
const fontFamily = document.getElementById('chat-font-family').value;
const opacity = document.getElementById('chat-opacity').value;
const showBadges = document.getElementById('chat-show-badges').checked;
const showTimestamps = document.getElementById('chat-show-timestamps').checked;
const previewDiv = document.getElementById('chat-preview');
previewDiv.style.backgroundColor = bg;
previewDiv.style.color = color;
previewDiv.style.fontSize = fontSize + 'px';
previewDiv.style.fontFamily = fontFamily;
previewDiv.style.opacity = opacity/100;
let badgesHtml = '';
if (showBadges) badgesHtml = '<span style="margin-right:4px;">🎭</span><span style="margin-right:4px;">👑</span><span>✔️</span> ';
let timeHtml = '';
if (showTimestamps) timeHtml = '<span style="color:#aaa; margin-right:8px;">12:34</span> ';
previewDiv.innerHTML = timeHtml + badgesHtml + '<span style="font-weight:bold;">TestUser:</span> Привет, мир!';
}
function showModal() {
document.getElementById('service-modal').style.display = 'flex';
document.getElementById('chat-config-fields').style.display = currentType === 'chat' ? 'block' : 'none';
document.getElementById('alert-config-fields').style.display = currentType === 'alert' ? 'block' : 'none';
document.getElementById('modal-title').innerText = (document.getElementById('service-id').value == 0 ? 'Создание' : 'Редактирование') + (currentType === 'chat' ? ' чат-оверлея' : ' оверлея оповещений');
}
async function openCreateDialog(type) {
currentType = type;
document.getElementById('service-id').value = 0;
document.getElementById('service-enabled').checked = true;
// Определяем свободный порт
const res = await fetch('/api/webservices');
let servicesList = [];
if (res.ok) {
servicesList = await res.json();
if (!Array.isArray(servicesList)) servicesList = [];
}
const usedPorts = servicesList.map(s => s.port);
let basePort = 9000;
while (usedPorts.includes(basePort)) basePort++;
document.getElementById('service-port').value = basePort;
if (type === 'chat') {
// Настройки чата по умолчанию
document.getElementById('chat-bg-color').value = '#000000';
document.getElementById('chat-text-color').value = '#ffffff';
document.getElementById('chat-font-size').value = 14;
document.getElementById('chat-font-family').value = 'Arial, sans-serif';
document.getElementById('chat-opacity').value = 80;
document.getElementById('opacity-val').innerText = '80';
document.getElementById('chat-timeout').value = 10;
document.getElementById('chat-max-msgs').value = 20;
document.getElementById('chat-show-badges').checked = true;
document.getElementById('chat-show-timestamps').checked = false;
updateChatPreview();
}
showModal();
}
document.getElementById('add-chat-btn').onclick = () => openCreateDialog('chat');
document.getElementById('add-alert-btn').onclick = () => openCreateDialog('alert');
document.getElementById('close-modal').onclick = () => document.getElementById('service-modal').style.display = 'none';
// Обработчики для превью чата
document.getElementById('chat-bg-color').addEventListener('input', updateChatPreview);
document.getElementById('chat-text-color').addEventListener('input', updateChatPreview);
document.getElementById('chat-font-size').addEventListener('input', updateChatPreview);
document.getElementById('chat-font-family').addEventListener('input', updateChatPreview);
document.getElementById('chat-opacity').addEventListener('input', (e) => { document.getElementById('opacity-val').innerText = e.target.value; updateChatPreview(); });
document.getElementById('chat-show-badges').addEventListener('change', updateChatPreview);
document.getElementById('chat-show-timestamps').addEventListener('change', updateChatPreview);
document.getElementById('service-form').onsubmit = async (e) => {
e.preventDefault();
const id = parseInt(document.getElementById('service-id').value);
const port = parseInt(document.getElementById('service-port').value);
const enabled = document.getElementById('service-enabled').checked;
let config = {};
if (currentType === 'chat') {
config = {
bg_color: document.getElementById('chat-bg-color').value,
text_color: document.getElementById('chat-text-color').value,
font_size: parseInt(document.getElementById('chat-font-size').value),
font_family: document.getElementById('chat-font-family').value,
opacity: parseInt(document.getElementById('chat-opacity').value),
message_timeout_sec: parseInt(document.getElementById('chat-timeout').value),
max_messages: parseInt(document.getElementById('chat-max-msgs').value),
show_badges: document.getElementById('chat-show-badges').checked,
show_timestamps: document.getElementById('chat-show-timestamps').checked
};
} else {
// Для alert-сервиса отправляем пустой конфиг
config = {};
}
const url = id === 0 ? '/api/webservices/create' : `/api/webservices/update?id=${id}`;
const method = id === 0 ? 'POST' : 'PUT';
const body = JSON.stringify({ type: currentType, port, config, enabled });
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body });
if (res.ok) {
document.getElementById('service-modal').style.display = 'none';
loadServices();
} else {
const err = await res.text();
alert('Ошибка: ' + err);
}
};
async function loadAlertServices() {
const res = await fetch('/api/webservices/alert/list');
const services = await res.json();
const select = document.getElementById('test-alert-service');
select.innerHTML = '<option value="">-- выберите сервис --</option>';
services.forEach(s => {
const option = document.createElement('option');
option.value = s.id;
option.textContent = s.name;
select.appendChild(option);
});
}
document.getElementById('test-alert-btn').onclick = async () => {
const serviceId = document.getElementById('test-alert-service').value;
if (!serviceId) {
alert('Выберите alert-сервис');
return;
}
const eventType = document.getElementById('test-alert-event').value;
const res = await fetch('/api/webservices/test/alert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serviceId: parseInt(serviceId), eventType: eventType })
});
if (res.ok) alert('Тестовое оповещение отправлено');
else alert('Ошибка');
};
// Вызываем при загрузке страницы
loadAlertServices();
loadServices();
setInterval(loadServices, 5000);
</script>
{{end}}