feat: add ntfy and telegram notification workflows
This commit is contained in:
@@ -16,7 +16,20 @@ const DEFAULT_SETTINGS = {
|
||||
storeId: '51450',
|
||||
description: 'TVS'
|
||||
}
|
||||
]
|
||||
],
|
||||
notifications: {
|
||||
ntfy: {
|
||||
enabled: false,
|
||||
serverUrl: 'https://ntfy.sh',
|
||||
topicPrefix: '',
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
telegram: {
|
||||
enabled: false,
|
||||
botToken: ''
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function ensureDir() {
|
||||
@@ -45,6 +58,33 @@ function sanitizeIgnoredSlots(slots = []) {
|
||||
.filter((slot) => slot.storeId);
|
||||
}
|
||||
|
||||
function sanitizeString(value) {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim();
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function sanitizeNotifications(input = {}) {
|
||||
const defaults = DEFAULT_SETTINGS.notifications;
|
||||
return {
|
||||
ntfy: {
|
||||
enabled: !!(input?.ntfy?.enabled),
|
||||
serverUrl: sanitizeString(input?.ntfy?.serverUrl || defaults.ntfy.serverUrl),
|
||||
topicPrefix: sanitizeString(input?.ntfy?.topicPrefix || defaults.ntfy.topicPrefix),
|
||||
username: sanitizeString(input?.ntfy?.username || defaults.ntfy.username),
|
||||
password: sanitizeString(input?.ntfy?.password || defaults.ntfy.password)
|
||||
},
|
||||
telegram: {
|
||||
enabled: !!(input?.telegram?.enabled),
|
||||
botToken: sanitizeString(input?.telegram?.botToken || defaults.telegram.botToken)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function readSettings() {
|
||||
ensureDir();
|
||||
if (!fs.existsSync(SETTINGS_FILE)) {
|
||||
@@ -65,7 +105,8 @@ function readSettings() {
|
||||
parsed.storePickupCheckDelayMs,
|
||||
DEFAULT_SETTINGS.storePickupCheckDelayMs
|
||||
),
|
||||
ignoredSlots: sanitizeIgnoredSlots(parsed.ignoredSlots)
|
||||
ignoredSlots: sanitizeIgnoredSlots(parsed.ignoredSlots),
|
||||
notifications: sanitizeNotifications(parsed.notifications)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Konnte Admin-Einstellungen nicht lesen:', error.message);
|
||||
@@ -88,7 +129,11 @@ function writeSettings(patch = {}) {
|
||||
ignoredSlots:
|
||||
patch.ignoredSlots !== undefined
|
||||
? sanitizeIgnoredSlots(patch.ignoredSlots)
|
||||
: current.ignoredSlots
|
||||
: current.ignoredSlots,
|
||||
notifications:
|
||||
patch.notifications !== undefined
|
||||
? sanitizeNotifications(patch.notifications)
|
||||
: current.notifications
|
||||
};
|
||||
|
||||
ensureDir();
|
||||
|
||||
128
services/notificationService.js
Normal file
128
services/notificationService.js
Normal file
@@ -0,0 +1,128 @@
|
||||
const axios = require('axios');
|
||||
const adminConfig = require('./adminConfig');
|
||||
const { readNotificationSettings } = require('./userSettingsStore');
|
||||
|
||||
function formatDateLabel(dateInput) {
|
||||
try {
|
||||
const date = new Date(dateInput);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'unbekanntes Datum';
|
||||
}
|
||||
return date.toLocaleString('de-DE', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch (_error) {
|
||||
return 'unbekanntes Datum';
|
||||
}
|
||||
}
|
||||
|
||||
async function sendNtfyNotification(adminNtfy, userNtfy, payload) {
|
||||
if (!adminNtfy?.enabled || !userNtfy?.enabled || !userNtfy.topic) {
|
||||
return;
|
||||
}
|
||||
const server = (userNtfy.serverUrl || adminNtfy.serverUrl || 'https://ntfy.sh').replace(/\/+$/, '');
|
||||
const topicPrefix = adminNtfy.topicPrefix ? `${adminNtfy.topicPrefix.replace(/\/+$/, '')}/` : '';
|
||||
const topic = `${topicPrefix}${userNtfy.topic.replace(/^\//, '')}`;
|
||||
const url = `${server}/${topic}`;
|
||||
const headers = {
|
||||
Title: payload.title,
|
||||
Priority: payload.priority || 'default'
|
||||
};
|
||||
if (adminNtfy.username && adminNtfy.password) {
|
||||
const credentials = Buffer.from(`${adminNtfy.username}:${adminNtfy.password}`).toString('base64');
|
||||
headers.Authorization = `Basic ${credentials}`;
|
||||
}
|
||||
await axios.post(url, payload.message, { headers, timeout: 15000 });
|
||||
}
|
||||
|
||||
async function sendTelegramNotification(adminTelegram, userTelegram, payload) {
|
||||
if (!adminTelegram?.enabled || !adminTelegram.botToken || !userTelegram?.enabled || !userTelegram.chatId) {
|
||||
return;
|
||||
}
|
||||
const endpoint = `https://api.telegram.org/bot${adminTelegram.botToken}/sendMessage`;
|
||||
await axios.post(
|
||||
endpoint,
|
||||
{
|
||||
chat_id: userTelegram.chatId,
|
||||
text: `${payload.title}\n${payload.message}`,
|
||||
disable_web_page_preview: true
|
||||
},
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function notifyChannels(profileId, template) {
|
||||
const adminSettings = adminConfig.readSettings();
|
||||
const userSettings = readNotificationSettings(profileId);
|
||||
try {
|
||||
await Promise.allSettled([
|
||||
sendNtfyNotification(adminSettings.notifications?.ntfy, userSettings.notifications?.ntfy, template),
|
||||
sendTelegramNotification(adminSettings.notifications?.telegram, userSettings.notifications?.telegram, template)
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('[NOTIFICATIONS] Versand fehlgeschlagen:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendSlotNotification({ profileId, storeName, pickupDate, onlyNotify, booked }) {
|
||||
const dateLabel = formatDateLabel(pickupDate);
|
||||
const title = onlyNotify
|
||||
? `Slot verfügbar bei ${storeName}`
|
||||
: booked
|
||||
? `Slot gebucht bei ${storeName}`
|
||||
: `Slot gefunden bei ${storeName}`;
|
||||
const message = onlyNotify
|
||||
? `Es wurde ein freier Slot am ${dateLabel} entdeckt.`
|
||||
: booked
|
||||
? `Der Slot am ${dateLabel} wurde erfolgreich gebucht.`
|
||||
: `Es wurde ein Slot am ${dateLabel} gefunden.`;
|
||||
|
||||
await notifyChannels(profileId, {
|
||||
title,
|
||||
message,
|
||||
priority: booked ? 'high' : 'default'
|
||||
});
|
||||
}
|
||||
|
||||
async function sendTestNotification(profileId, channel) {
|
||||
const title = 'Pickup Benachrichtigung (Test)';
|
||||
const message = 'Das ist eine Testnachricht. Bei Fragen wende dich bitte an den Admin.';
|
||||
const adminSettings = adminConfig.readSettings();
|
||||
const userSettings = readNotificationSettings(profileId);
|
||||
const tasks = [];
|
||||
|
||||
if (!channel || channel === 'ntfy') {
|
||||
tasks.push(
|
||||
sendNtfyNotification(adminSettings.notifications?.ntfy, userSettings.notifications?.ntfy, {
|
||||
title,
|
||||
message,
|
||||
priority: 'default'
|
||||
})
|
||||
);
|
||||
}
|
||||
if (!channel || channel === 'telegram') {
|
||||
tasks.push(
|
||||
sendTelegramNotification(adminSettings.notifications?.telegram, userSettings.notifications?.telegram, {
|
||||
title,
|
||||
message,
|
||||
priority: 'default'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
throw new Error('Kein unterstützter Kanal oder Kanal deaktiviert.');
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendSlotNotification,
|
||||
sendTestNotification
|
||||
};
|
||||
@@ -2,6 +2,7 @@ const cron = require('node-cron');
|
||||
const foodsharingClient = require('./foodsharingClient');
|
||||
const sessionStore = require('./sessionStore');
|
||||
const { DEFAULT_SETTINGS } = require('./adminConfig');
|
||||
const notificationService = require('./notificationService');
|
||||
|
||||
const weekdayMap = {
|
||||
Montag: 'Monday',
|
||||
@@ -37,7 +38,20 @@ function resolveSettings(settings) {
|
||||
initialDelayMaxSeconds: Number.isFinite(settings.initialDelayMaxSeconds)
|
||||
? settings.initialDelayMaxSeconds
|
||||
: DEFAULT_SETTINGS.initialDelayMaxSeconds,
|
||||
ignoredSlots: Array.isArray(settings.ignoredSlots) ? settings.ignoredSlots : DEFAULT_SETTINGS.ignoredSlots
|
||||
ignoredSlots: Array.isArray(settings.ignoredSlots) ? settings.ignoredSlots : DEFAULT_SETTINGS.ignoredSlots,
|
||||
notifications: {
|
||||
ntfy: {
|
||||
enabled: !!settings.notifications?.ntfy?.enabled,
|
||||
serverUrl: settings.notifications?.ntfy?.serverUrl || DEFAULT_SETTINGS.notifications.ntfy.serverUrl,
|
||||
topicPrefix: settings.notifications?.ntfy?.topicPrefix || DEFAULT_SETTINGS.notifications.ntfy.topicPrefix,
|
||||
username: settings.notifications?.ntfy?.username || DEFAULT_SETTINGS.notifications.ntfy.username,
|
||||
password: settings.notifications?.ntfy?.password || DEFAULT_SETTINGS.notifications.ntfy.password
|
||||
},
|
||||
telegram: {
|
||||
enabled: !!settings.notifications?.telegram?.enabled,
|
||||
botToken: settings.notifications?.telegram?.botToken || DEFAULT_SETTINGS.notifications.telegram.botToken
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,6 +161,13 @@ async function processBooking(session, entry, pickup) {
|
||||
|
||||
if (entry.onlyNotify) {
|
||||
console.log(`[INFO] Slot gefunden (nur Hinweis) für ${storeName} am ${readableDate}`);
|
||||
await notificationService.sendSlotNotification({
|
||||
profileId: session.profile.id,
|
||||
storeName,
|
||||
pickupDate: pickup.date,
|
||||
onlyNotify: true,
|
||||
booked: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -159,6 +180,13 @@ async function processBooking(session, entry, pickup) {
|
||||
}
|
||||
await foodsharingClient.bookSlot(entry.id, utcDate, session.profile.id, session);
|
||||
console.log(`[SUCCESS] Slot gebucht für ${storeName} am ${readableDate}`);
|
||||
await notificationService.sendSlotNotification({
|
||||
profileId: session.profile.id,
|
||||
storeName,
|
||||
pickupDate: pickup.date,
|
||||
onlyNotify: false,
|
||||
booked: true
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[ERROR] Buchung für ${storeName} am ${readableDate} fehlgeschlagen:`, error.message);
|
||||
}
|
||||
|
||||
108
services/userSettingsStore.js
Normal file
108
services/userSettingsStore.js
Normal file
@@ -0,0 +1,108 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SETTINGS_DIR = path.join(__dirname, '..', 'config');
|
||||
|
||||
const DEFAULT_USER_SETTINGS = {
|
||||
notifications: {
|
||||
ntfy: {
|
||||
enabled: false,
|
||||
topic: '',
|
||||
serverUrl: ''
|
||||
},
|
||||
telegram: {
|
||||
enabled: false,
|
||||
chatId: ''
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function ensureDir() {
|
||||
if (!fs.existsSync(SETTINGS_DIR)) {
|
||||
fs.mkdirSync(SETTINGS_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function getSettingsPath(profileId = 'shared') {
|
||||
return path.join(SETTINGS_DIR, `${profileId}-notifications.json`);
|
||||
}
|
||||
|
||||
function sanitizeBoolean(value) {
|
||||
return !!value;
|
||||
}
|
||||
|
||||
function sanitizeString(value) {
|
||||
if (typeof value !== 'string') {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function hydrateSettingsFile(profileId) {
|
||||
ensureDir();
|
||||
const filePath = getSettingsPath(profileId);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, JSON.stringify(DEFAULT_USER_SETTINGS, null, 2));
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function readNotificationSettings(profileId) {
|
||||
const filePath = hydrateSettingsFile(profileId);
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
notifications: {
|
||||
ntfy: {
|
||||
enabled: sanitizeBoolean(parsed?.notifications?.ntfy?.enabled),
|
||||
topic: sanitizeString(parsed?.notifications?.ntfy?.topic),
|
||||
serverUrl: sanitizeString(parsed?.notifications?.ntfy?.serverUrl)
|
||||
},
|
||||
telegram: {
|
||||
enabled: sanitizeBoolean(parsed?.notifications?.telegram?.enabled),
|
||||
chatId: sanitizeString(parsed?.notifications?.telegram?.chatId)
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[NOTIFICATIONS] Konnte Einstellungen für ${profileId} nicht lesen:`, error.message);
|
||||
return JSON.parse(JSON.stringify(DEFAULT_USER_SETTINGS));
|
||||
}
|
||||
}
|
||||
|
||||
function writeNotificationSettings(profileId, patch = {}) {
|
||||
const current = readNotificationSettings(profileId);
|
||||
const next = {
|
||||
notifications: {
|
||||
ntfy: {
|
||||
enabled: sanitizeBoolean(patch?.notifications?.ntfy?.enabled ?? current.notifications.ntfy.enabled),
|
||||
topic: sanitizeString(patch?.notifications?.ntfy?.topic ?? current.notifications.ntfy.topic),
|
||||
serverUrl: sanitizeString(
|
||||
patch?.notifications?.ntfy?.serverUrl ?? current.notifications.ntfy.serverUrl
|
||||
)
|
||||
},
|
||||
telegram: {
|
||||
enabled: sanitizeBoolean(
|
||||
patch?.notifications?.telegram?.enabled ?? current.notifications.telegram.enabled
|
||||
),
|
||||
chatId: sanitizeString(
|
||||
patch?.notifications?.telegram?.chatId ?? current.notifications.telegram.chatId
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const filePath = hydrateSettingsFile(profileId);
|
||||
fs.writeFileSync(filePath, JSON.stringify(next, null, 2));
|
||||
return next;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_USER_SETTINGS,
|
||||
readNotificationSettings,
|
||||
writeNotificationSettings
|
||||
};
|
||||
Reference in New Issue
Block a user