Files
autoficher/app/daemon.py
omar 5ba5710db2 feat: daemon con bot de Telegram, killswitch y servicio systemd
Pasa de un cron ciego a un servicio en segundo plano. El killswitch lo exige:
para poder cancelar un fichaje pendiente desde el movil tiene que haber un
proceso escuchando durante la espera.

- daemon: bucle propio que duerme exactamente hasta el siguiente evento, con
  precision de decimas. No usa JobQueue de PTB porque APScheduler 3.6 importa
  pkg_resources, que ya no existe en Python 3.13; ademas asi no se añade una
  dependencia. Recupera fichajes perdidos si el equipo estuvo apagado.
- bot: /hoy /saldo /plan /fichar /desfichar /off /on /pausa /reanudar /log.
  Los comandos se registran con setMyCommands para que salgan en el menu del
  chat. Solo atiende al chat_id configurado.
- killswitch: aviso 12 min antes con teclado inline (cancelar / +15 min /
  fichar ya).
- Google Calendar queda solo para ausencias personales; festivos y vacaciones
  salen del calendario de Timenet.
- deploy/autoficher.service: unidad de usuario endurecida (ProtectSystem
  strict, escritura solo en el propio directorio), Restart=always.
- requirements: python-telegram-bot >= 22. La 20.5 tiene un bug de __slots__
  en Updater que rompe el polling en Python 3.13.
- .gitignore: data/, *.db, .env, __pycache__ en cualquier nivel.

tools/test_daemon.py cubre con reloj simulado y sin red: plan, aviso previo,
fichaje a su hora, killswitch, retraso de 15 min, pausa, recuperacion tras
apagon, fallo de red y dia no laborable. 19 comprobaciones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 18:51:46 +02:00

284 lines
11 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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: <i>{ev}</i>\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: <i>{ausencia}</i> (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 <b>{row['state']}</b>."
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"🟥 <b>No se pudo fichar {lab}</b>\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<i>{reason}</i>"
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"⏳ <b>{lab}</b> programada a las <code>{planned:%H:%M:%S}</code>"
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"· <code>{datetime.fromisoformat(r['planned_at']):%H:%M:%S}</code> "
f"{'ENTRADA' if r['typ'] == ENTRADA else 'SALIDA'}" for r in plan)
or "<i>hoy no toca fichar</i>")
await self.notify(f"🟢 <b>Autoficher arrancado</b>\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("🔴 <b>Autoficher detenido</b>")
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()