438 lines
14 KiB
JavaScript
438 lines
14 KiB
JavaScript
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');
|
||
|
||
function wait(ms) {
|
||
if (!ms || ms <= 0) {
|
||
return Promise.resolve();
|
||
}
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
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,
|
||
storeWatchRequestDelayMs: Number.isFinite(settings.storeWatchRequestDelayMs)
|
||
? settings.storeWatchRequestDelayMs
|
||
: DEFAULT_SETTINGS.storeWatchRequestDelayMs,
|
||
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, settings = DEFAULT_SETTINGS) {
|
||
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;
|
||
}
|
||
|
||
const perRequestDelay = Math.max(0, Number(settings?.storeWatchRequestDelayMs) || 0);
|
||
let changed = false;
|
||
for (let index = 0; index < watchers.length; index += 1) {
|
||
const watcher = watchers[index];
|
||
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);
|
||
} finally {
|
||
const hasNext = index < watchers.length - 1;
|
||
if (hasNext && perRequestDelay > 0) {
|
||
await wait(perRequestDelay);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (changed) {
|
||
writeStoreWatch(session.profile.id, watchers);
|
||
}
|
||
}
|
||
|
||
function scheduleStoreWatchers(sessionId, settings) {
|
||
const effectiveSettings = settings || DEFAULT_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 = effectiveSettings.storeWatchCron || DEFAULT_SETTINGS.storeWatchCron;
|
||
const job = cron.schedule(
|
||
cronExpression,
|
||
() => {
|
||
checkWatchedStores(sessionId, effectiveSettings).catch((error) => {
|
||
console.error('[WATCH] Regelmäßige Prüfung fehlgeschlagen:', error.message);
|
||
});
|
||
},
|
||
{ timezone: 'Europe/Berlin' }
|
||
);
|
||
sessionStore.attachJob(sessionId, job);
|
||
setTimeout(
|
||
() => checkWatchedStores(sessionId, effectiveSettings),
|
||
randomDelayMs(
|
||
effectiveSettings.storeWatchInitialDelayMinSeconds,
|
||
effectiveSettings.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
|
||
};
|