aktueller stand
This commit is contained in:
@@ -31,7 +31,8 @@ const DEFAULT_SETTINGS = {
|
||||
},
|
||||
telegram: {
|
||||
enabled: false,
|
||||
botToken: ''
|
||||
botToken: '',
|
||||
chatId: ''
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -84,7 +85,8 @@ function sanitizeNotifications(input = {}) {
|
||||
},
|
||||
telegram: {
|
||||
enabled: !!(input?.telegram?.enabled),
|
||||
botToken: sanitizeString(input?.telegram?.botToken || defaults.telegram.botToken)
|
||||
botToken: sanitizeString(input?.telegram?.botToken || defaults.telegram.botToken),
|
||||
chatId: sanitizeString(input?.telegram?.chatId || defaults.telegram.chatId)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,15 +55,43 @@ client.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
const CSRF_COOKIE_NAMES = ['CSRF_TOKEN', 'CSRF-TOKEN', 'XSRF-TOKEN', 'XSRF_TOKEN'];
|
||||
|
||||
function extractCookieValue(cookies = [], name) {
|
||||
if (!Array.isArray(cookies) || !name) {
|
||||
return null;
|
||||
}
|
||||
const prefix = `${name}=`;
|
||||
const match = cookies.find((cookie) => cookie.startsWith(prefix));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return match.split(';')[0].slice(prefix.length);
|
||||
}
|
||||
|
||||
function extractCsrfToken(cookies = []) {
|
||||
if (!Array.isArray(cookies)) {
|
||||
for (const name of CSRF_COOKIE_NAMES) {
|
||||
const value = extractCookieValue(cookies, name);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractCsrfTokenFromCookieHeader(cookieHeader = '') {
|
||||
if (!cookieHeader) {
|
||||
return null;
|
||||
}
|
||||
const tokenCookie = cookies.find((cookie) => cookie.startsWith('CSRF_TOKEN='));
|
||||
if (!tokenCookie) {
|
||||
return null;
|
||||
const pairs = cookieHeader.split(';').map((part) => part.trim());
|
||||
for (const name of CSRF_COOKIE_NAMES) {
|
||||
const prefix = `${name}=`;
|
||||
const match = pairs.find((pair) => pair.startsWith(prefix));
|
||||
if (match) {
|
||||
return match.slice(prefix.length);
|
||||
}
|
||||
}
|
||||
return tokenCookie.split(';')[0].split('=')[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
function serializeCookies(cookies = []) {
|
||||
@@ -78,8 +106,10 @@ function buildHeaders(cookieHeader, csrfToken) {
|
||||
if (cookieHeader) {
|
||||
headers.cookie = cookieHeader;
|
||||
}
|
||||
if (csrfToken) {
|
||||
headers['x-csrf-token'] = csrfToken;
|
||||
const token = csrfToken || extractCsrfTokenFromCookieHeader(cookieHeader);
|
||||
if (token) {
|
||||
headers['x-csrf-token'] = token;
|
||||
headers['x-xsrf-token'] = token;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
@@ -135,25 +165,13 @@ async function login(email, password) {
|
||||
};
|
||||
}
|
||||
|
||||
async function checkSession(cookieHeader, profileId) {
|
||||
if (!cookieHeader) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await client.get(`/api/wall/foodsaver/${profileId}?limit=1`, {
|
||||
headers: buildHeaders(cookieHeader)
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProfile(cookieHeader) {
|
||||
async function fetchProfile(cookieHeader, { throwOnError = false } = {}) {
|
||||
try {
|
||||
return await getCurrentUserDetails(cookieHeader);
|
||||
} catch (error) {
|
||||
if (throwOnError) {
|
||||
throw error;
|
||||
}
|
||||
console.warn('Profil konnte nicht geladen werden:', error.message);
|
||||
return null;
|
||||
}
|
||||
@@ -296,7 +314,6 @@ async function bookSlot(storeId, utcDate, profileId, session) {
|
||||
|
||||
module.exports = {
|
||||
login,
|
||||
checkSession,
|
||||
fetchProfile,
|
||||
fetchStores,
|
||||
fetchPickups,
|
||||
|
||||
@@ -62,6 +62,19 @@ async function sendTelegramNotification(adminTelegram, userTelegram, payload) {
|
||||
);
|
||||
}
|
||||
|
||||
async function sendAdminTelegramNotification(payload) {
|
||||
const adminSettings = adminConfig.readSettings();
|
||||
const adminTelegram = adminSettings.notifications?.telegram;
|
||||
if (!adminTelegram?.enabled || !adminTelegram.botToken || !adminTelegram.chatId) {
|
||||
return;
|
||||
}
|
||||
await sendTelegramNotification(
|
||||
adminTelegram,
|
||||
{ enabled: true, chatId: adminTelegram.chatId },
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
async function notifyChannels(profileId, template) {
|
||||
const adminSettings = adminConfig.readSettings();
|
||||
const userSettings = readNotificationSettings(profileId);
|
||||
@@ -217,11 +230,32 @@ async function sendDormantPickupWarning({ profileId, storeName, storeId, reasonL
|
||||
});
|
||||
}
|
||||
|
||||
async function sendAdminBookingErrorNotification({ profileId, profileEmail, storeName, storeId, pickupDate, error }) {
|
||||
const dateLabel = formatDateLabel(pickupDate);
|
||||
const storeLabel = storeName || storeId || 'Unbekannter Store';
|
||||
const storeLink = storeId ? `https://foodsharing.de/store/${storeId}` : null;
|
||||
const profileLabel = profileEmail || profileId || 'Unbekanntes Profil';
|
||||
const messageLines = [
|
||||
`Buchung fehlgeschlagen für ${storeLabel} am ${dateLabel}.`,
|
||||
`Profil: ${profileLabel}.`,
|
||||
`Fehler: ${error || 'Unbekannter Fehler'}.`
|
||||
];
|
||||
if (storeLink) {
|
||||
messageLines.push(storeLink);
|
||||
}
|
||||
await sendAdminTelegramNotification({
|
||||
title: 'Fehler beim Slot-Buchen',
|
||||
message: messageLines.join('\n'),
|
||||
priority: 'high'
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendSlotNotification,
|
||||
sendStoreWatchNotification,
|
||||
sendStoreWatchSummaryNotification,
|
||||
sendTestNotification,
|
||||
sendDesiredWindowMissedNotification,
|
||||
sendDormantPickupWarning
|
||||
sendDormantPickupWarning,
|
||||
sendAdminBookingErrorNotification
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ const { readConfig, writeConfig } = require('./configStore');
|
||||
const { readStoreWatch, writeStoreWatch } = require('./storeWatchStore');
|
||||
const { setStoreStatus, persistStoreStatusCache } = require('./storeStatusCache');
|
||||
const { sendDormantPickupWarning } = require('./notificationService');
|
||||
const { ensureSession, withSessionRetry } = require('./sessionRefresh');
|
||||
|
||||
function wait(ms) {
|
||||
if (!ms || ms <= 0) {
|
||||
@@ -122,43 +123,6 @@ function persistEntryDeactivation(profileId, entryId, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -326,12 +290,20 @@ async function processBooking(session, entry, pickup) {
|
||||
|
||||
const utcDate = new Date(pickup.date).toISOString();
|
||||
try {
|
||||
const allowed = await foodsharingClient.pickupRuleCheck(entry.id, utcDate, session.profile.id, session);
|
||||
const allowed = await withSessionRetry(
|
||||
session,
|
||||
() => foodsharingClient.pickupRuleCheck(entry.id, utcDate, session.profile.id, session),
|
||||
{ label: 'pickupRuleCheck' }
|
||||
);
|
||||
if (!allowed) {
|
||||
console.warn(`[WARN] Rule-Check fehlgeschlagen für ${storeName} am ${readableDate}`);
|
||||
return;
|
||||
}
|
||||
await foodsharingClient.bookSlot(entry.id, utcDate, session.profile.id, session);
|
||||
await withSessionRetry(
|
||||
session,
|
||||
() => foodsharingClient.bookSlot(entry.id, utcDate, session.profile.id, session),
|
||||
{ label: 'bookSlot' }
|
||||
);
|
||||
console.log(`[SUCCESS] Slot gebucht für ${storeName} am ${readableDate}`);
|
||||
await notificationService.sendSlotNotification({
|
||||
profileId: session.profile.id,
|
||||
@@ -345,6 +317,21 @@ async function processBooking(session, entry, pickup) {
|
||||
persistEntryDeactivation(session.profile.id, entry.id);
|
||||
} catch (error) {
|
||||
console.error(`[ERROR] Buchung für ${storeName} am ${readableDate} fehlgeschlagen:`, error.message);
|
||||
try {
|
||||
await notificationService.sendAdminBookingErrorNotification({
|
||||
profileId: session.profile.id,
|
||||
profileEmail: session.profile.email,
|
||||
storeName,
|
||||
storeId: entry.id,
|
||||
pickupDate: pickup.date,
|
||||
error: error.message
|
||||
});
|
||||
} catch (notifyError) {
|
||||
console.error(
|
||||
`[NOTIFY] Admin-Benachrichtigung für fehlgeschlagene Buchung bei ${storeName} fehlgeschlagen:`,
|
||||
notifyError.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,7 +355,11 @@ async function checkEntry(sessionId, entry, settings) {
|
||||
}
|
||||
|
||||
try {
|
||||
const pickups = await foodsharingClient.fetchPickups(entry.id, session.cookieHeader);
|
||||
const pickups = await withSessionRetry(
|
||||
session,
|
||||
() => foodsharingClient.fetchPickups(entry.id, session.cookieHeader),
|
||||
{ label: 'fetchPickups' }
|
||||
);
|
||||
let hasProfileId = false;
|
||||
let availablePickup = null;
|
||||
|
||||
@@ -435,7 +426,11 @@ async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS, option
|
||||
for (let index = 0; index < watchers.length; index += 1) {
|
||||
const watcher = watchers[index];
|
||||
try {
|
||||
const details = await foodsharingClient.fetchStoreDetails(watcher.storeId, session.cookieHeader);
|
||||
const details = await withSessionRetry(
|
||||
session,
|
||||
() => foodsharingClient.fetchStoreDetails(watcher.storeId, session.cookieHeader),
|
||||
{ label: 'fetchStoreDetails' }
|
||||
);
|
||||
const status = details?.teamSearchStatus === 1 ? 1 : 0;
|
||||
const checkedAt = Date.now();
|
||||
if (status === 1 && watcher.lastTeamSearchStatus !== 1) {
|
||||
@@ -589,6 +584,15 @@ function setMonthOffset(date, offset) {
|
||||
return copy;
|
||||
}
|
||||
|
||||
function getMissingLastPickupStoreIds(config = []) {
|
||||
if (!Array.isArray(config)) {
|
||||
return [];
|
||||
}
|
||||
return config
|
||||
.filter((entry) => entry && entry.id && !entry.hidden && !entry.skipDormantCheck && !entry.lastPickupAt)
|
||||
.map((entry) => String(entry.id));
|
||||
}
|
||||
|
||||
async function checkDormantMembers(sessionId) {
|
||||
const session = sessionStore.get(sessionId);
|
||||
if (!session?.profile?.id) {
|
||||
@@ -601,11 +605,15 @@ async function checkDormantMembers(sessionId) {
|
||||
}
|
||||
const config = readConfig(profileId);
|
||||
const skipMap = new Map();
|
||||
config.forEach((entry) => {
|
||||
const configEntryMap = new Map();
|
||||
config.forEach((entry, index) => {
|
||||
if (entry?.id) {
|
||||
skipMap.set(String(entry.id), !!entry.skipDormantCheck);
|
||||
const id = String(entry.id);
|
||||
skipMap.set(id, !!entry.skipDormantCheck);
|
||||
configEntryMap.set(id, { entry, index });
|
||||
}
|
||||
});
|
||||
let configChanged = false;
|
||||
|
||||
const stores = Array.isArray(session.storesCache?.data) ? session.storesCache.data : [];
|
||||
if (stores.length === 0) {
|
||||
@@ -624,7 +632,11 @@ async function checkDormantMembers(sessionId) {
|
||||
}
|
||||
let members = [];
|
||||
try {
|
||||
members = await foodsharingClient.fetchStoreMembers(storeId, session.cookieHeader);
|
||||
members = await withSessionRetry(
|
||||
session,
|
||||
() => foodsharingClient.fetchStoreMembers(storeId, session.cookieHeader),
|
||||
{ label: 'fetchStoreMembers' }
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(`[DORMANT] Mitglieder von Store ${storeId} konnten nicht geladen werden:`, error.message);
|
||||
continue;
|
||||
@@ -635,6 +647,14 @@ async function checkDormantMembers(sessionId) {
|
||||
}
|
||||
const reasons = [];
|
||||
const lastFetchMs = memberEntry.last_fetch ? Number(memberEntry.last_fetch) * 1000 : null;
|
||||
if (Number.isFinite(lastFetchMs)) {
|
||||
const configEntry = configEntryMap.get(storeId)?.entry;
|
||||
const lastPickupAt = new Date(lastFetchMs).toISOString();
|
||||
if (configEntry && configEntry.lastPickupAt !== lastPickupAt) {
|
||||
configEntry.lastPickupAt = lastPickupAt;
|
||||
configChanged = true;
|
||||
}
|
||||
}
|
||||
if (!lastFetchMs || lastFetchMs < fourMonthsAgo) {
|
||||
const lastFetchLabel = lastFetchMs ? new Date(lastFetchMs).toLocaleDateString('de-DE') : 'unbekannt';
|
||||
reasons.push(`Letzte Abholung: ${lastFetchLabel} (älter als 4 Monate)`);
|
||||
@@ -660,6 +680,13 @@ async function checkDormantMembers(sessionId) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (configChanged) {
|
||||
try {
|
||||
writeConfig(profileId, config);
|
||||
} catch (error) {
|
||||
console.error(`[DORMANT] Letzte Abholung für Profil ${profileId} konnte nicht gespeichert werden:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDormantMembershipCheck(sessionId) {
|
||||
@@ -674,6 +701,24 @@ function scheduleDormantMembershipCheck(sessionId) {
|
||||
{ timezone: 'Europe/Berlin' }
|
||||
);
|
||||
sessionStore.attachJob(sessionId, job);
|
||||
const session = sessionStore.get(sessionId);
|
||||
const profileId = session?.profile?.id;
|
||||
if (!profileId) {
|
||||
return;
|
||||
}
|
||||
const config = readConfig(profileId);
|
||||
const missingIds = getMissingLastPickupStoreIds(config);
|
||||
if (missingIds.length === 0) {
|
||||
if (session.dormantBootstrapSignature) {
|
||||
sessionStore.update(sessionId, { dormantBootstrapSignature: null });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const signature = missingIds.sort().join(',');
|
||||
if (session.dormantBootstrapSignature === signature) {
|
||||
return;
|
||||
}
|
||||
sessionStore.update(sessionId, { dormantBootstrapSignature: signature });
|
||||
setTimeout(() => checkDormantMembers(sessionId), randomDelayMs(30, 180));
|
||||
}
|
||||
|
||||
|
||||
80
services/sessionRefresh.js
Normal file
80
services/sessionRefresh.js
Normal file
@@ -0,0 +1,80 @@
|
||||
const foodsharingClient = require('./foodsharingClient');
|
||||
const sessionStore = require('./sessionStore');
|
||||
|
||||
function isUnauthorizedError(error) {
|
||||
const status = error?.response?.status;
|
||||
return status === 401 || status === 403;
|
||||
}
|
||||
|
||||
async function refreshSession(session, { label } = {}) {
|
||||
if (!session?.credentials?.email || !session?.credentials?.password) {
|
||||
console.warn(
|
||||
`[SESSION] Session ${session?.id || 'unbekannt'} 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 ${session.id} wurde erfolgreich erneuert${label ? ` (${label})` : ''}.`
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[SESSION] Session ${session?.id || 'unbekannt'} konnte nicht erneuert werden${label ? ` (${label})` : ''}:`,
|
||||
error.message
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSession(session) {
|
||||
if (!session?.profile?.id) {
|
||||
return false;
|
||||
}
|
||||
if (!session.cookieHeader) {
|
||||
return refreshSession(session, { label: 'missing-cookie' });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function withSessionRetry(session, action, { label } = {}) {
|
||||
if (!session) {
|
||||
throw new Error('Session fehlt');
|
||||
}
|
||||
if (!session.cookieHeader && session.credentials) {
|
||||
const refreshed = await refreshSession(session, { label });
|
||||
if (!refreshed) {
|
||||
throw new Error('Session konnte nicht erneuert werden');
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await action();
|
||||
} catch (error) {
|
||||
if (!isUnauthorizedError(error)) {
|
||||
throw error;
|
||||
}
|
||||
const refreshed = await refreshSession(session, { label });
|
||||
if (!refreshed) {
|
||||
throw error;
|
||||
}
|
||||
return action();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureSession,
|
||||
refreshSession,
|
||||
withSessionRetry
|
||||
};
|
||||
Reference in New Issue
Block a user