diff --git a/src/bot/bot.py b/src/bot/bot.py index ca41a5d..5da2c19 100644 --- a/src/bot/bot.py +++ b/src/bot/bot.py @@ -5,8 +5,9 @@ Main user interface — all commands handled here import asyncio import os import time -from datetime import datetime +from datetime import datetime, timedelta from typing import Optional +from zoneinfo import ZoneInfo import structlog from telegram import Update, Message @@ -536,22 +537,62 @@ async def cmd_costs(update: Update, ctx: ContextTypes.DEFAULT_TYPE): await db_conn.close() +def _parse_at_time(time_str: str) -> tuple[float, int, int, bool]: + """Parse 'HH:MM' in the configured timezone. + + Returns (next_run_at_unix, hour, minute, is_today). If the time has + already passed today, schedules for tomorrow (is_today=False). + Raises ValueError if the string is not a valid HH:MM time. + """ + parts = time_str.split(":") + if len(parts) != 2 or not (parts[0].isdigit() and parts[1].isdigit()): + raise ValueError(f"Hora inválida: {time_str!r}") + hour, minute = int(parts[0]), int(parts[1]) + if not (0 <= hour <= 23 and 0 <= minute <= 59): + raise ValueError(f"Hora fuera de rango: {time_str!r}") + + tz = ZoneInfo(settings.timezone) + now_local = datetime.now(tz) + target = now_local.replace(hour=hour, minute=minute, second=0, microsecond=0) + is_today = True + if target <= now_local: + target += timedelta(days=1) + is_today = False + return target.timestamp(), hour, minute, is_today + + async def cmd_watch(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if not is_authorized(update.effective_user.id): return chat_id = update.effective_chat.id - args = ctx.args or [] + args = list(ctx.args or []) if not args: await update.message.reply_text( - "❌ Uso: `/watch [horas]`\nEjemplo: `/watch Incidente Roswell 24`", + "❌ Uso: `/watch [horas]` o `/watch --at HH:MM [horas]`\n" + "Ejemplo: `/watch Incidente Roswell 24`\n" + "Ejemplo: `/watch Incidente Roswell --at 19:30 6`", parse_mode=ParseMode.MARKDOWN ) return + # Extract optional --at HH:MM from anywhere in the args + at_time_str: Optional[str] = None + if "--at" in args: + idx = args.index("--at") + if idx + 1 >= len(args): + await update.message.reply_text( + "❌ `--at` requiere una hora en formato HH:MM. Ejemplo: `--at 19:30`", + parse_mode=ParseMode.MARKDOWN + ) + return + at_time_str = args[idx + 1] + # Remove '--at' and its value from args + del args[idx:idx + 2] + interval_hours = 24 - if args[-1].isdigit(): + if args and args[-1].isdigit(): interval_hours = int(args[-1]) topic = " ".join(args[:-1]).strip() else: @@ -567,14 +608,31 @@ async def cmd_watch(update: Update, ctx: ContextTypes.DEFAULT_TYPE): ) return + next_run_at: Optional[float] = None + when_msg = f"Primera ejecución en ~{interval_hours}h." + if at_time_str is not None: + try: + next_run_at, hour, minute, is_today = _parse_at_time(at_time_str) + except ValueError: + await update.message.reply_text( + "❌ Hora inválida. Usa el formato HH:MM (24h). Ejemplo: `--at 19:30`", + parse_mode=ParseMode.MARKDOWN + ) + return + day_word = "hoy" if is_today else "mañana" + when_msg = ( + f"Primera ejecución: {day_word} a las {hour:02d}:{minute:02d} " + f"({settings.timezone})" + ) + db_conn = await get_db() db = ResearchDB(db_conn) try: try: - await db.add_watch(topic, chat_id, interval_hours) + await db.add_watch(topic, chat_id, interval_hours, next_run_at=next_run_at) await update.message.reply_text( f"👁 Watching: `{topic}` — cada {interval_hours}h\n" - f"Primera ejecución en ~{interval_hours}h.\n" + f"{when_msg}\n" f"Usa /watches para ver todos tus temas.", parse_mode=ParseMode.MARKDOWN ) @@ -633,14 +691,27 @@ async def cmd_watches(update: Update, ctx: ContextTypes.DEFAULT_TYPE): return now = time.time() + tz = ZoneInfo(settings.timezone) + today_local = datetime.now(tz).date() lines = ["👁 *Tus temas vigilados:*\n"] for i, w in enumerate(watches, 1): secs_remaining = max(0.0, w["next_run_at"] - now) hours_remaining = secs_remaining / 3600 eta = f"{int(secs_remaining / 60)}min" if hours_remaining < 1 else f"{hours_remaining:.1f}h" status = "✅" if w["enabled"] else "⏸" + + nxt = datetime.fromtimestamp(w["next_run_at"], tz) + if nxt.date() == today_local: + day_word = "hoy" + elif nxt.date() == today_local + timedelta(days=1): + day_word = "mañana" + else: + day_word = nxt.strftime("%d/%m") + local_time = nxt.strftime("%H:%M") + lines.append( - f"{i}. {status} `{w['topic']}` — cada {w['interval_hours']}h · próxima en {eta}" + f"{i}. {status} `{w['topic']}` — cada {w['interval_hours']}h · " + f"próxima a las {local_time} ({day_word}) · en {eta}" ) await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.MARKDOWN) diff --git a/src/config.py b/src/config.py index c0c4e25..676f9e5 100644 --- a/src/config.py +++ b/src/config.py @@ -44,6 +44,7 @@ class Settings(BaseSettings): # App log_level: str = Field("INFO", env="LOG_LEVEL") + timezone: str = Field("Europe/Madrid", env="TIMEZONE") @property def allowed_user_ids(self) -> list[int]: diff --git a/src/db/database.py b/src/db/database.py index a74eebb..cc64587 100644 --- a/src/db/database.py +++ b/src/db/database.py @@ -378,13 +378,16 @@ class ResearchDB: # --- Watched Topics --- - async def add_watch(self, topic: str, chat_id: int, interval_hours: int) -> int: + async def add_watch(self, topic: str, chat_id: int, interval_hours: int, + next_run_at: Optional[float] = None) -> int: now = time.time() + if next_run_at is None: + next_run_at = now + interval_hours * 3600 cursor = await self.db.execute( """INSERT OR REPLACE INTO watched_topics (topic, chat_id, interval_hours, next_run_at, created_at) VALUES (?, ?, ?, ?, ?)""", - (topic, chat_id, interval_hours, now + interval_hours * 3600, now) + (topic, chat_id, interval_hours, next_run_at, now) ) await self.db.commit() return cursor.lastrowid