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', Dienstag: 'Tuesday', Mittwoch: 'Wednesday', Donnerstag: 'Thursday', Freitag: 'Friday', Samstag: 'Saturday', Sonntag: 'Sunday' }; function randomDelayMs(minSeconds = 10, maxSeconds = 120) { const min = minSeconds * 1000; const max = maxSeconds * 1000; return Math.floor(Math.random() * (max - min + 1)) + min; } function resolveSettings(settings) { if (!settings) { return { ...DEFAULT_SETTINGS }; } return { scheduleCron: settings.scheduleCron || DEFAULT_SETTINGS.scheduleCron, randomDelayMinSeconds: Number.isFinite(settings.randomDelayMinSeconds) ? settings.randomDelayMinSeconds : DEFAULT_SETTINGS.randomDelayMinSeconds, randomDelayMaxSeconds: Number.isFinite(settings.randomDelayMaxSeconds) ? settings.randomDelayMaxSeconds : DEFAULT_SETTINGS.randomDelayMaxSeconds, initialDelayMinSeconds: Number.isFinite(settings.initialDelayMinSeconds) ? settings.initialDelayMinSeconds : DEFAULT_SETTINGS.initialDelayMinSeconds, initialDelayMaxSeconds: Number.isFinite(settings.initialDelayMaxSeconds) ? settings.initialDelayMaxSeconds : DEFAULT_SETTINGS.initialDelayMaxSeconds, 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 } } }; } async function ensureSession(session) { const profileId = session.profile?.id; if (!profileId) { return false; } const stillValid = await foodsharingClient.checkSession(session.cookieHeader, profileId); if (stillValid) { return true; } if (!session.credentials) { console.warn(`Session ${session.id} kann nicht erneuert werden – keine Zugangsdaten gespeichert.`); return false; } try { const refreshed = await foodsharingClient.login( session.credentials.email, session.credentials.password ); sessionStore.update(session.id, { cookieHeader: refreshed.cookieHeader, csrfToken: refreshed.csrfToken, profile: { ...session.profile, ...refreshed.profile } }); console.log(`Session ${session.id} wurde erfolgreich erneuert.`); return true; } catch (error) { console.error(`Session ${session.id} konnte nicht erneuert werden:`, error.message); return false; } } function toDateValue(input) { if (!input) { return null; } const date = input instanceof Date ? new Date(input.getTime()) : new Date(input); if (Number.isNaN(date.getTime())) { return null; } return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); } function matchesDesiredDate(pickupDate, desiredDate, desiredDateRange) { const pickupValue = toDateValue(pickupDate); if (pickupValue === null) { return false; } const hasRange = Boolean(desiredDateRange && (desiredDateRange.start || desiredDateRange.end)); if (hasRange) { const startValue = toDateValue(desiredDateRange.start); const endValue = toDateValue(desiredDateRange.end); const normalizedStart = startValue !== null ? startValue : endValue; const normalizedEnd = endValue !== null ? endValue : startValue; if (normalizedStart !== null && pickupValue < normalizedStart) { return false; } if (normalizedEnd !== null && pickupValue > normalizedEnd) { return false; } return true; } const desiredValue = toDateValue(desiredDate); if (desiredValue === null) { return true; } return pickupValue === desiredValue; } function matchesDesiredWeekday(pickupDate, desiredWeekday) { if (!desiredWeekday) { return true; } const weekday = pickupDate.toLocaleDateString('en-US', { weekday: 'long' }); return weekday === desiredWeekday; } function shouldIgnoreSlot(entry, pickup, settings) { const rules = settings.ignoredSlots || []; return rules.some((rule) => { if (!rule?.storeId) { return false; } if (String(rule.storeId) !== entry.id) { return false; } if (rule.description) { return pickup.description === rule.description; } return true; }); } async function processBooking(session, entry, pickup) { const readableDate = new Date(pickup.date).toLocaleString('de-DE'); const storeName = entry.label || entry.id; 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; } const utcDate = new Date(pickup.date).toISOString(); try { const allowed = await foodsharingClient.pickupRuleCheck(entry.id, utcDate, session.profile.id, session); if (!allowed) { console.warn(`[WARN] Rule-Check fehlgeschlagen für ${storeName} am ${readableDate}`); return; } 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); } } async function checkEntry(sessionId, entry, settings) { const session = sessionStore.get(sessionId); if (!session) { return; } const ready = await ensureSession(session); if (!ready) { return; } try { const pickups = await foodsharingClient.fetchPickups(entry.id, session.cookieHeader); let hasProfileId = false; let availablePickup = null; const desiredWeekday = entry.desiredWeekday ? weekdayMap[entry.desiredWeekday] || entry.desiredWeekday : null; pickups.forEach((pickup) => { const pickupDate = new Date(pickup.date); if (!matchesDesiredDate(pickupDate, entry.desiredDate, entry.desiredDateRange)) { return; } if (!matchesDesiredWeekday(pickupDate, desiredWeekday)) { return; } if (entry.checkProfileId && pickup.occupiedSlots?.some((slot) => slot.profile?.id === session.profile.id)) { hasProfileId = true; return; } if (pickup.isAvailable && !availablePickup) { availablePickup = pickup; } }); if (!availablePickup) { console.log( `[INFO] Kein freier Slot für ${entry.label || entry.id} in dieser Runde gefunden. Profil bereits eingetragen: ${ hasProfileId ? 'ja' : 'nein' }` ); return; } if (shouldIgnoreSlot(entry, availablePickup, settings)) { console.log(`[INFO] Slot für ${entry.id} aufgrund einer Admin-Regel ignoriert.`); return; } if (!entry.checkProfileId || !hasProfileId) { await processBooking(session, entry, availablePickup); } } catch (error) { console.error(`[ERROR] Pickup-Check für Store ${entry.id} fehlgeschlagen:`, error.message); } } function scheduleEntry(sessionId, entry, settings) { const cronExpression = settings.scheduleCron || DEFAULT_SETTINGS.scheduleCron; const job = cron.schedule( cronExpression, () => { const delay = randomDelayMs( settings.randomDelayMinSeconds, settings.randomDelayMaxSeconds ); setTimeout(() => checkEntry(sessionId, entry, settings), delay); }, { timezone: 'Europe/Berlin' } ); sessionStore.attachJob(sessionId, job); setTimeout( () => checkEntry(sessionId, entry, settings), randomDelayMs(settings.initialDelayMinSeconds, settings.initialDelayMaxSeconds) ); } function scheduleConfig(sessionId, config, settings) { const resolvedSettings = resolveSettings(settings); sessionStore.clearJobs(sessionId); const activeEntries = config.filter((entry) => entry.active); if (activeEntries.length === 0) { console.log(`[INFO] Keine aktiven Einträge für Session ${sessionId} – Scheduler ruht.`); return; } activeEntries.forEach((entry) => scheduleEntry(sessionId, entry, resolvedSettings)); console.log( `[INFO] Scheduler für Session ${sessionId} mit ${activeEntries.length} Jobs aktiv (Cron: ${resolvedSettings.scheduleCron}).` ); } module.exports = { scheduleConfig };