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:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,4 +1,10 @@
|
||||
config.json
|
||||
googleoauth.json
|
||||
app/__pycache__
|
||||
__pycache__/
|
||||
*.pyc
|
||||
venv
|
||||
data/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.env
|
||||
|
||||
136
README.md
136
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 <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
337
app/bot.py
Normal 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
283
app/daemon.py
Normal 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
6
autoficherd.py
Executable 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
34
deploy/autoficher.service
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user