Cinco graficos generados con matplotlib y enviados como foto a Telegram, con
botones para elegir. La paleta es la de referencia del proyecto, validada con el
comprobador de daltonismo en claro y oscuro (peor par CVD delta-E 24.7 / 26.8,
umbral 8).
- saldo acumulado (linea) + saldo de cada dia (barras), dos paneles
- puntualidad desviacion de cada entrada en el tiempo. Es el que explica el
problema de un vistazo: en 6 meses no hay ni un solo punto por
debajo de cero, o sea que nunca se ha llegado antes de la hora
- horas histograma de la hora de entrada, historico frente a planificado.
Las dos distribuciones no se solapan en absoluto
- regalado minutos al mes presente sin que corra el reloj
- prevision linea de tiempo de los proximos dias con la franja que cuenta
Un PNG no tiene hover, asi que las cifras que en una pagina resolveria el
tooltip van en el pie de foto y en etiquetas directas sobre las marcas.
Al mirarlos renderizados aparecieron cuatro defectos que el codigo no delata:
titulo y subtitulo pisandose, la etiqueta del peor dia chocando con el eje de
fechas, un dia de +64 min aplastando el resto del eje contra el cero, y un bug
de formato que pintaba "09:64" en vez de "10:04". Corregidos: ejes acotados con
los valores extremos anotados aparte, y las horas formateadas con un helper.
Executor.historico() extrae los dias de varios meses ya parseados; el servicio
lo cachea media hora porque son seis llamadas a la API.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
556 lines
25 KiB
Python
556 lines
25 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 charts, jitter, profiles
|
|
|
|
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"),
|
|
("perfil", "Ver y cambiar el perfil de jitter"),
|
|
("proximos", "Previsión de los próximos días [nº de días]"),
|
|
("grafico", "Gráficos: saldo, puntualidad, previsión…"),
|
|
("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, "perfil": self.cmd_perfil,
|
|
"grafico": self.cmd_grafico, "graficos": self.cmd_grafico,
|
|
"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)
|
|
|
|
def _perfil_texto(self) -> str:
|
|
actual = self.svc.store.get("perfil", profiles.DEFECTO)
|
|
lines = ["<b>Perfil de jitter</b>",
|
|
"Cuánta variedad tienen las horas de fichaje, y lo que cuesta "
|
|
"en minutos de estar ahí. Todos dan saldo 0.\n"]
|
|
for n in profiles.nombres():
|
|
st = profiles.stats(n)
|
|
marca = "▶️ " if n == actual else "· "
|
|
lines.append(
|
|
f"{marca}<b>{profiles.titulo(n)}</b> — {st['coste']:.1f} min/día "
|
|
f"(madrugón {st['madrugon']:.1f}) · {st['mins']} minutos, moda {st['moda']}%")
|
|
lines.append(f" <i>{profiles.resumen(n)}</i>")
|
|
lines.append("\n<i>madrugón = de esos minutos, los que te levantas antes "
|
|
"por la entrada de la mañana. Tu histórico real: 4 minutos, moda 38%.</i>")
|
|
real = self._histograma_real()
|
|
if real:
|
|
lines.append("\n" + real)
|
|
return "\n".join(lines)
|
|
|
|
def _histograma_real(self) -> str:
|
|
"""Lo que hay sorteado ahora mismo, para poder ver el efecto del cambio."""
|
|
from collections import Counter
|
|
hoy = self.svc.today()
|
|
c: Counter = Counter()
|
|
for i in range(60):
|
|
for r in self.svc.store.get_plan(hoy + timedelta(days=i)):
|
|
t = datetime.fromisoformat(r["target"])
|
|
if r["typ"] == ENTRADA and (t.hour, t.minute) == (9, 0):
|
|
c[datetime.fromisoformat(r["planned_at"]).strftime("%H:%M")] += 1
|
|
if not c:
|
|
return ""
|
|
tot = sum(c.values())
|
|
filas = "\n".join(
|
|
f" <code>{h}</code> {'█' * n} {n}" for h, n in sorted(c.items()))
|
|
return (f"<b>Tus próximos {tot} días ya sorteados</b> (entrada de la mañana)\n"
|
|
f"{filas}\n <i>{len(c)} horas distintas · la más repetida "
|
|
f"{max(c.values())} de {tot} ({round(100 * max(c.values()) / tot)}%)</i>")
|
|
|
|
def _perfil_teclado(self) -> InlineKeyboardMarkup:
|
|
actual = self.svc.store.get("perfil", profiles.DEFECTO)
|
|
botones = [InlineKeyboardButton(
|
|
("✅ " if n == actual else "") + profiles.titulo(n),
|
|
callback_data=f"{CB}:perfil:{n}") for n in profiles.nombres()]
|
|
filas = [botones[i:i + 2] for i in range(0, len(botones), 2)]
|
|
return InlineKeyboardMarkup(filas)
|
|
|
|
async def cmd_perfil(self, update: Update, ctx) -> None:
|
|
if ctx.args:
|
|
n = ctx.args[0].strip().lower()
|
|
if not profiles.existe(n):
|
|
await update.effective_message.reply_text(
|
|
"No conozco ese perfil. Son: " + ", ".join(profiles.nombres()))
|
|
return
|
|
await self.svc.cambiar_perfil(n)
|
|
await update.effective_message.reply_text(
|
|
f"✅ Perfil <b>{profiles.titulo(n)}</b>. Replanificados los días futuros.",
|
|
parse_mode=ParseMode.HTML)
|
|
return
|
|
await update.effective_message.reply_text(
|
|
self._perfil_texto(), parse_mode=ParseMode.HTML,
|
|
reply_markup=self._perfil_teclado())
|
|
|
|
async def _cambiar_perfil(self, q, nombre: str) -> None:
|
|
if not profiles.existe(nombre):
|
|
await q.answer("Perfil desconocido")
|
|
return
|
|
await q.answer(f"Cambiando a {profiles.titulo(nombre)}…")
|
|
await self.svc.cambiar_perfil(nombre)
|
|
await q.edit_message_text(
|
|
self._perfil_texto() + "\n\n<b>Replanificados los días futuros.</b>",
|
|
parse_mode=ParseMode.HTML, reply_markup=self._perfil_teclado())
|
|
|
|
GRAFICOS = {
|
|
"saldo": "📉 Saldo acumulado",
|
|
"puntualidad": "🎯 Puntualidad",
|
|
"horas": "🕘 Hora de entrada",
|
|
"regalado": "🎁 Tiempo regalado",
|
|
"prevision": "📅 Previsión",
|
|
}
|
|
|
|
async def cmd_grafico(self, update: Update, ctx) -> None:
|
|
if ctx.args and ctx.args[0].lower() in self.GRAFICOS:
|
|
await update.effective_message.reply_text("⏳ Generando…")
|
|
await self._enviar_grafico(ctx.args[0].lower())
|
|
return
|
|
botones = [InlineKeyboardButton(txt, callback_data=f"{CB}:graf:{k}")
|
|
for k, txt in self.GRAFICOS.items()]
|
|
filas = [botones[i:i + 2] for i in range(0, len(botones), 2)]
|
|
await update.effective_message.reply_text(
|
|
"<b>Gráficos</b>\nElige uno:", parse_mode=ParseMode.HTML,
|
|
reply_markup=InlineKeyboardMarkup(filas))
|
|
|
|
async def _enviar_grafico(self, cual: str) -> None:
|
|
try:
|
|
png, pie = await self._generar(cual)
|
|
except Exception as e:
|
|
logger.exception("fallo generando el gráfico %s", cual)
|
|
await self.send(f"🟥 No se pudo generar el gráfico: {e}")
|
|
return
|
|
await self.svc.app.bot.send_photo(
|
|
chat_id=self.chat_id, photo=png, caption=pie, parse_mode=ParseMode.HTML)
|
|
|
|
async def _generar(self, cual: str) -> tuple[bytes, str]:
|
|
"""Devuelve (png, pie de foto). El pie lleva las cifras, porque una imagen
|
|
no tiene tooltip donde consultarlas."""
|
|
tema = self.svc.cfg.get("chart_theme", "dark")
|
|
|
|
if cual == "prevision":
|
|
dias = self.svc.datos_prevision()
|
|
if not dias:
|
|
raise ValueError("no hay nada planificado todavía")
|
|
png = await asyncio.to_thread(charts.prevision, dias, tema)
|
|
n = sum(len(d["checks"]) for d in dias)
|
|
return png, f"<b>{len(dias)} días</b> planificados · {n} fichajes"
|
|
|
|
hist = await self.svc.historico()
|
|
if not hist:
|
|
raise ValueError("no hay histórico que dibujar")
|
|
b = await asyncio.to_thread(self.svc.executor.balance)
|
|
|
|
if cual == "saldo":
|
|
xant = b["total"] - sum(r["X"] for r in hist)
|
|
png = await asyncio.to_thread(charts.saldo, hist, xant, tema)
|
|
peor = min(hist, key=lambda r: r["X"])
|
|
return png, (f"Acumulado <b>{hm(b['total'])}</b> · "
|
|
f"{len(hist)} días · peor día {peor['fecha']:%d/%m} ({peor['X']} min)")
|
|
|
|
if cual == "puntualidad":
|
|
png = await asyncio.to_thread(charts.puntualidad, hist, tema)
|
|
ents = [c for r in hist for c in r["checks"] if c["typ"] == ENTRADA]
|
|
return png, (f"{len(ents)} entradas · ninguna por debajo de cero: "
|
|
f"nunca has llegado antes de la hora")
|
|
|
|
if cual == "horas":
|
|
png = await asyncio.to_thread(charts.horas, hist,
|
|
self.svc.entradas_planificadas(), tema)
|
|
return png, "Naranja pierde minutos, azul no. Las dos nubes no se tocan."
|
|
|
|
if cual == "regalado":
|
|
png = await asyncio.to_thread(charts.regalado, hist, tema)
|
|
tot = sum(max(0, r["A"] - r["W"]) for r in hist)
|
|
return png, (f"<b>{tot} min</b> ({tot / 60:.1f} h) presente sin que "
|
|
f"corra el reloj, en {len(hist)} días")
|
|
|
|
raise ValueError(f"gráfico desconocido: {cual}")
|
|
|
|
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
|
|
partes = q.data.split(":")
|
|
action = partes[1] if len(partes) > 1 else ""
|
|
|
|
if action == "perfil":
|
|
await self._cambiar_perfil(q, partes[2])
|
|
return
|
|
|
|
if action == "graf":
|
|
await q.answer("Generando…")
|
|
await self._enviar_grafico(partes[2])
|
|
return
|
|
|
|
try:
|
|
day, idx = date.fromisoformat(partes[2]), int(partes[3])
|
|
except (IndexError, 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}")
|