feat: /watch --at HH:MM — hora absoluta en Europe/Madrid
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
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:
co-authored by
Claude Opus 4.8
parent
4bfd27db2c
commit
41e4e3f5d6
+78
-7
@@ -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 <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
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user