4949WEB_DIST = next ((p for p in _WEB_DIST_CANDIDATES if p .exists ()), _WEB_DIST_CANDIDATES [0 ])
5050CHAT_LOG_FILE = DATA_DIR / "chat_history.json"
5151ACTIVITY_LOG_FILE = DATA_DIR / "activity_log.json"
52+ ALERT_STATE_FILE = DATA_DIR / "alert_state.json"
5253
5354# Track background scanner state
5455_scanner_task : asyncio .Task | None = None
@@ -335,8 +336,42 @@ async def _scan_portals(portals: list, config: WiesnConfig) -> list[dict]:
335336# Track already-notified evening slots to avoid repeated push notifications.
336337# Key: "portal_name|datum_text|time_text"
337338_notified_evening_slots : set [str ] = set ()
338- # Track slots where push delivery succeeded (separate from detection)
339- _push_delivered_slots : set [str ] = set ()
339+
340+
341+ def _save_alert_state () -> None :
342+ """Persist dedupe set and recent alerts to disk."""
343+ try :
344+ DATA_DIR .mkdir (parents = True , exist_ok = True )
345+ state = {
346+ "notified_slots" : list (_notified_evening_slots ),
347+ "alerts" : list (_slot_alerts ),
348+ "alert_id" : _slot_alert_id ,
349+ }
350+ ALERT_STATE_FILE .write_text (
351+ json .dumps (state , ensure_ascii = False , indent = 2 ), encoding = "utf-8"
352+ )
353+ except Exception as e :
354+ logger .warning ("Failed to save alert state: %s" , e )
355+
356+
357+ def _load_alert_state () -> None :
358+ """Restore dedupe set and recent alerts from disk on startup."""
359+ global _slot_alert_id
360+ if not ALERT_STATE_FILE .exists ():
361+ return
362+ try :
363+ data = json .loads (ALERT_STATE_FILE .read_text (encoding = "utf-8" ))
364+ if isinstance (data .get ("notified_slots" ), list ):
365+ _notified_evening_slots .update (data ["notified_slots" ])
366+ logger .info ("Restored %d notified slot keys from disk" , len (_notified_evening_slots ))
367+ if isinstance (data .get ("alerts" ), list ):
368+ for a in data ["alerts" ][- 50 :]:
369+ _slot_alerts .append (a )
370+ logger .info ("Restored %d slot alerts from disk" , len (_slot_alerts ))
371+ if isinstance (data .get ("alert_id" ), int ):
372+ _slot_alert_id = max (_slot_alert_id , data ["alert_id" ])
373+ except Exception as e :
374+ logger .warning ("Failed to load alert state: %s" , e )
340375
341376
342377async def _notify_new_evening_slots (results : list [dict ], config : WiesnConfig ) -> None :
@@ -380,7 +415,7 @@ async def _notify_new_evening_slots(results: list[dict], config: WiesnConfig) ->
380415 datum_text_val = ds .get ("datum_text" , ds .get ("datum_value" , "" ))
381416 _push_slot_alert (portal_name , datum_text_val , times , booking_url )
382417
383- # Push notification only if outside quiet hours
418+ # Push notification only if outside quiet hours (with retry)
384419 push_status = "skipped_quiet_hours"
385420 if push_allowed :
386421 title = f"Evening slots: { portal_name } "
@@ -390,18 +425,28 @@ async def _notify_new_evening_slots(results: list[dict], config: WiesnConfig) ->
390425 f"🌙 { times } \n "
391426 f"\n → Book now: { booking_url } "
392427 )
393- result_json = await send_notification (
394- title = title ,
395- message = message ,
396- config = config .notifications ,
397- notify_type = "success" ,
398- event_type = "evening_slot" ,
399- )
400- try :
401- result_data = _json .loads (result_json )
402- push_status = result_data .get ("status" , "unknown" )
403- except (ValueError , TypeError ):
404- push_status = "error"
428+ # Retry up to 2 times on failure
429+ for attempt in range (3 ):
430+ result_json = await send_notification (
431+ title = title ,
432+ message = message ,
433+ config = config .notifications ,
434+ notify_type = "success" ,
435+ event_type = "evening_slot" ,
436+ )
437+ try :
438+ result_data = _json .loads (result_json )
439+ push_status = result_data .get ("status" , "unknown" )
440+ except (ValueError , TypeError ):
441+ push_status = "error"
442+ if push_status in ("sent" , "partial" ):
443+ break
444+ if attempt < 2 :
445+ logger .warning (
446+ "Push notification failed (attempt %d/3) for %s, retrying..." ,
447+ attempt + 1 , portal_name ,
448+ )
449+ await asyncio .sleep (2 ** attempt )
405450
406451 # Log truthfully
407452 _log_activity (
@@ -414,6 +459,9 @@ async def _notify_new_evening_slots(results: list[dict], config: WiesnConfig) ->
414459 portal = portal_name , datum = datum_text_val ,
415460 push_status = push_status )
416461
462+ # Persist alert state after processing
463+ _save_alert_state ()
464+
417465
418466async def _background_scanner () -> None :
419467 """Periodically scan all enabled portals in the background."""
@@ -461,6 +509,7 @@ async def lifespan(app: FastAPI):
461509 # Restore persisted state
462510 _load_chat_log ()
463511 _load_activity_log ()
512+ _load_alert_state ()
464513
465514 _scanner_task = asyncio .create_task (_background_scanner ())
466515 logger .info ("[Scanner] Background scanner started" )
0 commit comments