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>
This commit is contained in:
2026-09-24 18:51:46 +02:00
parent 9294474066
commit 5ba5710db2
7 changed files with 782 additions and 24 deletions

8
.gitignore vendored
View File

@@ -1,4 +1,10 @@
config.json
googleoauth.json
app/__pycache__
__pycache__/
*.pyc
venv
data/
*.db
*.db-wal
*.db-shm
.env

136
README.md
View File

@@ -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 <pin> [-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 <user> -p <pin> [-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.

337
app/bot.py Normal file
View File

@@ -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 '✅'} <b>{lab}</b> registrada"
if rec and real and rec != real:
return f"{head}\nFichado {real} → <b>cuenta como {rec}</b>"
if rec:
return f"{head} a las <b>{rec}</b>"
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"<b>Autoficher</b>\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"<b>{today:%A %d/%m/%Y}</b>"]
cal = await asyncio.to_thread(self.svc.executor.calendar)
if not cal.is_workday(today):
lines.append(f"🌴 No laborable: <i>{cal.why_not_workday(today)}</i>")
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: <i>{ov['reason'] or 'sin motivo'}</i>")
if self.svc.is_paused():
lines.append("⏸ <b>Servicio en pausa</b>")
plan = self.svc.store.get_plan(today)
if plan:
lines.append("\n<b>Plan de hoy</b>")
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'], '·')} <code>{t:%H:%M:%S}</code> {lab}{extra}")
try:
s = await asyncio.to_thread(self.svc.executor.today_summary)
if s["checks"]:
lines.append("\n<b>Según Timenet</b>")
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} <code>{c['recorded']}</code>{mark}")
lines.append(f"trabajado {s['W']}/{s['P']} min · saldo del día <b>{hm(s['X'])}</b>")
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"<b>Saldo</b>\n"
f"Mes en curso: previsto {b['P']} min · trabajado {b['W']} min\n"
f"Saldo del mes: <b>{hm(b['X'])}</b>\n"
f"Arrastrado: {hm(b['XAnt'])}\n"
f"━━━━━━━━━━━━━\n"
f"<b>Acumulado: {hm(b['total'])}</b>",
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"<code>{e['ts'][5:16]}</code> {e['kind']}: {e['msg'][:80]}"
for e in ev)
await update.effective_message.reply_text(f"<b>Últimos eventos</b>\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("🛑 <b>Fichaje cancelado</b> 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 <b>{nuevo:%H:%M:%S}</b>.",
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}")

283
app/daemon.py Normal file
View File

@@ -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: <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()

6
autoficherd.py Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env python3
"""Punto de entrada del servicio autoficher."""
from app.daemon import main
if __name__ == "__main__":
main()

34
deploy/autoficher.service Normal file
View File

@@ -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

View File

@@ -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