180 lines
6.1 KiB
Python
180 lines
6.1 KiB
Python
import asyncio
|
|
import logging
|
|
from datetime import datetime
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import httpx
|
|
from sqlalchemy import select
|
|
|
|
from app.config import get_settings
|
|
from app.daily.recap import LLM_TIMEOUT_SEC, RecapService
|
|
from app.daily.schedule import resolve_evening_schedule
|
|
from app.db.base import SessionLocal
|
|
from app.db.models import User
|
|
from app.homelab.comfyui import ComfyUIClient
|
|
from app.homelab.context import resolve_timezone
|
|
from app.homelab.digest import build_morning_digest
|
|
from app.homelab.monitoring import check_netdata_alerts
|
|
from app.homelab_scoped.notices import post_chat_notice
|
|
from app.homelab.state import get_state, set_state
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
WATCH_INTERVAL_SEC = 60
|
|
_netdata_tick = 0
|
|
|
|
|
|
async def homelab_watcher_loop() -> None:
|
|
global _netdata_tick
|
|
while True:
|
|
try:
|
|
await asyncio.sleep(WATCH_INTERVAL_SEC)
|
|
await _tick_morning_digest()
|
|
await _tick_evening_summary()
|
|
await _tick_evening_summary_retries()
|
|
settings = get_settings()
|
|
_netdata_tick += WATCH_INTERVAL_SEC
|
|
if _netdata_tick >= settings.netdata_poll_interval_sec:
|
|
_netdata_tick = 0
|
|
await _tick_netdata()
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
logger.exception("Homelab watcher error")
|
|
|
|
|
|
async def _tick_morning_digest() -> None:
|
|
settings = get_settings()
|
|
if not settings.morning_digest_enabled:
|
|
return
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
|
|
digest = build_morning_digest(db, include_news=True)
|
|
for user in users:
|
|
tz_name = resolve_timezone(db, user.id)
|
|
try:
|
|
tz = ZoneInfo(tz_name)
|
|
except Exception:
|
|
tz = ZoneInfo("Europe/Moscow")
|
|
|
|
now = datetime.now(tz)
|
|
target_min = settings.morning_digest_hour * 60 + settings.morning_digest_minute
|
|
current_min = now.hour * 60 + now.minute
|
|
if current_min < target_min or current_min >= target_min + 3:
|
|
continue
|
|
|
|
today = now.date().isoformat()
|
|
state_key = f"last_morning_digest_date:{user.id}"
|
|
if get_state(db, state_key) == today:
|
|
continue
|
|
|
|
post_chat_notice(digest, user.id)
|
|
set_state(db, state_key, today)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
async def _tick_netdata() -> None:
|
|
db = SessionLocal()
|
|
try:
|
|
notices = check_netdata_alerts(db)
|
|
if not notices:
|
|
return
|
|
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
|
|
for user in users:
|
|
for notice in notices:
|
|
post_chat_notice(notice, user.id)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
async def _comfyui_reachable(base_url: str) -> bool:
|
|
try:
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(3.0, connect=2.0)) as client:
|
|
response = await client.get(f"{base_url.rstrip('/')}/system_stats")
|
|
return response.status_code < 500
|
|
except (httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError):
|
|
return False
|
|
|
|
|
|
async def _run_evening_recap(db, user: User, day, tz: ZoneInfo, *, scheduled: bool) -> None:
|
|
settings = get_settings()
|
|
schedule = resolve_evening_schedule(db, user.id)
|
|
if not schedule.enabled:
|
|
return
|
|
|
|
image_enabled = settings.evening_summary_image_enabled and settings.comfyui_enabled
|
|
if image_enabled:
|
|
client = ComfyUIClient()
|
|
if not await _comfyui_reachable(client.base_url):
|
|
logger.warning("evening_recap ComfyUI unreachable user=%s text_only", user.id)
|
|
image_enabled = False
|
|
|
|
recap = RecapService(db, user.id)
|
|
try:
|
|
row = await asyncio.wait_for(
|
|
recap.generate_and_post(day, tz, image_enabled=image_enabled),
|
|
timeout=settings.comfyui_timeout_sec + LLM_TIMEOUT_SEC,
|
|
)
|
|
except asyncio.TimeoutError:
|
|
logger.warning("evening_recap timed out user=%s day=%s scheduled=%s", user.id, day, scheduled)
|
|
return
|
|
|
|
if row and row.status in ("posted", "text_only"):
|
|
set_state(db, f"last_evening_summary_date:{user.id}", day.isoformat())
|
|
|
|
|
|
async def _tick_evening_summary() -> None:
|
|
settings = get_settings()
|
|
if not settings.evening_summary_enabled:
|
|
return
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
|
|
for user in users:
|
|
tz_name = resolve_timezone(db, user.id)
|
|
try:
|
|
tz = ZoneInfo(tz_name)
|
|
except Exception:
|
|
tz = ZoneInfo("Europe/Moscow")
|
|
|
|
schedule = resolve_evening_schedule(db, user.id)
|
|
if not schedule.enabled:
|
|
continue
|
|
|
|
now = datetime.now(tz)
|
|
target_min = schedule.hour * 60 + schedule.minute
|
|
current_min = now.hour * 60 + now.minute
|
|
if current_min < target_min or current_min >= target_min + 3:
|
|
continue
|
|
|
|
today = now.date()
|
|
state_key = f"last_evening_summary_date:{user.id}"
|
|
if get_state(db, state_key) == today.isoformat():
|
|
continue
|
|
|
|
await _run_evening_recap(db, user, today, tz, scheduled=True)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
async def _tick_evening_summary_retries() -> None:
|
|
db = SessionLocal()
|
|
try:
|
|
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
|
|
for user in users:
|
|
tz_name = resolve_timezone(db, user.id)
|
|
try:
|
|
tz = ZoneInfo(tz_name)
|
|
except Exception:
|
|
tz = ZoneInfo("Europe/Moscow")
|
|
|
|
recap = RecapService(db, user.id)
|
|
for row in recap.list_failed_for_retry():
|
|
await _run_evening_recap(db, user, row.day, tz, scheduled=False)
|
|
finally:
|
|
db.close()
|