"""Servicio en segundo plano: planifica, avisa, ficha y atiende Telegram. No usa ``JobQueue`` de PTB (APScheduler 3.6 no funciona en Python 3.13 y ademas necesitamos precision al segundo). El bucle propio duerme exactamente hasta el siguiente evento, asi que un fichaje no se desvia mas de unas decimas. """ from __future__ import annotations import asyncio import logging import random import signal from datetime import date, datetime, timedelta from zoneinfo import ZoneInfo from telegram.constants import ParseMode from telegram.ext import Application from app import settings from app.bot import BotUI, check_result_text, hm, pending_keyboard from app.executor import Executor from app.planner import ENTRADA, Planner from app.store import Store logger = logging.getLogger(__name__) ANNOUNCE_LEAD = timedelta(minutes=12) # cuanta antelacion tiene el killswitch MAX_SLEEP = 60.0 REPLAN_HOUR = 4 # hora a la que se planifica el dia class Service: def __init__(self, cfg: dict, pin: str, db_path: str | None = None): self.cfg = cfg self.tz = ZoneInfo(cfg.get("timezone", "Europe/Madrid")) self.store = Store(db_path) if db_path else Store() self.executor = Executor(cfg, self.store, pin) self.planner = Planner(cfg, self.store) self.app: Application | None = None self.ui: BotUI | None = None self._wake = asyncio.Event() self._stop = asyncio.Event() self._planned_for: date | None = None # ------------------------------------------------------------------ basico def now(self) -> datetime: return datetime.now(self.tz) def today(self) -> date: return self.now().date() def is_paused(self) -> bool: return bool(self.store.get("paused", False)) def set_paused(self, value: bool) -> None: self.store.put("paused", bool(value)) self.store.log("pausa", "activada" if value else "desactivada") self.wake() def wake(self) -> None: self._wake.set() async def notify(self, text: str, keyboard=None): if self.ui is None: logger.info("(sin bot todavia) %s", text) return None try: return await self.ui.send(text, keyboard) except Exception: logger.exception("no se pudo enviar el mensaje por Telegram") return None # --------------------------------------------------------------- calendario async def _personal_absence(self, day: date) -> str | None: """Ausencias personales desde Google Calendar. Solo eso: festivos y vacaciones los da Timenet. Si Google falla, se sigue fichando.""" if not self.cfg.get("use_google_calendar", True): return None try: from app import google_calendar ev = await asyncio.to_thread(lambda: google_calendar.GoogleCalendar().getEvent(day)) if ev and isinstance(ev, str) and not ev.startswith("Error"): return ev if ev and str(ev).startswith("Error"): await self.notify(f"⚠️ Google Calendar no responde: {ev}\nSe sigue con el plan.") except Exception as e: logger.warning("Google Calendar fallo: %s", e) await self.notify(f"⚠️ Google Calendar falló ({e}). Se sigue con el plan.") return None # --------------------------------------------------------------------- plan async def ensure_plan(self, force: bool = False) -> list: day = self.today() if self._planned_for == day and not force: return self.store.get_plan(day) cal = await asyncio.to_thread(self.executor.calendar) self._planned_for = day if self.is_paused(): logger.info("servicio en pausa, no se planifica") return [] ov = self.store.get_override(day) if ov and ov["action"] == "off": logger.info("%s desactivado a mano: %s", day, ov["reason"]) return [] if not cal.is_workday(day): motivo = cal.why_not_workday(day) logger.info("%s no laborable: %s", day, motivo) self.store.log("plan", f"{day} no laborable: {motivo}") return [] ausencia = await self._personal_absence(day) if ausencia: self.store.set_override(day, "off", f"Google Calendar: {ausencia}") await self.notify(f"🌴 Hoy no se ficha: {ausencia} (Google Calendar)") return [] plan = self.planner.plan_day(cal, day, random.Random()) self.store.save_plan(day, self.planner.rows_for_store(plan)) self.store.log("plan", f"{day} planificado: " + ", ".join(f"{p.label[:3]} {p.planned_at:%H:%M:%S}" for p in plan)) logger.info("plan de %s: %s", day, [str(p) for p in plan]) self.wake() return self.store.get_plan(day) async def cancel_pending_today(self, reason: str) -> None: day = self.today() for r in self.store.pending(day): self.store.set_state(day, r["idx"], "cancelled", note=reason) self.wake() # ------------------------------------------------------------------- fichar async def fire(self, day: date, idx: int, reason: str = "") -> str: row = self.store.get_check(day, idx) if row is None: return "🟥 Ese fichaje ya no existe en el plan." if row["state"] != "pending": return f"ℹ️ Ese fichaje ya estaba en estado {row['state']}." typ = row["typ"] lab = "ENTRADA" if typ == ENTRADA else "SALIDA" planned = datetime.fromisoformat(row["planned_at"]) retraso = (self.now() - planned).total_seconds() try: res = await asyncio.to_thread(self.executor.do_check, typ) except Exception as e: logger.exception("fallo al fichar %s idx=%s", day, idx) self.store.set_state(day, idx, "failed", note=str(e)[:200]) self.store.log("error", f"fichaje {lab} fallido: {e}") txt = f"🟥 No se pudo fichar {lab}\n{e}" await self.notify(txt) return txt self.store.set_state(day, idx, "done", fired_at=self.now(), check_id=res.get("check_id"), note=reason or None) self.store.log("check", f"{lab} -> {res.get('recorded')} (real {res.get('real')})") txt = check_result_text(lab, res) if retraso > 45: txt += f"\n⚠️ Se ejecutó {int(retraso)}s tarde respecto del plan." if reason: txt += f"\n{reason}" return txt # -------------------------------------------------------------------- bucle def _next_event(self) -> datetime | None: """Instante del proximo evento accionable.""" day = self.today() best: datetime | None = None for r in self.store.pending(day): t = datetime.fromisoformat(r["planned_at"]) cand = t if r["announced_at"] else t - ANNOUNCE_LEAD cand = max(cand, self.now()) if best is None or cand < best: best = cand # replanificacion del dia siguiente manana = (self.now() + timedelta(days=1)).replace( hour=REPLAN_HOUR, minute=0, second=0, microsecond=0) return min(best, manana) if best else manana async def tick(self) -> None: now = self.now() if self._planned_for != now.date(): await self.ensure_plan() if self.is_paused(): return for r in self.store.pending(now.date()): planned = datetime.fromisoformat(r["planned_at"]) if now >= planned: txt = await self.fire(now.date(), r["idx"]) await self.notify(txt) continue if not r["announced_at"] and now >= planned - ANNOUNCE_LEAD: lab = "ENTRADA" if r["typ"] == ENTRADA else "SALIDA" falta = int((planned - now).total_seconds() // 60) b = await asyncio.to_thread(self.executor.balance) msg = await self.notify( f"⏳ {lab} programada a las {planned:%H:%M:%S}" f" (en {falta} min)\nSaldo acumulado: {hm(b['total'])}", pending_keyboard(now.date(), r["idx"])) self.store.mark_announced(now.date(), r["idx"], msg.message_id if msg else None) async def _loop(self) -> None: while not self._stop.is_set(): try: await self.tick() except Exception as e: logger.exception("fallo en el tick") self.store.log("error", f"tick: {e}") await self.notify(f"🟥 Error interno del servicio: {e}") nxt = self._next_event() delay = max(0.5, min(MAX_SLEEP, (nxt - self.now()).total_seconds())) self._wake.clear() try: await asyncio.wait_for(self._wake.wait(), timeout=delay) except asyncio.TimeoutError: pass # ------------------------------------------------------------------ arranque async def run(self) -> None: token = (self.cfg.get("telegram") or {}).get("token") if not token: raise ValueError("falta telegram.token en config.json") self.app = Application.builder().token(token).job_queue(None).build() self.ui = BotUI(self) self.ui.register(self.app) await asyncio.to_thread(self.executor.ensure_login) await self.app.initialize() await self.app.start() await self.app.updater.start_polling(drop_pending_updates=True) logger.info("bot de Telegram escuchando") try: await self.ui.publish_menu() except Exception: logger.exception("no se pudo publicar el menu de comandos") await self.ensure_plan() plan = self.store.get_plan(self.today()) resumen = ("\n".join( f"· {datetime.fromisoformat(r['planned_at']):%H:%M:%S} " f"{'ENTRADA' if r['typ'] == ENTRADA else 'SALIDA'}" for r in plan) or "hoy no toca fichar") await self.notify(f"🟢 Autoficher arrancado\n{resumen}") self.store.log("arranque", f"servicio iniciado, {len(plan)} fichajes planificados") loop = asyncio.get_running_loop() for sig in (signal.SIGINT, signal.SIGTERM): loop.add_signal_handler(sig, self._stop.set) task = asyncio.create_task(self._loop()) await self._stop.wait() logger.info("parando…") task.cancel() await self.notify("🔴 Autoficher detenido") await self.app.updater.stop() await self.app.stop() await self.app.shutdown() self.store.close() def main() -> None: logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S") logging.getLogger("httpx").setLevel(logging.WARNING) cfg = settings.load() asyncio.run(Service(cfg, settings.pin(cfg)).run()) if __name__ == "__main__": main()