feat: push notifications
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
145
web/backend/package-lock.json
generated
145
web/backend/package-lock.json
generated
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "wallabicher-backend",
|
||||
"name": "wallabicher-backend",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
@@ -13,6 +13,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"redis": "^4.6.10",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.14.2",
|
||||
"yaml": "^2.3.4"
|
||||
}
|
||||
@@ -89,6 +90,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
@@ -108,6 +118,18 @@
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -120,6 +142,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.2",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
||||
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
@@ -156,6 +184,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -318,6 +352,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -589,6 +632,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -609,6 +661,42 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
@@ -678,6 +766,27 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -747,6 +856,21 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
@@ -1131,6 +1255,25 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
|
||||
@@ -8,16 +8,20 @@
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js"
|
||||
},
|
||||
"keywords": ["wallabicher", "api", "express"],
|
||||
"keywords": [
|
||||
"wallabicher",
|
||||
"api",
|
||||
"express"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"chokidar": "^3.5.3",
|
||||
"cors": "^2.8.5",
|
||||
"ws": "^8.14.2",
|
||||
"express": "^4.18.2",
|
||||
"redis": "^4.6.10",
|
||||
"yaml": "^2.3.4",
|
||||
"chokidar": "^3.5.3"
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.14.2",
|
||||
"yaml": "^2.3.4"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user