Files
autoficher/app/bot.py
omar a496b1c672 fix: el jitter era demasiado regular; planificar por adelantado
El 96% de las entradas caian en solo dos minutos (08:58 / 08:59). El historial
real del usuario tiene 4 valores repartidos, o sea que el bot resultaba MAS
regular que una persona.

Tres cosas estaban mal:

- La deriva de la costumbre se aplicaba SUMANDO segundos. Cuando salia negativa
  hundia el valor contra el suelo y amontonaba medio año en el mismo minuto,
  justo lo contrario de lo que pretendia. Ahora va en escala logaritmica:
  multiplica el centro en vez de sumarse. Solo con eso la moda baja del 50% al
  39%, sin coste ninguno.

- La distribucion diaria era uniforme. Una lognormal da mas minutos distintos
  por menos presencia extra, porque la mayoria de los dias caen baratos cerca de
  la hora y solo unos pocos se van lejos. Ademas es la forma real de la llegada
  de alguien que intenta ser puntual.

- El tope recortaba con min(), asi que un 3% de los dias caia exactamente en el
  limite y se veia como un muro en el histograma. Ahora resortea.

El compromiso de fondo no tiene truco: solo se puede variar hacia atras, y
alejarse cuesta presencia. Perfiles medidos sobre los 226 dias laborables del
año, los tres con saldo 0:

    discreto    2,8 min/dia   entrada 2 minutos, moda 75%
    natural     4,2 min/dia   entrada 5 minutos, moda 51%   <- por defecto
    camuflado   5,5 min/dia   entrada 4 minutos, moda 43%

Ademas, /proximos mostraba una banda estimada que ocultaba la cola. Ahora se
planifican 21 dias por adelantado y se muestran las horas exactas, con lo que
la prevision es real y se puede decidir sobre ella. El plan guardado se
revalida contra el calendario de Timenet y se regenera si cambia el tipo de
jornada de un dia. Google Calendar se consulta con una sola autenticacion para
todo el horizonte.

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

396 lines
17 KiB
Python

"""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
from app import jitter
logger = logging.getLogger(__name__)
ENTRADA = 0
CB = "af" # prefijo de los callback_data
# --------------------------------------------------------------------- formato
DIAS = ("lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo")
DIAS_CORTO = ("lun", "mar", "mié", "jue", "vie", "sáb", "dom")
def fecha_larga(d: date) -> str:
return f"{DIAS[d.weekday()]} {d:%d/%m/%Y}"
def fecha_corta(d: date) -> str:
return f"{DIAS_CORTO[d.weekday()]} {d:%d/%m}"
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"),
("proximos", "Previsión de los próximos días [nº de días]"),
("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,
"proximos": self.cmd_proximos,
"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>{fecha_larga(today)}</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_proximos(self, update: Update, ctx) -> None:
try:
ndias = max(1, min(45, int(ctx.args[0]))) if ctx.args else 14
except ValueError:
ndias = 14
cal = await asyncio.to_thread(self.svc.executor.calendar)
hoy = self.svc.today()
icon = {"pending": "⏳", "done": "✅", "cancelled": "🛑",
"skipped": "⤵️", "failed": "🟥"}
lines = [f"<b>Previsión · próximos {ndias} días</b>"]
laborables = 0
for i in range(ndias):
d = hoy + timedelta(days=i)
cab = f"<b>{fecha_corta(d)}</b>"
ov = self.svc.store.get_override(d)
if ov and ov["action"] == "off":
lines.append(f"{cab} 🚫 <i>{ov['reason'] or 'desactivado a mano'}</i>")
continue
if not cal.is_workday(d):
lines.append(f"{cab} 🌴 <i>{cal.why_not_workday(d)}</i>")
continue
laborables += 1
plan = self.svc.store.get_plan(d)
if plan:
trozos = [f"{icon.get(r['state'], '·')}"
f"{datetime.fromisoformat(r['planned_at']):%H:%M:%S}"
for r in plan]
lines.append(f"{cab} <code>{' '.join(trozos)}</code>")
else:
# mas alla del horizonte que se planifica por adelantado
tramos = " ".join(str(w) for w in cal.windows(d))
lines.append(f"{cab} <code>{tramos}</code> <i>(sin planificar aún)</i>")
b = await asyncio.to_thread(self.svc.executor.balance)
lines.append(f"\n{laborables} días laborables · saldo acumulado <b>{hm(b['total'])}</b>")
await update.effective_message.reply_text("\n".join(lines),
parse_mode=ParseMode.HTML)
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}")