feat: implement user authentication and login modal, refactor backend
This commit is contained in:
@@ -1,875 +1,71 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { createServer } from 'http';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { readFileSync, writeFileSync, existsSync, statSync } from 'fs';
|
||||
import { watch } from 'chokidar';
|
||||
import yaml from 'yaml';
|
||||
import redis from 'redis';
|
||||
import webpush from 'web-push';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
// En Docker, usar PROJECT_ROOT de env, sino usar ruta relativa
|
||||
const PROJECT_ROOT = process.env.PROJECT_ROOT || join(__dirname, '../..');
|
||||
import { PATHS, SERVER } from './config/constants.js';
|
||||
import { rateLimitMiddleware } from './middlewares/rateLimit.js';
|
||||
import { initRedis } from './services/redis.js';
|
||||
import { initVAPIDKeys } from './services/webPush.js';
|
||||
import { initWebSocket } from './services/websocket.js';
|
||||
import { startArticleMonitoring } from './services/articleMonitor.js';
|
||||
import { initFileWatcher } from './services/fileWatcher.js';
|
||||
import routes from './routes/index.js';
|
||||
import workersRouter from './routes/workers.js';
|
||||
import articlesRouter from './routes/articles.js';
|
||||
import favoritesRouter from './routes/favorites.js';
|
||||
import logsRouter from './routes/logs.js';
|
||||
import configRouter from './routes/config.js';
|
||||
import telegramRouter from './routes/telegram.js';
|
||||
import pushRouter from './routes/push.js';
|
||||
import usersRouter from './routes/users.js';
|
||||
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
// Middlewares globales
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Configuración
|
||||
const CONFIG_PATH = join(PROJECT_ROOT, 'config.yaml');
|
||||
const WORKERS_PATH = join(PROJECT_ROOT, 'workers.json');
|
||||
const PUSH_SUBSCRIPTIONS_PATH = join(PROJECT_ROOT, 'push-subscriptions.json');
|
||||
// Aplicar rate limiting a todas las rutas API
|
||||
app.use('/api', rateLimitMiddleware);
|
||||
|
||||
// Inicializar VAPID keys para Web Push
|
||||
let vapidKeys = null;
|
||||
const VAPID_KEYS_PATH = join(PROJECT_ROOT, 'vapid-keys.json');
|
||||
|
||||
function initVAPIDKeys() {
|
||||
try {
|
||||
if (existsSync(VAPID_KEYS_PATH)) {
|
||||
vapidKeys = JSON.parse(readFileSync(VAPID_KEYS_PATH, 'utf8'));
|
||||
console.log('✅ VAPID keys cargadas desde archivo');
|
||||
} else {
|
||||
// Generar nuevas VAPID keys
|
||||
vapidKeys = webpush.generateVAPIDKeys();
|
||||
writeFileSync(VAPID_KEYS_PATH, JSON.stringify(vapidKeys, null, 2), 'utf8');
|
||||
console.log('✅ Nuevas VAPID keys generadas y guardadas');
|
||||
}
|
||||
|
||||
// Configurar web-push con las VAPID keys
|
||||
webpush.setVapidDetails(
|
||||
'mailto:admin@pribyte.cloud', // Contacto (puedes cambiarlo)
|
||||
vapidKeys.publicKey,
|
||||
vapidKeys.privateKey
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error inicializando VAPID keys:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Inicializar VAPID keys al arrancar
|
||||
initVAPIDKeys();
|
||||
|
||||
// Función para obtener la ruta del log (en Docker puede estar en /data/logs)
|
||||
function getLogPath() {
|
||||
const logsDirPath = join(PROJECT_ROOT, 'logs', 'monitor.log');
|
||||
const rootLogPath = join(PROJECT_ROOT, 'monitor.log');
|
||||
|
||||
if (existsSync(logsDirPath)) {
|
||||
return logsDirPath;
|
||||
}
|
||||
return rootLogPath;
|
||||
}
|
||||
// Inicializar WebSocket
|
||||
initWebSocket(server);
|
||||
|
||||
const LOG_PATH = getLogPath();
|
||||
|
||||
let redisClient = null;
|
||||
let config = null;
|
||||
|
||||
// Inicializar Redis si está configurado
|
||||
async function initRedis() {
|
||||
try {
|
||||
config = yaml.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
||||
const cacheConfig = config?.cache;
|
||||
|
||||
if (cacheConfig?.type === 'redis') {
|
||||
const redisConfig = cacheConfig.redis;
|
||||
// En Docker, usar el nombre del servicio si no se especifica host
|
||||
const redisHost = process.env.REDIS_HOST || redisConfig.host || 'localhost';
|
||||
redisClient = redis.createClient({
|
||||
socket: {
|
||||
host: redisHost,
|
||||
port: redisConfig.port || 6379,
|
||||
},
|
||||
password: redisConfig.password || undefined,
|
||||
database: redisConfig.db || 0,
|
||||
});
|
||||
|
||||
redisClient.on('error', (err) => console.error('Redis Client Error', err));
|
||||
await redisClient.connect();
|
||||
console.log('✅ Conectado a Redis');
|
||||
|
||||
// Inicializar claves conocidas para evitar notificar artículos existentes
|
||||
try {
|
||||
const initialKeys = await redisClient.keys('notified:*');
|
||||
notifiedArticleKeys = new Set(initialKeys);
|
||||
console.log(`📋 ${notifiedArticleKeys.size} artículos ya notificados detectados`);
|
||||
} catch (error) {
|
||||
console.error('Error inicializando claves de artículos:', error.message);
|
||||
}
|
||||
} else {
|
||||
console.log('ℹ️ Redis no configurado, usando modo memoria');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error inicializando Redis:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast a todos los clientes WebSocket
|
||||
function broadcast(data) {
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === 1) { // WebSocket.OPEN
|
||||
client.send(JSON.stringify(data));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Leer archivo JSON de forma segura
|
||||
function readJSON(path, defaultValue = {}) {
|
||||
try {
|
||||
if (existsSync(path)) {
|
||||
return JSON.parse(readFileSync(path, 'utf8'));
|
||||
}
|
||||
return defaultValue;
|
||||
} catch (error) {
|
||||
console.error(`Error leyendo ${path}:`, error.message);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Escribir archivo JSON
|
||||
function writeJSON(path, data) {
|
||||
try {
|
||||
writeFileSync(path, JSON.stringify(data, null, 2), 'utf8');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error escribiendo ${path}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener artículos notificados desde Redis
|
||||
async function getNotifiedArticles() {
|
||||
if (!redisClient) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const keys = await redisClient.keys('notified:*');
|
||||
const articles = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const parts = key.split(':');
|
||||
if (parts.length >= 3) {
|
||||
const platform = parts[1];
|
||||
const id = parts.slice(2).join(':');
|
||||
const ttl = await redisClient.ttl(key);
|
||||
const value = await redisClient.get(key);
|
||||
|
||||
// Intentar parsear como JSON (nuevo formato con toda la info)
|
||||
let articleData = {};
|
||||
try {
|
||||
if (value && value !== '1') {
|
||||
articleData = JSON.parse(value);
|
||||
}
|
||||
} catch (e) {
|
||||
// Si no es JSON válido, usar valor por defecto
|
||||
}
|
||||
|
||||
articles.push({
|
||||
platform: articleData.platform || platform,
|
||||
id: articleData.id || id,
|
||||
title: articleData.title || null,
|
||||
description: articleData.description || null,
|
||||
price: articleData.price || null,
|
||||
currency: articleData.currency || null,
|
||||
location: articleData.location || null,
|
||||
allows_shipping: articleData.allows_shipping !== undefined ? articleData.allows_shipping : null,
|
||||
url: articleData.url || null,
|
||||
images: articleData.images || [],
|
||||
modified_at: articleData.modified_at || null,
|
||||
notifiedAt: Date.now() - (7 * 24 * 60 * 60 - ttl) * 1000,
|
||||
expiresAt: Date.now() + ttl * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return articles;
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo artículos de Redis:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// API Routes
|
||||
|
||||
// Obtener favoritos desde Redis
|
||||
async function getFavorites() {
|
||||
if (!redisClient) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const keys = await redisClient.keys('notified:*');
|
||||
const favorites = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const value = await redisClient.get(key);
|
||||
if (value) {
|
||||
try {
|
||||
const articleData = JSON.parse(value);
|
||||
if (articleData.is_favorite === true) {
|
||||
favorites.push(articleData);
|
||||
}
|
||||
} catch (e) {
|
||||
// Si no es JSON válido, ignorar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return favorites;
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo favoritos de Redis:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener estadísticas
|
||||
app.get('/api/stats', async (req, res) => {
|
||||
try {
|
||||
const workers = readJSON(WORKERS_PATH, { items: [] });
|
||||
const favorites = await getFavorites();
|
||||
const notifiedArticles = await getNotifiedArticles();
|
||||
|
||||
const stats = {
|
||||
totalWorkers: workers.items?.length || 0,
|
||||
activeWorkers: (workers.items || []).filter(w => !workers.disabled?.includes(w.name)).length,
|
||||
totalFavorites: favorites.length,
|
||||
totalNotified: notifiedArticles.length,
|
||||
platforms: {
|
||||
wallapop: notifiedArticles.filter(a => a.platform === 'wallapop').length,
|
||||
vinted: notifiedArticles.filter(a => a.platform === 'vinted').length,
|
||||
},
|
||||
};
|
||||
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Obtener workers
|
||||
app.get('/api/workers', (req, res) => {
|
||||
try {
|
||||
const workers = readJSON(WORKERS_PATH, { items: [], general: {}, disabled: [] });
|
||||
res.json(workers);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Actualizar workers
|
||||
app.put('/api/workers', (req, res) => {
|
||||
try {
|
||||
const workers = req.body;
|
||||
if (writeJSON(WORKERS_PATH, workers)) {
|
||||
broadcast({ type: 'workers_updated', data: workers });
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(500).json({ error: 'Error guardando workers' });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Obtener favoritos
|
||||
app.get('/api/favorites', async (req, res) => {
|
||||
try {
|
||||
const favorites = await getFavorites();
|
||||
res.json(favorites);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Añadir favorito
|
||||
app.post('/api/favorites', async (req, res) => {
|
||||
try {
|
||||
if (!redisClient) {
|
||||
return res.status(500).json({ error: 'Redis no está disponible' });
|
||||
}
|
||||
|
||||
const { platform, id } = req.body;
|
||||
if (!platform || !id) {
|
||||
return res.status(400).json({ error: 'platform e id son requeridos' });
|
||||
}
|
||||
|
||||
const key = `notified:${platform}:${id}`;
|
||||
const value = await redisClient.get(key);
|
||||
|
||||
if (!value) {
|
||||
return res.status(404).json({ error: 'Artículo no encontrado' });
|
||||
}
|
||||
|
||||
try {
|
||||
const articleData = JSON.parse(value);
|
||||
articleData.is_favorite = true;
|
||||
// Mantener el TTL existente
|
||||
const ttl = await redisClient.ttl(key);
|
||||
if (ttl > 0) {
|
||||
await redisClient.setex(key, ttl, JSON.stringify(articleData));
|
||||
} else {
|
||||
await redisClient.set(key, JSON.stringify(articleData));
|
||||
}
|
||||
|
||||
const favorites = await getFavorites();
|
||||
broadcast({ type: 'favorites_updated', data: favorites });
|
||||
res.json({ success: true, favorites });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Error procesando artículo' });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Eliminar favorito
|
||||
app.delete('/api/favorites/:platform/:id', async (req, res) => {
|
||||
try {
|
||||
if (!redisClient) {
|
||||
return res.status(500).json({ error: 'Redis no está disponible' });
|
||||
}
|
||||
|
||||
const { platform, id } = req.params;
|
||||
const key = `notified:${platform}:${id}`;
|
||||
const value = await redisClient.get(key);
|
||||
|
||||
if (!value) {
|
||||
return res.status(404).json({ error: 'Artículo no encontrado' });
|
||||
}
|
||||
|
||||
try {
|
||||
const articleData = JSON.parse(value);
|
||||
articleData.is_favorite = false;
|
||||
// Mantener el TTL existente
|
||||
const ttl = await redisClient.ttl(key);
|
||||
if (ttl > 0) {
|
||||
await redisClient.setex(key, ttl, JSON.stringify(articleData));
|
||||
} else {
|
||||
await redisClient.set(key, JSON.stringify(articleData));
|
||||
}
|
||||
|
||||
const favorites = await getFavorites();
|
||||
broadcast({ type: 'favorites_updated', data: favorites });
|
||||
res.json({ success: true, favorites });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Error procesando artículo' });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Limpiar toda la caché de Redis
|
||||
app.delete('/api/cache', async (req, res) => {
|
||||
try {
|
||||
if (!redisClient) {
|
||||
return res.status(500).json({ error: 'Redis no está disponible' });
|
||||
}
|
||||
|
||||
// Obtener todas las claves que empiezan con 'notified:'
|
||||
const keys = await redisClient.keys('notified:*');
|
||||
|
||||
if (!keys || keys.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Cache ya está vacío',
|
||||
count: 0
|
||||
});
|
||||
}
|
||||
|
||||
// Eliminar todas las claves
|
||||
const count = keys.length;
|
||||
for (const key of keys) {
|
||||
await redisClient.del(key);
|
||||
}
|
||||
|
||||
// Notificar a los clientes WebSocket
|
||||
broadcast({
|
||||
type: 'cache_cleared',
|
||||
data: { count, timestamp: Date.now() }
|
||||
});
|
||||
|
||||
// También actualizar favoritos (debería estar vacío ahora)
|
||||
const favorites = await getFavorites();
|
||||
broadcast({ type: 'favorites_updated', data: favorites });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Cache limpiado: ${count} artículos eliminados`,
|
||||
count
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error limpiando cache de Redis:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Obtener artículos notificados
|
||||
app.get('/api/articles', async (req, res) => {
|
||||
try {
|
||||
const articles = await getNotifiedArticles();
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
const offset = parseInt(req.query.offset) || 0;
|
||||
|
||||
const sorted = articles.sort((a, b) => b.notifiedAt - a.notifiedAt);
|
||||
const paginated = sorted.slice(offset, offset + limit);
|
||||
|
||||
res.json({
|
||||
articles: paginated,
|
||||
total: articles.length,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Buscar artículos en Redis
|
||||
app.get('/api/articles/search', async (req, res) => {
|
||||
try {
|
||||
const query = req.query.q || '';
|
||||
if (!query.trim()) {
|
||||
return res.json({ articles: [], total: 0 });
|
||||
}
|
||||
|
||||
const searchTerm = query.toLowerCase().trim();
|
||||
const allArticles = await getNotifiedArticles();
|
||||
|
||||
// Filtrar artículos que coincidan con la búsqueda
|
||||
const filtered = allArticles.filter(article => {
|
||||
// Buscar en título
|
||||
const title = (article.title || '').toLowerCase();
|
||||
if (title.includes(searchTerm)) return true;
|
||||
|
||||
// Buscar en descripción
|
||||
const description = (article.description || '').toLowerCase();
|
||||
if (description.includes(searchTerm)) return true;
|
||||
|
||||
// Buscar en localidad
|
||||
const location = (article.location || '').toLowerCase();
|
||||
if (location.includes(searchTerm)) return true;
|
||||
|
||||
// Buscar en precio (como número o texto)
|
||||
const price = String(article.price || '').toLowerCase();
|
||||
if (price.includes(searchTerm)) return true;
|
||||
|
||||
// Buscar en plataforma
|
||||
const platform = (article.platform || '').toLowerCase();
|
||||
if (platform.includes(searchTerm)) return true;
|
||||
|
||||
// Buscar en ID
|
||||
const id = String(article.id || '').toLowerCase();
|
||||
if (id.includes(searchTerm)) return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// Ordenar por fecha de notificación (más recientes primero)
|
||||
const sorted = filtered.sort((a, b) => b.notifiedAt - a.notifiedAt);
|
||||
|
||||
res.json({
|
||||
articles: sorted,
|
||||
total: sorted.length,
|
||||
query: query,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Obtener logs (últimas líneas o nuevas líneas desde un número de línea)
|
||||
app.get('/api/logs', (req, res) => {
|
||||
try {
|
||||
// Intentar múltiples ubicaciones posibles
|
||||
let logFile = LOG_PATH;
|
||||
if (!existsSync(logFile)) {
|
||||
// Intentar en el directorio de logs
|
||||
const altPath = join(PROJECT_ROOT, 'logs', 'monitor.log');
|
||||
if (existsSync(altPath)) {
|
||||
logFile = altPath;
|
||||
} else {
|
||||
return res.json({ logs: [], totalLines: 0, lastLineNumber: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar que no sea un directorio
|
||||
try {
|
||||
const stats = statSync(logFile);
|
||||
if (stats.isDirectory()) {
|
||||
return res.json({ logs: ['Error: monitor.log es un directorio. Por favor, elimínalo y reinicia.'], totalLines: 0, lastLineNumber: 0 });
|
||||
}
|
||||
} catch (e) {
|
||||
return res.json({ logs: [], totalLines: 0, lastLineNumber: 0 });
|
||||
}
|
||||
|
||||
const logsContent = readFileSync(logFile, 'utf8');
|
||||
const allLines = logsContent.split('\n').filter(l => l.trim());
|
||||
const totalLines = allLines.length;
|
||||
|
||||
// Si se proporciona since (número de línea desde el que empezar), devolver solo las nuevas
|
||||
const sinceLine = parseInt(req.query.since) || 0;
|
||||
|
||||
if (sinceLine > 0 && sinceLine < totalLines) {
|
||||
// Devolver solo las líneas nuevas después de sinceLine
|
||||
const newLines = allLines.slice(sinceLine);
|
||||
return res.json({
|
||||
logs: newLines,
|
||||
totalLines: totalLines,
|
||||
lastLineNumber: totalLines - 1 // Índice de la última línea
|
||||
});
|
||||
} else {
|
||||
// Carga inicial: devolver las últimas líneas
|
||||
const limit = parseInt(req.query.limit) || 500;
|
||||
const lastLines = allLines.slice(-limit);
|
||||
return res.json({
|
||||
logs: lastLines,
|
||||
totalLines: totalLines,
|
||||
lastLineNumber: totalLines - 1 // Índice de la última línea
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Obtener configuración
|
||||
app.get('/api/config', (req, res) => {
|
||||
try {
|
||||
if (!config) {
|
||||
config = yaml.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
||||
}
|
||||
// No enviar token por seguridad
|
||||
const safeConfig = { ...config };
|
||||
if (safeConfig.telegram_token) {
|
||||
safeConfig.telegram_token = '***';
|
||||
}
|
||||
res.json(safeConfig);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Obtener threads/topics de Telegram
|
||||
app.get('/api/telegram/threads', async (req, res) => {
|
||||
try {
|
||||
if (!config) {
|
||||
config = yaml.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
||||
}
|
||||
|
||||
const token = config?.telegram_token;
|
||||
const channel = config?.telegram_channel;
|
||||
|
||||
if (!token || !channel) {
|
||||
return res.status(400).json({ error: 'Token o canal de Telegram no configurados' });
|
||||
}
|
||||
|
||||
// Convertir el canal a chat_id si es necesario
|
||||
let chatId = channel;
|
||||
if (channel.startsWith('@')) {
|
||||
// Para canales con @, necesitamos obtener el chat_id primero
|
||||
const getChatUrl = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(channel)}`;
|
||||
const chatResponse = await fetch(getChatUrl);
|
||||
const chatData = await chatResponse.json();
|
||||
|
||||
if (!chatData.ok) {
|
||||
return res.status(400).json({ error: `Error obteniendo chat: ${chatData.description || 'Chat no encontrado'}` });
|
||||
}
|
||||
|
||||
chatId = chatData.result.id;
|
||||
}
|
||||
|
||||
// Intentar obtener forum topics
|
||||
const forumTopicsUrl = `https://api.telegram.org/bot${token}/getForumTopics?chat_id=${chatId}&limit=100`;
|
||||
const topicsResponse = await fetch(forumTopicsUrl);
|
||||
const topicsData = await topicsResponse.json();
|
||||
|
||||
if (topicsData.ok && topicsData.result?.topics) {
|
||||
const threads = topicsData.result.topics.map(topic => ({
|
||||
id: topic.message_thread_id,
|
||||
name: topic.name || `Thread ${topic.message_thread_id}`,
|
||||
icon_color: topic.icon_color,
|
||||
icon_custom_emoji_id: topic.icon_custom_emoji_id,
|
||||
}));
|
||||
|
||||
return res.json({ threads, success: true });
|
||||
} else {
|
||||
// Si no hay forum topics, devolver un mensaje informativo
|
||||
return res.json({
|
||||
threads: [],
|
||||
success: false,
|
||||
message: 'El chat no tiene forum topics habilitados o no se pudieron obtener. Puedes obtener el Thread ID manualmente copiando el enlace del tema.',
|
||||
info: 'Para obtener el Thread ID manualmente: 1. Haz clic derecho en el tema/hilo en Telegram 2. Selecciona "Copiar enlace del tema" 3. El número al final de la URL es el Thread ID (ej: t.me/c/1234567890/8 → Thread ID = 8)'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo threads de Telegram:', error.message);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Obtener suscripciones push guardadas
|
||||
function getPushSubscriptions() {
|
||||
return readJSON(PUSH_SUBSCRIPTIONS_PATH, []);
|
||||
}
|
||||
|
||||
// Guardar suscripciones push
|
||||
function savePushSubscriptions(subscriptions) {
|
||||
return writeJSON(PUSH_SUBSCRIPTIONS_PATH, subscriptions);
|
||||
}
|
||||
|
||||
// API Routes para Push Notifications
|
||||
|
||||
// Obtener clave pública VAPID
|
||||
app.get('/api/push/public-key', (req, res) => {
|
||||
if (!vapidKeys || !vapidKeys.publicKey) {
|
||||
return res.status(500).json({ error: 'VAPID keys no están configuradas' });
|
||||
}
|
||||
res.json({ publicKey: vapidKeys.publicKey });
|
||||
});
|
||||
|
||||
// Suscribirse a notificaciones push
|
||||
app.post('/api/push/subscribe', async (req, res) => {
|
||||
try {
|
||||
const subscription = req.body;
|
||||
|
||||
if (!subscription || !subscription.endpoint) {
|
||||
return res.status(400).json({ error: 'Suscripción inválida' });
|
||||
}
|
||||
|
||||
const subscriptions = getPushSubscriptions();
|
||||
|
||||
// Verificar si ya existe esta suscripción
|
||||
const existingIndex = subscriptions.findIndex(
|
||||
sub => sub.endpoint === subscription.endpoint
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
subscriptions[existingIndex] = subscription;
|
||||
} else {
|
||||
subscriptions.push(subscription);
|
||||
}
|
||||
|
||||
savePushSubscriptions(subscriptions);
|
||||
console.log(`✅ Nueva suscripción push guardada. Total: ${subscriptions.length}`);
|
||||
|
||||
res.json({ success: true, totalSubscriptions: subscriptions.length });
|
||||
} catch (error) {
|
||||
console.error('Error guardando suscripción push:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Cancelar suscripción push
|
||||
app.post('/api/push/unsubscribe', async (req, res) => {
|
||||
try {
|
||||
const subscription = req.body;
|
||||
|
||||
if (!subscription || !subscription.endpoint) {
|
||||
return res.status(400).json({ error: 'Suscripción inválida' });
|
||||
}
|
||||
|
||||
const subscriptions = getPushSubscriptions();
|
||||
const filtered = subscriptions.filter(
|
||||
sub => sub.endpoint !== subscription.endpoint
|
||||
);
|
||||
|
||||
savePushSubscriptions(filtered);
|
||||
console.log(`✅ Suscripción push cancelada. Total: ${filtered.length}`);
|
||||
|
||||
res.json({ success: true, totalSubscriptions: filtered.length });
|
||||
} catch (error) {
|
||||
console.error('Error cancelando suscripción push:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Enviar notificación push a todas las suscripciones
|
||||
async function sendPushNotifications(notificationData) {
|
||||
const subscriptions = getPushSubscriptions();
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify(notificationData);
|
||||
const promises = subscriptions.map(async (subscription) => {
|
||||
try {
|
||||
await webpush.sendNotification(subscription, payload);
|
||||
console.log('✅ Notificación push enviada');
|
||||
} catch (error) {
|
||||
console.error('Error enviando notificación push:', error);
|
||||
|
||||
// Si la suscripción es inválida (404, 410), eliminarla
|
||||
if (error.statusCode === 404 || error.statusCode === 410) {
|
||||
const updatedSubscriptions = getPushSubscriptions().filter(
|
||||
sub => sub.endpoint !== subscription.endpoint
|
||||
);
|
||||
savePushSubscriptions(updatedSubscriptions);
|
||||
console.log(`Suscripción inválida eliminada. Total: ${updatedSubscriptions.length}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
// WebSocket connection
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('Cliente WebSocket conectado');
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('Cliente WebSocket desconectado');
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('Error WebSocket:', error);
|
||||
});
|
||||
});
|
||||
|
||||
// Determinar la ruta del log para el watcher
|
||||
let watchLogPath = LOG_PATH;
|
||||
if (!existsSync(watchLogPath)) {
|
||||
const altPath = join(PROJECT_ROOT, 'logs', 'monitor.log');
|
||||
if (existsSync(altPath)) {
|
||||
watchLogPath = altPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Watch files for changes (ya no vigilamos logs porque usa polling)
|
||||
const watcher = watch([WORKERS_PATH].filter(p => existsSync(p)), {
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
});
|
||||
|
||||
watcher.on('change', async (path) => {
|
||||
console.log(`Archivo cambiado: ${path}`);
|
||||
if (path === WORKERS_PATH) {
|
||||
const workers = readJSON(WORKERS_PATH);
|
||||
broadcast({ type: 'workers_updated', data: workers });
|
||||
}
|
||||
});
|
||||
|
||||
// Rastrear artículos ya notificados para detectar nuevos
|
||||
let notifiedArticleKeys = new Set();
|
||||
let articlesCheckInterval = null;
|
||||
|
||||
// Función para detectar y enviar artículos nuevos
|
||||
async function checkForNewArticles() {
|
||||
if (!redisClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentKeys = await redisClient.keys('notified:*');
|
||||
const currentKeysSet = new Set(currentKeys);
|
||||
|
||||
// Encontrar claves nuevas
|
||||
const newKeys = currentKeys.filter(key => !notifiedArticleKeys.has(key));
|
||||
|
||||
if (newKeys.length > 0) {
|
||||
// Obtener los artículos nuevos
|
||||
const newArticles = [];
|
||||
for (const key of newKeys) {
|
||||
try {
|
||||
const value = await redisClient.get(key);
|
||||
if (value) {
|
||||
// Intentar parsear como JSON
|
||||
let articleData = {};
|
||||
try {
|
||||
articleData = JSON.parse(value);
|
||||
} catch (e) {
|
||||
// Si no es JSON válido, extraer información de la key
|
||||
const parts = key.split(':');
|
||||
if (parts.length >= 3) {
|
||||
articleData = {
|
||||
platform: parts[1],
|
||||
id: parts.slice(2).join(':'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Añadir información adicional si está disponible
|
||||
if (articleData.platform && articleData.id) {
|
||||
newArticles.push({
|
||||
platform: articleData.platform || 'unknown',
|
||||
id: articleData.id || 'unknown',
|
||||
title: articleData.title || null,
|
||||
price: articleData.price || null,
|
||||
currency: articleData.currency || '€',
|
||||
url: articleData.url || null,
|
||||
images: articleData.images || [],
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error obteniendo artículo de Redis (${key}):`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Enviar artículos nuevos por WebSocket
|
||||
if (newArticles.length > 0) {
|
||||
broadcast({
|
||||
type: 'new_articles',
|
||||
data: newArticles
|
||||
});
|
||||
|
||||
// Enviar notificaciones push para cada artículo nuevo
|
||||
for (const article of newArticles) {
|
||||
await sendPushNotifications({
|
||||
title: `Nuevo artículo en ${article.platform?.toUpperCase() || 'Wallabicher'}`,
|
||||
body: article.title || 'Artículo nuevo disponible',
|
||||
icon: '/android-chrome-192x192.png',
|
||||
image: article.images?.[0] || null,
|
||||
url: article.url || '/',
|
||||
platform: article.platform,
|
||||
price: article.price,
|
||||
currency: article.currency || '€',
|
||||
id: article.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Actualizar el set de claves notificadas
|
||||
notifiedArticleKeys = currentKeysSet;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error verificando artículos nuevos:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Inicializar el check de artículos cuando Redis esté listo
|
||||
async function startArticleMonitoring() {
|
||||
if (redisClient) {
|
||||
// Iniciar intervalo para verificar nuevos artículos cada 3 segundos
|
||||
articlesCheckInterval = setInterval(checkForNewArticles, 3000);
|
||||
console.log('✅ Monitoreo de artículos nuevos iniciado');
|
||||
}
|
||||
}
|
||||
// Rutas API
|
||||
app.use('/api', routes);
|
||||
app.use('/api/workers', workersRouter);
|
||||
app.use('/api/articles', articlesRouter);
|
||||
app.use('/api/favorites', favoritesRouter);
|
||||
app.use('/api/logs', logsRouter);
|
||||
app.use('/api/config', configRouter);
|
||||
app.use('/api/telegram', telegramRouter);
|
||||
app.use('/api/push', pushRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
|
||||
// Inicializar servidor
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
async function startServer() {
|
||||
await initRedis();
|
||||
|
||||
// Iniciar monitoreo de artículos nuevos
|
||||
await startArticleMonitoring();
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`🚀 Servidor backend ejecutándose en http://localhost:${PORT}`);
|
||||
console.log(`📡 WebSocket disponible en ws://localhost:${PORT}`);
|
||||
});
|
||||
try {
|
||||
// Inicializar Redis
|
||||
await initRedis();
|
||||
|
||||
// Iniciar monitoreo de artículos nuevos
|
||||
await startArticleMonitoring();
|
||||
|
||||
// Inicializar file watcher
|
||||
initFileWatcher();
|
||||
|
||||
// Iniciar servidor HTTP
|
||||
server.listen(SERVER.PORT, () => {
|
||||
console.log(`🚀 Servidor backend ejecutándose en http://localhost:${SERVER.PORT}`);
|
||||
console.log(`📡 WebSocket disponible en ws://localhost:${SERVER.PORT}/ws`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error al iniciar el servidor:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
startServer().catch(console.error);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user