2 Commits

Author SHA1 Message Date
Joaquin 01d39bdc6c Merge remote-tracking branch origin/main into main
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-07-13 15:18:30 +00:00
Joaquin a1835f2f3e Feature: ChatBot integration 2026-07-13 15:18:05 +00:00
4 changed files with 579 additions and 0 deletions
+1
View File
@@ -1104,6 +1104,7 @@
<script src="/js/pages/SeriesPage.js?v=1"></script> <script src="/js/pages/SeriesPage.js?v=1"></script>
<script src="/js/pages/Settings.js?v=3"></script> <script src="/js/pages/Settings.js?v=3"></script>
<script src="/js/pages/WatchPage.js?v=3"></script> <script src="/js/pages/WatchPage.js?v=3"></script>
<script src="/js/components/ChatBot.js?v=2"></script>
<script src="/js/app.js?v=5"></script> <script src="/js/app.js?v=5"></script>
<script> <script>
// Check for SSO token in URL // Check for SSO token in URL
+428
View File
@@ -0,0 +1,428 @@
/**
* KiraBot — floating chat widget for KiraTV
*/
class ChatBot {
constructor() {
this._open = false;
this._tab = 'chat'; // 'chat' | 'recordings'
this._messages = [];
this._recordings = [];
this._loading = false;
this._token = null;
this._init();
}
_init() {
// Inject styles
const style = document.createElement('style');
style.textContent = `
#kirabot-btn {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9000;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #7c3aed, #4f46e5);
border: none;
cursor: pointer;
box-shadow: 0 4px 20px rgba(124,58,237,.5);
display: flex;
align-items: center;
justify-content: center;
transition: transform .2s, box-shadow .2s;
}
#kirabot-btn:hover { transform: scale(1.1); box-shadow: 0 6px 28px rgba(124,58,237,.7); }
#kirabot-btn svg { width: 26px; height: 26px; fill: #fff; }
#kirabot-badge {
position: absolute;
top: -4px;
right: -4px;
background: #ef4444;
color: #fff;
border-radius: 9999px;
font-size: 10px;
font-weight: 700;
min-width: 18px;
height: 18px;
display: none;
align-items: center;
justify-content: center;
padding: 0 4px;
}
#kirabot-panel {
position: fixed;
bottom: 90px;
right: 24px;
z-index: 9001;
width: 380px;
max-width: calc(100vw - 32px);
height: 560px;
max-height: calc(100vh - 110px);
background: #1a1a2e;
border: 1px solid rgba(124,58,237,.4);
border-radius: 20px;
display: flex;
flex-direction: column;
box-shadow: 0 16px 48px rgba(0,0,0,.6);
transition: opacity .25s, transform .25s;
overflow: hidden;
}
#kirabot-panel.hidden { opacity: 0; transform: translateY(12px) scale(.97); pointer-events: none; }
.kb-header {
background: linear-gradient(135deg, #7c3aed, #4f46e5);
padding: 14px 16px;
display: flex;
align-items: center;
gap: 10px;
}
.kb-header-title { font-weight: 700; font-size: 15px; color: #fff; flex: 1; }
.kb-header-sub { font-size: 11px; color: rgba(255,255,255,.7); }
.kb-close-btn {
background: rgba(255,255,255,.15);
border: none;
border-radius: 50%;
width: 28px; height: 28px;
cursor: pointer;
color: #fff;
font-size: 16px;
display: flex; align-items: center; justify-content: center;
}
.kb-tabs {
display: flex;
border-bottom: 1px solid rgba(255,255,255,.08);
background: #16162a;
}
.kb-tab {
flex: 1;
padding: 10px;
background: none;
border: none;
color: rgba(255,255,255,.5);
font-size: 13px;
cursor: pointer;
transition: color .2s;
}
.kb-tab.active { color: #a78bfa; border-bottom: 2px solid #7c3aed; font-weight: 600; }
.kb-messages {
flex: 1;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
scroll-behavior: smooth;
}
.kb-messages::-webkit-scrollbar { width: 4px; }
.kb-messages::-webkit-scrollbar-track { background: transparent; }
.kb-messages::-webkit-scrollbar-thumb { background: rgba(255,255,255,.15); border-radius: 2px; }
.kb-bubble {
max-width: 88%;
padding: 10px 13px;
border-radius: 14px;
font-size: 13.5px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.kb-bubble.user {
align-self: flex-end;
background: linear-gradient(135deg, #7c3aed, #4f46e5);
color: #fff;
border-bottom-right-radius: 4px;
}
.kb-bubble.bot {
align-self: flex-start;
background: rgba(255,255,255,.07);
color: #e2e8f0;
border-bottom-left-radius: 4px;
}
.kb-bubble.error { background: rgba(239,68,68,.15); color: #fca5a5; border: 1px solid rgba(239,68,68,.3); }
.kb-typing { align-self: flex-start; color: rgba(255,255,255,.4); font-size: 13px; font-style: italic; }
.kb-input-row {
display: flex;
gap: 8px;
padding: 12px 14px;
border-top: 1px solid rgba(255,255,255,.08);
background: #16162a;
}
.kb-input {
flex: 1;
background: rgba(255,255,255,.07);
border: 1px solid rgba(255,255,255,.12);
border-radius: 12px;
color: #e2e8f0;
font-size: 13.5px;
padding: 8px 12px;
resize: none;
outline: none;
font-family: inherit;
}
.kb-input:focus { border-color: rgba(124,58,237,.6); }
.kb-send-btn {
background: linear-gradient(135deg, #7c3aed, #4f46e5);
border: none;
border-radius: 12px;
width: 42px;
height: 42px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.kb-send-btn:disabled { opacity: .4; cursor: default; }
.kb-send-btn svg { width: 18px; height: 18px; fill: #fff; }
.kb-recordings {
flex: 1;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.kb-rec-item {
background: rgba(255,255,255,.05);
border: 1px solid rgba(255,255,255,.08);
border-radius: 12px;
padding: 10px 12px;
}
.kb-rec-title { font-weight: 600; font-size: 13px; color: #e2e8f0; }
.kb-rec-meta { font-size: 11px; color: rgba(255,255,255,.45); margin-top: 3px; }
.kb-rec-status { font-size: 11px; font-weight: 600; margin-top: 4px; }
.kb-rec-status.scheduled { color: #fbbf24; }
.kb-rec-status.recording { color: #f87171; }
.kb-rec-status.completed { color: #34d399; }
.kb-rec-status.failed { color: #f87171; }
.kb-rec-status.cancelled { color: rgba(255,255,255,.3); }
.kb-empty { text-align: center; color: rgba(255,255,255,.35); font-size: 13px; padding: 40px 0; }
.kb-bot-avatar { width: 22px; height: 22px; flex-shrink: 0; }
#kb-chat-view { display: flex; flex-direction: column; flex: 1; overflow: hidden; min-height: 0; }
#kb-rec-view { display: none; flex-direction: column; flex: 1; overflow: hidden; min-height: 0; }
`;
document.head.appendChild(style);
// Floating button
const btn = document.createElement('button');
btn.id = 'kirabot-btn';
btn.title = 'KiraBot — Asistente de TV';
btn.innerHTML = `
<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM9 11H7V9h2v2zm4 0h-2V9h2v2zm4 0h-2V9h2v2z"/></svg>
<span id="kirabot-badge"></span>
`;
btn.addEventListener('click', () => this.toggle());
document.body.appendChild(btn);
// Chat panel
const panel = document.createElement('div');
panel.id = 'kirabot-panel';
panel.className = 'hidden';
panel.innerHTML = `
<div class="kb-header">
<svg class="kb-bot-avatar" viewBox="0 0 24 24" fill="white"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/></svg>
<div>
<div class="kb-header-title">KiraBot</div>
<div class="kb-header-sub">Asistente de TV · IA</div>
</div>
<button class="kb-close-btn" id="kb-close">✕</button>
</div>
<div class="kb-tabs">
<button class="kb-tab active" id="kb-tab-chat">💬 Chat</button>
<button class="kb-tab" id="kb-tab-rec">📹 Grabaciones</button>
</div>
<div id="kb-chat-view">
<div class="kb-messages" id="kb-messages"></div>
<div class="kb-input-row">
<textarea class="kb-input" id="kb-input" rows="1"
placeholder="Pregunta por la programación, pide grabaciones..."></textarea>
<button class="kb-send-btn" id="kb-send" title="Enviar">
<svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</div>
</div>
<div id="kb-rec-view" style="display:none;flex:1;overflow:hidden;flex-direction:column;">
<div class="kb-recordings" id="kb-recordings"></div>
</div>
`;
document.body.appendChild(panel);
// Events
document.getElementById('kb-close').addEventListener('click', () => this.close());
document.getElementById('kb-tab-chat').addEventListener('click', () => this._switchTab('chat'));
document.getElementById('kb-tab-rec').addEventListener('click', () => this._switchTab('recordings'));
document.getElementById('kb-send').addEventListener('click', () => this._sendMessage());
// Stop all keyboard and mouse events from bubbling to NodeCast's global handlers
// (NodeCast intercepts spacebar for play/pause and other keys for media control)
const inputEl = document.getElementById('kb-input');
inputEl.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this._sendMessage(); }
});
inputEl.addEventListener('keypress', (e) => e.stopPropagation());
inputEl.addEventListener('keyup', (e) => e.stopPropagation());
panel.addEventListener('click', (e) => e.stopPropagation());
panel.addEventListener('mousedown', (e) => e.stopPropagation());
// Welcome message
this._appendBotMessage('¡Hola! Soy KiraBot 👋 Puedo ayudarte con la programación de TV y programar grabaciones. ¿En qué te puedo ayudar?');
}
toggle() {
this._open ? this.close() : this.open();
}
open() {
this._open = true;
document.getElementById('kirabot-panel').classList.remove('hidden');
document.getElementById('kb-input').focus();
this._scrollToBottom();
}
close() {
this._open = false;
document.getElementById('kirabot-panel').classList.add('hidden');
}
_switchTab(tab) {
this._tab = tab;
document.getElementById('kb-tab-chat').classList.toggle('active', tab === 'chat');
document.getElementById('kb-tab-rec').classList.toggle('active', tab === 'recordings');
document.getElementById('kb-chat-view').style.display = tab === 'chat' ? 'flex' : 'none';
document.getElementById('kb-rec-view').style.display = tab === 'recordings' ? 'flex' : 'none';
if (tab === 'recordings') this._loadRecordings();
}
_appendBotMessage(text, isError = false) {
const msgs = document.getElementById('kb-messages');
const div = document.createElement('div');
div.className = `kb-bubble bot${isError ? ' error' : ''}`;
div.textContent = text;
msgs.appendChild(div);
this._scrollToBottom();
return div;
}
_appendUserMessage(text) {
const msgs = document.getElementById('kb-messages');
const div = document.createElement('div');
div.className = 'kb-bubble user';
div.textContent = text;
msgs.appendChild(div);
this._scrollToBottom();
}
_showTyping() {
const msgs = document.getElementById('kb-messages');
const div = document.createElement('div');
div.className = 'kb-typing';
div.id = 'kb-typing';
div.textContent = 'KiraBot está escribiendo...';
msgs.appendChild(div);
this._scrollToBottom();
}
_removeTyping() {
document.getElementById('kb-typing')?.remove();
}
_scrollToBottom() {
const msgs = document.getElementById('kb-messages');
if (msgs) setTimeout(() => { msgs.scrollTop = msgs.scrollHeight; }, 10);
}
async _sendMessage() {
const input = document.getElementById('kb-input');
const text = input.value.trim();
if (!text || this._loading) return;
input.value = '';
input.style.height = 'auto';
this._appendUserMessage(text);
this._loading = true;
document.getElementById('kb-send').disabled = true;
this._showTyping();
try {
const resp = await fetch('/api/kirabot/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('authToken') || ''}`,
},
body: JSON.stringify({ message: text }),
});
const data = await resp.json();
this._removeTyping();
if (resp.ok) {
this._appendBotMessage(data.response || 'Sin respuesta');
} else {
this._appendBotMessage(data.error || 'Error al conectar con KiraBot', true);
}
} catch (err) {
this._removeTyping();
this._appendBotMessage('No se pudo conectar con KiraBot. ¿Está el servidor activo?', true);
} finally {
this._loading = false;
document.getElementById('kb-send').disabled = false;
input.focus();
}
}
async _loadRecordings() {
const container = document.getElementById('kb-recordings');
container.innerHTML = '<div class="kb-empty">Cargando...</div>';
try {
const resp = await fetch('/api/kirabot/recordings?limit=20', {
headers: { 'Authorization': `Bearer ${localStorage.getItem('authToken') || ''}` },
});
const recs = await resp.json();
container.innerHTML = '';
if (!Array.isArray(recs) || recs.length === 0) {
container.innerHTML = '<div class="kb-empty">No tienes grabaciones todavía.<br>¡Pídele a KiraBot que grabe algo!</div>';
return;
}
const statusLabels = {
scheduled: ['⏳', 'scheduled', 'Programada'],
recording: ['🔴', 'recording', 'Grabando'],
completed: ['✓', 'completed', 'Completada'],
failed: ['✗', 'failed', 'Fallida'],
cancelled: ['✗', 'cancelled', 'Cancelada'],
};
recs.forEach((r) => {
const [icon, cls, label] = statusLabels[r.status] || ['?', '', r.status];
const start = r.start_time ? new Date(r.start_time).toLocaleString('es-ES', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '';
const item = document.createElement('div');
item.className = 'kb-rec-item';
item.innerHTML = `
<div class="kb-rec-title">${this._esc(r.title)}</div>
<div class="kb-rec-meta">${this._esc(r.channel_name || '')}${start ? ' · ' + start : ''}</div>
<div class="kb-rec-status ${cls}">${icon} ${label}</div>
`;
container.appendChild(item);
});
} catch (err) {
container.innerHTML = '<div class="kb-empty" style="color:#f87171">Error al cargar las grabaciones.</div>';
}
}
_esc(str) {
return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
}
// Initialize when app is ready
document.addEventListener('DOMContentLoaded', () => {
// Delay slightly to let auth check happen first
setTimeout(() => {
if (localStorage.getItem('authToken')) {
window.kiraBot = new ChatBot();
}
}, 500);
});
+1
View File
@@ -187,6 +187,7 @@ app.use('/api/probe', require('./routes/probe'));
app.use('/api/subtitle', require('./routes/subtitle')); app.use('/api/subtitle', require('./routes/subtitle'));
app.use('/api/settings', require('./routes/settings')); app.use('/api/settings', require('./routes/settings'));
app.use('/api/history', require('./routes/history')); app.use('/api/history', require('./routes/history'));
app.use('/api/kirabot', require('./routes/kirabot'));
// Version endpoint // Version endpoint
app.get('/api/version', (req, res) => { app.get('/api/version', (req, res) => {
+149
View File
@@ -0,0 +1,149 @@
/**
* KiraBot proxy — forwards /api/kirabot/* to KiraStream backend
* authenticating on behalf of the current kiratv user.
*/
const express = require('express');
const router = express.Router();
const { requireAuth } = require('../auth');
const { sources } = require('../db');
const http = require('http');
const KIRASTREAM_BASE = process.env.KIRASTREAM_BASE_URL || 'http://10.10.10.234';
// Token cache: { userId: { token, expires } }
const _tokenCache = new Map();
async function _getKirastreamToken(user) {
const cached = _tokenCache.get(user.id);
if (cached && cached.expires > Date.now()) {
return cached.token;
}
// Find user's xtream source pointing to kirastream
let db;
try {
db = await require('../db').loadDb();
} catch (e) {
return null;
}
const src = db.sources.find(
(s) => s.ownerId === user.id && s.type === 'xtream' && s.username && s.password
);
if (!src) return null;
// POST form to /api/user/auth/token
return new Promise((resolve) => {
const body = `username=${encodeURIComponent(src.username)}&password=${encodeURIComponent(src.password)}`;
const url = new URL(`${KIRASTREAM_BASE}/api/user/auth/token`);
const options = {
hostname: url.hostname,
port: url.port || 80,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body),
},
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const json = JSON.parse(data);
if (json.access_token) {
_tokenCache.set(user.id, {
token: json.access_token,
expires: Date.now() + 25 * 60 * 1000, // 25 min
});
resolve(json.access_token);
} else {
resolve(null);
}
} catch (e) {
resolve(null);
}
});
});
req.on('error', () => resolve(null));
req.write(body);
req.end();
});
}
async function _proxyToKirastream(req, res, path, method, body) {
const token = await _getKirastreamToken(req.user);
if (!token) {
return res.status(401).json({ error: 'No se pudo autenticar con KiraStream' });
}
const url = new URL(`${KIRASTREAM_BASE}/api/user/kirabot${path}`);
const bodyStr = body ? JSON.stringify(body) : '';
const options = {
hostname: url.hostname,
port: url.port || 80,
path: url.pathname + (url.search || ''),
method: method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyStr),
},
};
return new Promise((resolve) => {
const proxyReq = http.request(options, (proxyRes) => {
let data = '';
proxyRes.on('data', (chunk) => { data += chunk; });
proxyRes.on('end', () => {
try {
const json = JSON.parse(data);
res.status(proxyRes.statusCode).json(json);
} catch (e) {
res.status(proxyRes.statusCode).send(data);
}
resolve();
});
});
proxyReq.on('error', (err) => {
res.status(502).json({ error: 'Error de conexión con KiraStream', detail: err.message });
resolve();
});
if (bodyStr) proxyReq.write(bodyStr);
proxyReq.end();
});
}
router.use(requireAuth);
router.post('/chat', async (req, res) => {
await _proxyToKirastream(req, res, '/chat', 'POST', req.body);
});
router.get('/chat/history', async (req, res) => {
await _proxyToKirastream(req, res, '/chat/history', 'GET', null);
});
router.delete('/chat/history', async (req, res) => {
await _proxyToKirastream(req, res, '/chat/history', 'DELETE', null);
});
router.get('/status', async (req, res) => {
await _proxyToKirastream(req, res, '/status', 'GET', null);
});
router.get('/recordings', async (req, res) => {
const qs = req.query.limit ? `?limit=${req.query.limit}` : '';
await _proxyToKirastream(req, res, `/recordings${qs}`, 'GET', null);
});
router.post('/recordings', async (req, res) => {
await _proxyToKirastream(req, res, '/recordings', 'POST', req.body);
});
router.delete('/recordings/:id', async (req, res) => {
await _proxyToKirastream(req, res, `/recordings/${req.params.id}`, 'DELETE', null);
});
module.exports = router;