refactor(heartbeat): smart calendar with daily summary and dedup reminders

Calendar no longer bypasses quiet hours. First run after quiet hours
sends full daily summary, subsequent runs only remind for next event
within 45 min with deduplication. Calendar cooldown set to 30 min.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MoltBot Service
2026-02-15 11:09:59 +00:00
parent 9c1f9f94e7
commit f8ff971627
3 changed files with 185 additions and 59 deletions

View File

@@ -8,7 +8,7 @@ a Claude CLI session is triggered to handle them.
import json
import logging
import subprocess
from datetime import datetime, timezone
from datetime import datetime, date, timezone
from pathlib import Path
log = logging.getLogger(__name__)
@@ -63,11 +63,11 @@ def run_heartbeat(config: dict | None = None) -> str:
results.append(email_result)
checks["email"] = now.isoformat()
# Check 2: Calendar (critical — pierces quiet hours)
if check_flags.get("calendar"):
cal_result = _check_calendar(state)
# Check 2: Calendar — daily summary + next-event reminder (no quiet hours bypass)
if check_flags.get("calendar") and not is_quiet and _should_run("calendar", checks, now, cooldowns):
cal_result = _check_calendar_smart(state, quiet_hours)
if cal_result:
critical.append(cal_result)
results.append(cal_result)
checks["calendar"] = now.isoformat()
# Check 3: KB index freshness + auto-reindex
@@ -171,14 +171,77 @@ def _check_email(state: dict) -> str | None:
return None
def _check_calendar(state: dict) -> str | None:
"""Check upcoming calendar events via tools/calendar_check.py. Parses JSON."""
def _check_calendar_smart(state: dict, quiet_hours: tuple) -> str | None:
"""Smart calendar check: daily summary once, then only next-event reminders.
- First run after quiet hours: full day summary (all events today)
- Subsequent runs: only the nearest event within 45 min, deduplicated
"""
script = TOOLS_DIR / "calendar_check.py"
if not script.exists():
return None
today_str = date.today().isoformat()
cal_state = state.setdefault("calendar", {})
sent_summary_date = cal_state.get("daily_summary_date")
reminded_events = cal_state.get("reminded_events", {})
# Clean old reminded_events (from previous days)
reminded_events = {
k: v for k, v in reminded_events.items() if v == today_str
}
cal_state["reminded_events"] = reminded_events
is_first_run_today = sent_summary_date != today_str
if is_first_run_today:
# Daily summary: all events for today
return _calendar_daily_summary(cal_state, today_str)
else:
# Next-event reminder (within 45 min, deduplicated)
return _calendar_next_reminder(cal_state, today_str, reminded_events)
def _calendar_daily_summary(cal_state: dict, today_str: str) -> str | None:
"""Fetch all today's events and return a daily summary."""
script = TOOLS_DIR / "calendar_check.py"
try:
result = subprocess.run(
["python3", str(script), "soon"],
["python3", str(script), "today"],
capture_output=True, text=True, timeout=30,
cwd=str(PROJECT_ROOT)
)
if result.returncode != 0:
return None
output = result.stdout.strip()
if not output:
return None
data = json.loads(output)
today_events = data.get("today", [])
if not today_events:
cal_state["daily_summary_date"] = today_str
return None
parts = []
for event in today_events:
time = event.get("time", "")
name = event.get("summary", "?")
parts.append(f" {time}{name}")
cal_state["daily_summary_date"] = today_str
return "📅 Program azi:\n" + "\n".join(parts)
except Exception as e:
log.warning("Calendar daily summary failed: %s", e)
return None
def _calendar_next_reminder(cal_state: dict, today_str: str,
reminded_events: dict) -> str | None:
"""Remind only for the next upcoming event within 45 min, if not already reminded."""
script = TOOLS_DIR / "calendar_check.py"
try:
result = subprocess.run(
["python3", str(script), "soon", "1"],
capture_output=True, text=True, timeout=30,
cwd=str(PROJECT_ROOT)
)
@@ -191,21 +254,28 @@ def _check_calendar(state: dict) -> str | None:
upcoming = data.get("upcoming", [])
if not upcoming:
return None
parts = []
for event in upcoming:
mins = event.get("minutes_until", "?")
name = event.get("summary", "?")
time = event.get("time", "")
parts.append(f"in {mins} min — {name} ({time})")
return "Calendar: " + "; ".join(parts)
except json.JSONDecodeError:
# Fallback: treat as plain text
output = result.stdout.strip()
if output:
return f"Calendar: {output}"
return None
event = upcoming[0]
mins = event.get("minutes_until", 999)
name = event.get("summary", "?")
time = event.get("time", "")
# Only remind if within 45 minutes
if isinstance(mins, (int, float)) and mins > 45:
return None
# Dedup key: event name + time
dedup_key = f"{name}@{time}"
if dedup_key in reminded_events:
return None
# Mark as reminded
reminded_events[dedup_key] = today_str
cal_state["reminded_events"] = reminded_events
return f"⏰ in {mins} min — {name} ({time})"
except Exception as e:
log.warning("Calendar check failed: %s", e)
log.warning("Calendar next reminder failed: %s", e)
return None