Files
Pickup-Config/services/pickupScheduler.js
2025-11-10 16:44:54 +01:00

417 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const cron = require('node-cron');
const foodsharingClient = require('./foodsharingClient');
const sessionStore = require('./sessionStore');
const { DEFAULT_SETTINGS } = require('./adminConfig');
const notificationService = require('./notificationService');
const { readConfig, writeConfig } = require('./configStore');
const { readStoreWatch, writeStoreWatch } = require('./storeWatchStore');
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,
storeWatchCron: settings.storeWatchCron || DEFAULT_SETTINGS.storeWatchCron,
storeWatchInitialDelayMinSeconds: Number.isFinite(settings.storeWatchInitialDelayMinSeconds)
? settings.storeWatchInitialDelayMinSeconds
: DEFAULT_SETTINGS.storeWatchInitialDelayMinSeconds,
storeWatchInitialDelayMaxSeconds: Number.isFinite(settings.storeWatchInitialDelayMaxSeconds)
? settings.storeWatchInitialDelayMaxSeconds
: DEFAULT_SETTINGS.storeWatchInitialDelayMaxSeconds,
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
}
}
};
}
function deactivateEntryInMemory(entry) {
if (entry) {
entry.active = false;
}
}
function persistEntryDeactivation(profileId, entryId) {
if (!profileId || !entryId) {
return;
}
try {
const config = readConfig(profileId);
let changed = false;
const updated = config.map((item) => {
if (String(item?.id) === String(entryId) && item?.active !== false) {
changed = true;
return { ...item, active: false };
}
return item;
});
if (changed) {
writeConfig(profileId, updated);
}
} catch (error) {
console.error(`[CONFIG] Konnte Eintrag ${entryId} für Profil ${profileId} nicht deaktivieren:`, error.message);
}
}
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,
storeId: entry.id
});
deactivateEntryInMemory(entry);
persistEntryDeactivation(session.profile.id, entry.id);
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,
storeId: entry.id
});
deactivateEntryInMemory(entry);
persistEntryDeactivation(session.profile.id, entry.id);
} catch (error) {
console.error(`[ERROR] Buchung für ${storeName} am ${readableDate} fehlgeschlagen:`, error.message);
}
}
async function checkEntry(sessionId, entry, settings) {
if (entry?.active === false) {
return;
}
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);
}
}
async function checkWatchedStores(sessionId) {
const session = sessionStore.get(sessionId);
if (!session?.profile?.id) {
return;
}
const watchers = readStoreWatch(session.profile.id);
if (!Array.isArray(watchers) || watchers.length === 0) {
return;
}
const ready = await ensureSession(session);
if (!ready) {
return;
}
let changed = false;
for (const watcher of watchers) {
try {
const details = await foodsharingClient.fetchStoreDetails(watcher.storeId, session.cookieHeader);
const status = details?.teamSearchStatus === 1 ? 1 : 0;
if (status === 1 && watcher.lastTeamSearchStatus !== 1) {
await notificationService.sendStoreWatchNotification({
profileId: session.profile.id,
storeName: watcher.storeName,
storeId: watcher.storeId,
regionName: watcher.regionName
});
}
if (watcher.lastTeamSearchStatus !== status) {
watcher.lastTeamSearchStatus = status;
changed = true;
}
} catch (error) {
console.error(`[WATCH] Prüfung für Store ${watcher.storeId} fehlgeschlagen:`, error.message);
}
}
if (changed) {
writeStoreWatch(session.profile.id, watchers);
}
}
function scheduleStoreWatchers(sessionId, settings) {
const session = sessionStore.get(sessionId);
if (!session?.profile?.id) {
return false;
}
const watchers = readStoreWatch(session.profile.id);
if (!Array.isArray(watchers) || watchers.length === 0) {
return false;
}
const cronExpression = settings.storeWatchCron || DEFAULT_SETTINGS.storeWatchCron;
const job = cron.schedule(
cronExpression,
() => {
checkWatchedStores(sessionId).catch((error) => {
console.error('[WATCH] Regelmäßige Prüfung fehlgeschlagen:', error.message);
});
},
{ timezone: 'Europe/Berlin' }
);
sessionStore.attachJob(sessionId, job);
setTimeout(
() => checkWatchedStores(sessionId),
randomDelayMs(settings.storeWatchInitialDelayMinSeconds, settings.storeWatchInitialDelayMaxSeconds)
);
console.log(
`[WATCH] Überwache ${watchers.length} Betriebe für Session ${sessionId} (Cron: ${cronExpression}).`
);
return true;
}
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 watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings);
const entries = Array.isArray(config) ? config : [];
const activeEntries = entries.filter((entry) => entry.active);
if (activeEntries.length === 0) {
if (watchScheduled) {
console.log(
`[INFO] Keine aktiven Pickup-Einträge für Session ${sessionId} Store-Watch bleibt aktiv.`
);
} else {
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
};