feat: push notifications

Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
Omar Sánchez Pizarro
2026-01-19 23:27:02 +01:00
parent ed4062b350
commit 9a61f16959
8 changed files with 683 additions and 9 deletions

View File

@@ -8,6 +8,7 @@ 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);
@@ -24,6 +25,37 @@ 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');
// 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() {
@@ -585,6 +617,112 @@ app.get('/api/telegram/threads', async (req, res) => {
}
});
// 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');
@@ -684,6 +822,21 @@ async function checkForNewArticles() {
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