diff --git a/.gitignore b/.gitignore index bf9a65d..bed0068 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,10 @@ config.json googleoauth.json -app/__pycache__ +__pycache__/ +*.pyc venv +data/ +*.db +*.db-wal +*.db-shm +.env diff --git a/README.md b/README.md index ce3b260..9037753 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,118 @@ Autoficher para Timenet -============================== +======================= -Que es esto? +Ficha y desficha automáticamente en Timenet, controlado desde Telegram. + +Cómo calcula Timenet (y por qué importa) +---------------------------------------- + +Verificado sobre 6 meses de historial (104 días laborables, 376 fichajes): + +``` +trabajado = Σ min(salida, fin_teórico) − max(entrada, inicio_teórico) +``` + +Timenet **recorta a la ventana teórica**. La API guarda dos horas por fichaje: `HR` +(la que fichaste) y `H` (la que te cuentan). De 376 fichajes, 136 llevaban ajuste: +**todos salidas, todos recortados hacia atrás**. Ninguna entrada ajustada jamás. + +Consecuencias, y son todo el diseño de este programa: + +* **Sólo importan las entradas.** Llegar tarde se debita al minuto; salir tarde no + acredita nada. Por eso las entradas se fichan *antes* de la hora y las salidas + *después*. +* **Acabar el mes en positivo es imposible.** 0 días con saldo positivo en 108, y + `timeExtraHours: false` en todos los tipos de jornada. El objetivo es 0 clavado. +* El calendario laboral (festivos, vacaciones, horarios) lo da `cp/calendar`. No hay + que adivinarlo. Google Calendar queda sólo para ausencias personales. + +Servicio (recomendado) +---------------------- + +```bash +pip install -r requirements.txt # requiere python-telegram-bot >= 22 +cp sample.config.json config.json # y edítalo +echo "TIMENET_PIN=1234" > .env && chmod 600 .env + +cp deploy/autoficher.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now autoficher.service +sudo loginctl enable-linger $USER # para que sobreviva al logout y al reinicio +``` + +```bash +systemctl --user status autoficher # estado +journalctl --user -u autoficher -f # logs en vivo +systemctl --user restart autoficher # recargar tras tocar config +``` + +Comandos de Telegram +-------------------- + +Se registran solos en el menú del chat al arrancar. + +| comando | qué hace | +|---|---| +| `/hoy` | plan y estado de hoy, con lo que Timenet ha registrado de verdad | +| `/saldo` | saldo del mes y acumulado | +| `/plan` | plan de hoy, con botón para re-tirar los dados | +| `/fichar` `/desfichar` | fichaje manual inmediato | +| `/off [hoy\|dd/mm\|dd/mm-dd/mm]` | no fichar esos días | +| `/on [fecha]` | volver a fichar ese día | +| `/pausa` `/reanudar` | parar o retomar el servicio | +| `/log` | últimos eventos | + +Doce minutos antes de cada fichaje llega un aviso con killswitch: + +``` +⏳ ENTRADA programada a las 08:59:22 (en 12 min) +[🛑 Cancelar hoy] [⏭ +15 min] [✅ Fichar ya] +``` + +El bot sólo atiende al `chat_id` de `config.json`; cualquier otro chat se ignora. + +Configuración +------------- + +Lo esencial va en `config.json` (ver `sample.config.json`). Los valores por defecto +del jitter están en `app/settings.py`: + +| perfil | `entry_before` / `exit_after` (s) | presencia extra | colchón mínimo | +|---|---|---|---| +| holgado | 40-150 / 30-130 | 5,67 min/día | 30 s | +| **equilibrado** (por defecto) | 30-100 / 20-70 | 3,55 min/día | 20 s | +| ajustado | 20-70 / 15-45 | 2,70 min/día | 15 s | + +Los tres dan saldo 0; cambia cuánta presencia real cuestan y cuánto retraso del +daemon aguantan antes de perder un minuto. + +`geo_jitter_meters` desplaza la posición dentro de un disco de ese radio. Sin él, +las coordenadas salen idénticas al sexto decimal en todos los fichajes. + +Herramientas +------------ + +```bash +./venv/bin/python3 tools/tn_inspect.py # calendario y saldo, contra la API real +./venv/bin/python3 tools/dryrun.py # simula el año y verifica que no pierde minutos +./venv/bin/python3 tools/test_daemon.py # 19 pruebas con reloj simulado, sin red +``` + +Modo antiguo (CLI de un disparo) +-------------------------------- + +`main.py` sigue funcionando para fichajes puntuales desde consola o cron: + +``` +python3 main.py -p [-t <0=Entrada, 1=Salida> | -bt] [-f] [-d] +``` + +No aplica la corrección del sesgo ni consulta el calendario de Timenet: pierde de 3 +a 5 minutos al día. Úsalo sólo como respaldo manual. + +Google Calendar --------------- -Aplicación simple para fichar y desfichar automáticamente -Como se usa? ---------------- -1. Ejecutamos `pip3 install -r requirements.txt` para instalar los componentes -2. Ejecutamos `python3 ./autofitcher.py -u -p [-t <0 = Entrada, 1 = Salida>| -bt]` -3. Opcionalmente podemos ponerlo en un Crontab de linux para que se ejecute cada X tiempo y se realize el fichado automatico -Parametros obligatorios ---------------- - - -u > Usuario de timenet (Pista Cero = 4c26cc59-ee52-4c47-b7fe-1065a5e5bf84) (Lo he pasado a config.json) - - -p > Pin de usuario - -Tendremos que escojer también entre --basedtime o --type - - - -bt > No requiere argumento. Esto ficha y desficha basado en la configuración de horas a seguir - - -t <0 = Entrada, 1 = Salida> > Forzamos estado al entrar/salir - -Calendario google ---------------- -Para añadir el uso de calendario de google debemos de descargar el archivo de acceso oauth API y añadirlo -a la raiz del proyecto como "googleoauth.json" se revisará calendario de festivos en españa y calendario autoficher. +Para ausencias personales. Descarga el OAuth de la API de Google y déjalo en la raíz +como `googleoauth_grant.json`; el token se guarda en `googleoauth.json`. Se consulta +el calendario `autoficher`: si un evento cubre el día, no se ficha. diff --git a/app/bot.py b/app/bot.py new file mode 100644 index 0000000..506a27a --- /dev/null +++ b/app/bot.py @@ -0,0 +1,337 @@ +"""Interfaz de Telegram: comandos, teclados inline y killswitch. + +Solo atiende al ``chat_id`` configurado. Cualquier otro chat se ignora en silencio. +""" +from __future__ import annotations + +import asyncio +import logging +from datetime import date, datetime, timedelta + +from telegram import (BotCommand, BotCommandScopeChat, InlineKeyboardButton, + InlineKeyboardMarkup, Update) +from telegram.constants import ParseMode +from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes + +logger = logging.getLogger(__name__) + +ENTRADA = 0 +CB = "af" # prefijo de los callback_data + + +# --------------------------------------------------------------------- formato +def hm(minutes: int) -> str: + """Minutos -> '+1h 23m' / '-4m' / '0m'.""" + if not minutes: + return "0m" + sign = "-" if minutes < 0 else "+" + m = abs(int(minutes)) + return f"{sign}{m // 60}h {m % 60:02d}m" if m >= 60 else f"{sign}{m}m" + + +def parse_day(txt: str, today: date) -> date | None: + t = (txt or "").strip().lower() + if t in ("", "hoy"): + return today + if t in ("mañana", "manana"): + return today + timedelta(days=1) + if t == "ayer": + return today - timedelta(days=1) + for fmt in ("%d/%m/%Y", "%d/%m/%y", "%d/%m", "%Y-%m-%d"): + try: + d = datetime.strptime(t, fmt).date() + return d.replace(year=today.year) if fmt == "%d/%m" else d + except ValueError: + continue + return None + + +def parse_range(args: list[str], today: date) -> list[date]: + """'hoy', '05/10', '05/10-09/10' -> lista de fechas.""" + txt = " ".join(args).strip() + if "-" in txt and not txt.startswith("-"): + a, _, b = txt.partition("-") + da, db = parse_day(a, today), parse_day(b, today) + if da and db and da <= db: + return [da + timedelta(days=i) for i in range((db - da).days + 1)] + return [] + d = parse_day(txt, today) + return [d] if d else [] + + +# ------------------------------------------------------------------------ teclado +def check_result_text(lab: str, res: dict, manual: bool = False) -> str: + """Resultado de un fichaje, contando si Timenet lo ha recortado.""" + if res.get("repeated"): + return f"🕐 {lab}: Timenet dice que ya estaba fichado." + rec, real = res.get("recorded"), res.get("real") + head = f"{'✅ (manual)' if manual else '✅'} {lab} registrada" + if rec and real and rec != real: + return f"{head}\nFichado {real} → cuenta como {rec}" + if rec: + return f"{head} a las {rec}" + return f"{head} (sin confirmación de hora)" + + +def pending_keyboard(day: date, idx: int) -> InlineKeyboardMarkup: + d = day.isoformat() + return InlineKeyboardMarkup([[ + InlineKeyboardButton("🛑 Cancelar hoy", callback_data=f"{CB}:cancel:{d}:{idx}"), + InlineKeyboardButton("⏭ +15 min", callback_data=f"{CB}:delay:{d}:{idx}"), + InlineKeyboardButton("✅ Fichar ya", callback_data=f"{CB}:now:{d}:{idx}"), + ]]) + + +# Lo que Telegram mostrara en el menu de comandos. Orden = orden en el menu. +MENU: list[tuple[str, str]] = [ + ("hoy", "Plan y estado de hoy"), + ("saldo", "Saldo del mes y acumulado"), + ("plan", "Ver el plan y re-tirar los dados"), + ("fichar", "Fichar ENTRADA ahora"), + ("desfichar", "Fichar SALIDA ahora"), + ("off", "No fichar un dia [hoy|dd/mm|dd/mm-dd/mm]"), + ("on", "Volver a fichar ese dia"), + ("pausa", "Parar el servicio"), + ("reanudar", "Retomar el servicio"), + ("log", "Ultimos eventos"), + ("ayuda", "Ayuda"), +] + + +class BotUI: + def __init__(self, service): + self.svc = service + self.chat_id = int((service.cfg.get("telegram") or {}).get("chat_id")) + + # -------------------------------------------------------------- utilidades + def _mine(self, update: Update) -> bool: + chat = update.effective_chat + if chat is None or chat.id != self.chat_id: + logger.warning("mensaje ignorado de chat no autorizado: %s", + chat.id if chat else "?") + return False + return True + + def register(self, app: Application) -> None: + cmds = { + "start": self.cmd_hoy, "help": self.cmd_help, "ayuda": self.cmd_help, + "hoy": self.cmd_hoy, "saldo": self.cmd_saldo, "plan": self.cmd_plan, + "off": self.cmd_off, "on": self.cmd_on, + "fichar": self.cmd_fichar, "desfichar": self.cmd_desfichar, + "pausa": self.cmd_pausa, "reanudar": self.cmd_reanudar, + "log": self.cmd_log, + } + for name, fn in cmds.items(): + app.add_handler(CommandHandler(name, self._guard(fn))) + app.add_handler(CallbackQueryHandler(self.on_button, pattern=rf"^{CB}:")) + + def _guard(self, fn): + async def wrapper(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + if not self._mine(update): + return + try: + await fn(update, ctx) + except Exception as e: + logger.exception("fallo en %s", fn.__name__) + await update.effective_message.reply_text(f"🟥 Error en el comando: {e}") + return wrapper + + async def send(self, text: str, keyboard=None): + return await self.svc.app.bot.send_message( + chat_id=self.chat_id, text=text, reply_markup=keyboard, + parse_mode=ParseMode.HTML, disable_notification=False) + + # ---------------------------------------------------------------- comandos + async def cmd_help(self, update: Update, ctx) -> None: + cuerpo = "\n".join(f"/{c} — {d}" for c, d in MENU) + await update.effective_message.reply_text( + f"Autoficher\n\n{cuerpo}", parse_mode=ParseMode.HTML) + + async def publish_menu(self) -> None: + """Registra los comandos en Telegram para que salgan en el menu del chat.""" + cmds = [BotCommand(c, d) for c, d in MENU] + await self.svc.app.bot.set_my_commands( + cmds, scope=BotCommandScopeChat(chat_id=self.chat_id)) + logger.info("menu de comandos publicado en Telegram (%d comandos)", len(cmds)) + + async def cmd_hoy(self, update: Update, ctx) -> None: + await update.effective_message.reply_text(await self._status_text(), + parse_mode=ParseMode.HTML) + + async def _status_text(self) -> str: + today = self.svc.today() + lines = [f"{today:%A %d/%m/%Y}"] + + cal = await asyncio.to_thread(self.svc.executor.calendar) + if not cal.is_workday(today): + lines.append(f"🌴 No laborable: {cal.why_not_workday(today)}") + else: + dt = cal.day_type(today) + lines.append(f"🗓 {dt.name} · previsto {dt.minutes} min") + + ov = self.svc.store.get_override(today) + if ov: + lines.append(f"🚫 Desactivado a mano: {ov['reason'] or 'sin motivo'}") + if self.svc.is_paused(): + lines.append("⏸ Servicio en pausa") + + plan = self.svc.store.get_plan(today) + if plan: + lines.append("\nPlan de hoy") + icon = {"pending": "⏳", "done": "✅", "cancelled": "🛑", + "skipped": "⤵️", "failed": "🟥"} + for r in plan: + t = datetime.fromisoformat(r["planned_at"]) + lab = "ENTRADA" if r["typ"] == ENTRADA else "SALIDA " + extra = "" + if r["state"] == "done" and r["fired_at"]: + extra = f" → fichado {datetime.fromisoformat(r['fired_at']):%H:%M:%S}" + lines.append(f"{icon.get(r['state'], '·')} {t:%H:%M:%S} {lab}{extra}") + + try: + s = await asyncio.to_thread(self.svc.executor.today_summary) + if s["checks"]: + lines.append("\nSegún Timenet") + for c in s["checks"]: + lab = "entrada" if c["typ"] == ENTRADA else "salida " + mark = "" if c["recorded"] == c["real"] else f" (fichado {c['real']}, recortado)" + lines.append(f"· {lab} {c['recorded']}{mark}") + lines.append(f"trabajado {s['W']}/{s['P']} min · saldo del día {hm(s['X'])}") + except Exception as e: + lines.append(f"\n⚠️ No se pudo leer Timenet: {e}") + return "\n".join(lines) + + async def cmd_saldo(self, update: Update, ctx) -> None: + b = await asyncio.to_thread(self.svc.executor.balance) + await update.effective_message.reply_text( + f"Saldo\n" + f"Mes en curso: previsto {b['P']} min · trabajado {b['W']} min\n" + f"Saldo del mes: {hm(b['X'])}\n" + f"Arrastrado: {hm(b['XAnt'])}\n" + f"━━━━━━━━━━━━━\n" + f"Acumulado: {hm(b['total'])}", + parse_mode=ParseMode.HTML) + + async def cmd_plan(self, update: Update, ctx) -> None: + kb = InlineKeyboardMarkup([[InlineKeyboardButton( + "🎲 Re-tirar el plan de hoy", callback_data=f"{CB}:replan:{self.svc.today()}:0")]]) + await update.effective_message.reply_text(await self._status_text(), + parse_mode=ParseMode.HTML, reply_markup=kb) + + async def cmd_off(self, update: Update, ctx) -> None: + days = parse_range(ctx.args, self.svc.today()) + if not days: + await update.effective_message.reply_text( + "Uso: /off [hoy | mañana | dd/mm | dd/mm-dd/mm]") + return + for d in days: + self.svc.store.set_override(d, "off", "desde Telegram") + self.svc.store.log("override", f"off {days[0]}..{days[-1]}") + if self.svc.today() in days: + await self.svc.cancel_pending_today("desactivado a mano") + await update.effective_message.reply_text( + f"🚫 No se fichará {'el ' + days[0].strftime('%d/%m') if len(days) == 1 else f'del {days[0]:%d/%m} al {days[-1]:%d/%m} ({len(days)} días)'}") + + async def cmd_on(self, update: Update, ctx) -> None: + days = parse_range(ctx.args, self.svc.today()) + if not days: + await update.effective_message.reply_text("Uso: /on [hoy | dd/mm | dd/mm-dd/mm]") + return + for d in days: + self.svc.store.clear_override(d) + if self.svc.today() in days: + await self.svc.ensure_plan(force=True) + await update.effective_message.reply_text( + f"✅ Reactivado ({len(days)} día{'s' if len(days) > 1 else ''})") + + async def cmd_fichar(self, update: Update, ctx) -> None: + await self._manual(update, ENTRADA) + + async def cmd_desfichar(self, update: Update, ctx) -> None: + await self._manual(update, 1) + + async def _manual(self, update: Update, typ: int) -> None: + lab = "ENTRADA" if typ == ENTRADA else "SALIDA" + msg = await update.effective_message.reply_text(f"⏳ Fichando {lab} a mano…") + try: + res = await asyncio.to_thread(self.svc.executor.do_check, typ) + self.svc.store.log("manual", f"{lab} -> {res.get('recorded')}") + await msg.edit_text(check_result_text(lab, res, manual=True), + parse_mode=ParseMode.HTML) + except Exception as e: + logger.exception("fallo en fichaje manual") + await msg.edit_text(f"🟥 No se pudo fichar {lab}: {e}") + + async def cmd_pausa(self, update: Update, ctx) -> None: + self.svc.set_paused(True) + await self.svc.cancel_pending_today("servicio en pausa") + await update.effective_message.reply_text("⏸ Servicio en pausa. /reanudar para volver.") + + async def cmd_reanudar(self, update: Update, ctx) -> None: + self.svc.set_paused(False) + await self.svc.ensure_plan(force=True) + await update.effective_message.reply_text("▶️ Servicio reanudado.\n\n" + + await self._status_text(), + parse_mode=ParseMode.HTML) + + async def cmd_log(self, update: Update, ctx) -> None: + ev = self.svc.store.recent_events(15) + if not ev: + await update.effective_message.reply_text("Sin eventos todavía.") + return + txt = "\n".join(f"{e['ts'][5:16]} {e['kind']}: {e['msg'][:80]}" + for e in ev) + await update.effective_message.reply_text(f"Últimos eventos\n{txt}", + parse_mode=ParseMode.HTML) + + # --------------------------------------------------------------- callbacks + async def on_button(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + q = update.callback_query + if not self._mine(update): + await q.answer() + return + try: + _, action, day_s, idx_s = q.data.split(":") + day, idx = date.fromisoformat(day_s), int(idx_s) + except ValueError: + await q.answer("Botón no reconocido") + return + + try: + if action == "cancel": + self.svc.store.set_state(day, idx, "cancelled", note="cancelado desde Telegram") + self.svc.store.log("killswitch", f"cancelado {day} idx={idx}") + await q.answer("Cancelado") + await q.edit_message_text("🛑 Fichaje cancelado desde Telegram.", + parse_mode=ParseMode.HTML) + + elif action == "delay": + row = self.svc.store.get_check(day, idx) + if not row or row["state"] != "pending": + await q.answer("Ya no está pendiente") + return + nuevo = datetime.fromisoformat(row["planned_at"]) + timedelta(minutes=15) + self.svc.store.reschedule(day, idx, nuevo) + self.svc.wake() + await q.answer("Retrasado 15 min") + await q.edit_message_text( + f"⏭ Fichaje retrasado a las {nuevo:%H:%M:%S}.", + parse_mode=ParseMode.HTML) + + elif action == "now": + await q.answer("Fichando…") + await q.edit_message_text("⏳ Fichando ahora…") + ok = await self.svc.fire(day, idx, reason="botón «fichar ya»") + await q.edit_message_text(ok, parse_mode=ParseMode.HTML) + + elif action == "replan": + await self.svc.ensure_plan(force=True) + await q.answer("Plan regenerado") + await q.edit_message_text(await self._status_text(), + parse_mode=ParseMode.HTML) + else: + await q.answer("Acción desconocida") + except Exception as e: + logger.exception("fallo en callback %s", q.data) + await q.answer("Error") + await self.send(f"🟥 Error procesando el botón: {e}") diff --git a/app/daemon.py b/app/daemon.py new file mode 100644 index 0000000..c08dfd1 --- /dev/null +++ b/app/daemon.py @@ -0,0 +1,283 @@ +"""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() diff --git a/autoficherd.py b/autoficherd.py new file mode 100755 index 0000000..90cdd55 --- /dev/null +++ b/autoficherd.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +"""Punto de entrada del servicio autoficher.""" +from app.daemon import main + +if __name__ == "__main__": + main() diff --git a/deploy/autoficher.service b/deploy/autoficher.service new file mode 100644 index 0000000..5a6344b --- /dev/null +++ b/deploy/autoficher.service @@ -0,0 +1,34 @@ +[Unit] +Description=Autoficher - fichaje automatico en Timenet +After=network-online.target +Wants=network-online.target +# si entra en bucle de fallos, deja de reintentar en vez de martillear la API +StartLimitIntervalSec=600 +StartLimitBurst=6 + +[Service] +Type=simple +WorkingDirectory=%h/autoficher +ExecStart=%h/autoficher/venv/bin/python3 %h/autoficher/autoficherd.py +EnvironmentFile=%h/autoficher/.env +Environment=PYTHONUNBUFFERED=1 + +Restart=always +RestartSec=30 +# margen para cerrar sesion de Telegram y avisar antes de morir +TimeoutStopSec=30 +KillSignal=SIGTERM + +# endurecimiento: solo puede escribir en su propio directorio +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ReadWritePaths=%h/autoficher +ProtectKernelTunables=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +RestrictRealtime=yes +LockPersonality=yes + +[Install] +WantedBy=default.target diff --git a/requirements.txt b/requirements.txt index e375b72..c712f33 100644 --- a/requirements.txt +++ b/requirements.txt @@ -49,7 +49,7 @@ pyparsing==3.1.1 pyrsistent==0.15.5 python-crontab==3.0.0 python-dateutil==2.8.2 -python-telegram-bot +python-telegram-bot>=22.0 # <22 rompe en Python 3.13 (__slots__ en Updater) pytz PyYAML requests==2.32.3