feat: /watch --at HH:MM — hora absoluta en Europe/Madrid
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-06-09 13:55:52 +00:00
co-authored by Claude Opus 4.8
parent 4bfd27db2c
commit 41e4e3f5d6
3 changed files with 84 additions and 9 deletions
+78 -7
View File
@@ -5,8 +5,9 @@ Main user interface — all commands handled here
import asyncio import asyncio
import os import os
import time import time
from datetime import datetime from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from zoneinfo import ZoneInfo
import structlog import structlog
from telegram import Update, Message from telegram import Update, Message
@@ -536,22 +537,62 @@ async def cmd_costs(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
await db_conn.close() 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): async def cmd_watch(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not is_authorized(update.effective_user.id): if not is_authorized(update.effective_user.id):
return return
chat_id = update.effective_chat.id chat_id = update.effective_chat.id
args = ctx.args or [] args = list(ctx.args or [])
if not args: if not args:
await update.message.reply_text( await update.message.reply_text(
"❌ Uso: `/watch <tema> [horas]`\nEjemplo: `/watch Incidente Roswell 24`", "❌ Uso: `/watch <tema> [horas]` o `/watch <tema> --at HH:MM [horas]`\n"
"Ejemplo: `/watch Incidente Roswell 24`\n"
"Ejemplo: `/watch Incidente Roswell --at 19:30 6`",
parse_mode=ParseMode.MARKDOWN parse_mode=ParseMode.MARKDOWN
) )
return 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 interval_hours = 24
if args[-1].isdigit(): if args and args[-1].isdigit():
interval_hours = int(args[-1]) interval_hours = int(args[-1])
topic = " ".join(args[:-1]).strip() topic = " ".join(args[:-1]).strip()
else: else:
@@ -567,14 +608,31 @@ async def cmd_watch(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
) )
return 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_conn = await get_db()
db = ResearchDB(db_conn) db = ResearchDB(db_conn)
try: try:
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( await update.message.reply_text(
f"👁 Watching: `{topic}` — cada {interval_hours}h\n" 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.", f"Usa /watches para ver todos tus temas.",
parse_mode=ParseMode.MARKDOWN parse_mode=ParseMode.MARKDOWN
) )
@@ -633,14 +691,27 @@ async def cmd_watches(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
return return
now = time.time() now = time.time()
tz = ZoneInfo(settings.timezone)
today_local = datetime.now(tz).date()
lines = ["👁 *Tus temas vigilados:*\n"] lines = ["👁 *Tus temas vigilados:*\n"]
for i, w in enumerate(watches, 1): for i, w in enumerate(watches, 1):
secs_remaining = max(0.0, w["next_run_at"] - now) secs_remaining = max(0.0, w["next_run_at"] - now)
hours_remaining = secs_remaining / 3600 hours_remaining = secs_remaining / 3600
eta = f"{int(secs_remaining / 60)}min" if hours_remaining < 1 else f"{hours_remaining:.1f}h" eta = f"{int(secs_remaining / 60)}min" if hours_remaining < 1 else f"{hours_remaining:.1f}h"
status = "" if w["enabled"] else "" 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( 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) await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.MARKDOWN)
+1
View File
@@ -44,6 +44,7 @@ class Settings(BaseSettings):
# App # App
log_level: str = Field("INFO", env="LOG_LEVEL") log_level: str = Field("INFO", env="LOG_LEVEL")
timezone: str = Field("Europe/Madrid", env="TIMEZONE")
@property @property
def allowed_user_ids(self) -> list[int]: def allowed_user_ids(self) -> list[int]:
+5 -2
View File
@@ -378,13 +378,16 @@ class ResearchDB:
# --- Watched Topics --- # --- 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() now = time.time()
if next_run_at is None:
next_run_at = now + interval_hours * 3600
cursor = await self.db.execute( cursor = await self.db.execute(
"""INSERT OR REPLACE INTO watched_topics """INSERT OR REPLACE INTO watched_topics
(topic, chat_id, interval_hours, next_run_at, created_at) (topic, chat_id, interval_hours, next_run_at, created_at)
VALUES (?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?)""",
(topic, chat_id, interval_hours, now + interval_hours * 3600, now) (topic, chat_id, interval_hours, next_run_at, now)
) )
await self.db.commit() await self.db.commit()
return cursor.lastrowid return cursor.lastrowid