feat: add ntfy and telegram notification workflows

This commit is contained in:
2025-11-09 20:08:14 +01:00
parent d4a28c9897
commit 539759ed60
6 changed files with 820 additions and 6 deletions

View File

@@ -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();

View 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
};

View File

@@ -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);
}

View 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
};