1 Commits
Author SHA1 Message Date
Renovate Bot 4dff236093 chore(deps): update dependency fastapi to v0.136.3 2026-05-27 12:00:45 +00:00
4 changed files with 25 additions and 118 deletions
+4 -4
View File
@@ -1,5 +1,5 @@
# Core # Core
fastapi==0.137.0 fastapi==0.136.3
uvicorn==0.30.0 uvicorn==0.30.0
python-telegram-bot==21.5 python-telegram-bot==21.5
httpx==0.27.0 httpx==0.27.0
@@ -8,14 +8,14 @@ aiohttp==3.10.0
# Scraping # Scraping
beautifulsoup4==4.12.3 beautifulsoup4==4.12.3
lxml==5.2.2 lxml==5.2.2
trafilatura==1.12.2 trafilatura==1.12.0
youtube-transcript-api==0.6.3 youtube-transcript-api==0.6.2
pdfplumber==0.11.9 pdfplumber==0.11.9
feedparser==6.0.12 feedparser==6.0.12
duckduckgo-search==6.2.6 duckduckgo-search==6.2.6
# Storage & Embeddings # Storage & Embeddings
sqlite-vec==0.1.9 sqlite-vec==0.1.6
aiosqlite==0.20.0 aiosqlite==0.20.0
# Processing # Processing
+18 -105
View File
@@ -5,9 +5,8 @@ Main user interface — all commands handled here
import asyncio import asyncio
import os import os
import time import time
from datetime import datetime, timedelta from datetime import datetime
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
@@ -537,62 +536,22 @@ 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 = list(ctx.args or []) args = ctx.args or []
if not args: if not args:
await update.message.reply_text( await update.message.reply_text(
"❌ Uso: `/watch <tema> [horas]` o `/watch <tema> --at HH:MM [horas]`\n" "❌ Uso: `/watch <tema> [horas]`\nEjemplo: `/watch Incidente Roswell 24`",
"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 and args[-1].isdigit(): if 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:
@@ -608,31 +567,14 @@ 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, next_run_at=next_run_at) await db.add_watch(topic, chat_id, interval_hours)
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"{when_msg}\n" f"Primera ejecución en ~{interval_hours}h.\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
) )
@@ -691,27 +633,14 @@ 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 · " f"{i}. {status} `{w['topic']}` — cada {w['interval_hours']}h · próxima en {eta}"
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)
@@ -816,23 +745,6 @@ async def _purge_on_startup(app: Application) -> None:
await db_conn.close() await db_conn.close()
async def _safe_send(bot, chat_id, text: str):
"""Envía un mensaje, con fallback a texto plano si falla el parseo Markdown.
Los resúmenes generados por Claude suelen contener entidades Markdown
desbalanceadas (*, _, [, `) que hacen que Telegram rechace el mensaje con
BadRequest. Sin este fallback, el envío fallaba en silencio.
"""
try:
await bot.send_message(chat_id, text, parse_mode=ParseMode.MARKDOWN)
except Exception as e:
logger.warning("Envío Markdown falló, reintentando en texto plano", error=str(e))
try:
await bot.send_message(chat_id, text)
except Exception as e2:
logger.error("Envío en texto plano también falló", chat_id=chat_id, error=str(e2))
async def _scheduler_loop(app: Application): async def _scheduler_loop(app: Application):
while True: while True:
db_conn = None db_conn = None
@@ -875,23 +787,24 @@ async def _scheduler_loop(app: Application):
) )
if summary: if summary:
await _safe_send(app.bot, c, summary) await app.bot.send_message(
c, summary, parse_mode=ParseMode.MARKDOWN
)
else: else:
await _safe_send( await app.bot.send_message(
app.bot, c, c,
f"🔄 *{t}* — sin novedades significativas esta vez." f"🔄 *{t}* — sin novedades significativas esta vez.",
parse_mode=ParseMode.MARKDOWN
) )
except Exception as e:
logger.error("Tarea programada falló",
topic=t, session_id=s, error=str(e))
finally: finally:
await inner_db_conn.close() await inner_db_conn.close()
task = asyncio.create_task(_task()) task = asyncio.create_task(_task())
_active_tasks[chat_id] = task _active_tasks[chat_id] = task
await _safe_send( await app.bot.send_message(
app.bot, chat_id, chat_id,
f"🔄 Investigación automática iniciada: `{topic}`" f"🔄 Investigación automática iniciada: `{topic}`",
parse_mode=ParseMode.MARKDOWN
) )
except Exception as e: except Exception as e:
logger.warning("Scheduler loop error", error=str(e)) logger.warning("Scheduler loop error", error=str(e))
-1
View File
@@ -44,7 +44,6 @@ 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]:
+3 -8
View File
@@ -378,16 +378,13 @@ class ResearchDB:
# --- Watched Topics --- # --- Watched Topics ---
async def add_watch(self, topic: str, chat_id: int, interval_hours: int, async def add_watch(self, topic: str, chat_id: int, interval_hours: int) -> 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, next_run_at, now) (topic, chat_id, interval_hours, now + interval_hours * 3600, now)
) )
await self.db.commit() await self.db.commit()
return cursor.lastrowid return cursor.lastrowid
@@ -442,7 +439,7 @@ class ResearchDB:
) )
session_ids = [row[0] for row in await cursor.fetchall()] session_ids = [row[0] for row in await cursor.fetchall()]
counts = {"sessions": 0, "sources": 0, "chunks": 0, "outputs": 0, "api_usage": 0} counts = {"sessions": 0, "sources": 0, "chunks": 0, "outputs": 0}
for sid in session_ids: for sid in session_ids:
await self.db.execute( await self.db.execute(
@@ -453,8 +450,6 @@ class ResearchDB:
counts["chunks"] += cur.rowcount counts["chunks"] += cur.rowcount
cur = await self.db.execute("DELETE FROM outputs WHERE session_id = ?", (sid,)) cur = await self.db.execute("DELETE FROM outputs WHERE session_id = ?", (sid,))
counts["outputs"] += cur.rowcount counts["outputs"] += cur.rowcount
cur = await self.db.execute("DELETE FROM api_usage WHERE session_id = ?", (sid,))
counts["api_usage"] += cur.rowcount
cur = await self.db.execute("DELETE FROM sources WHERE session_id = ?", (sid,)) cur = await self.db.execute("DELETE FROM sources WHERE session_id = ?", (sid,))
counts["sources"] += cur.rowcount counts["sources"] += cur.rowcount
cur = await self.db.execute("DELETE FROM research_sessions WHERE id = ?", (sid,)) cur = await self.db.execute("DELETE FROM research_sessions WHERE id = ?", (sid,))