7 Commits
Author SHA1 Message Date
Renovate Bot 9eabcd3f04 chore(deps): update dependency fastapi to v0.137.0 2026-06-14 18:00:25 +00:00
ChemaVXandClaude Opus 4.8 fd9aaa193b fix: watch diff — envío con fallback a texto plano y task no silencioso
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
El resumen de diff generado por Claude se enviaba con parse_mode=MARKDOWN;
texto libre con entidades desbalanceadas provocaba BadRequest y el mensaje
no llegaba. Además el send_message estaba fuera del try/except de _task y el
task se retenía en _active_tasks sin await, así que la excepción se tragaba
sin log alguno.

- _safe_send(): intenta Markdown y reintenta en texto plano si falla
- _task: except de nivel superior que loguea cualquier fallo

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:39:55 +00:00
ChemaVXandClaude Opus 4.8 ca0eb059e8 fix: purga de inicio borra api_usage antes de research_sessions
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
La purga de sesiones >30d fallaba con FOREIGN KEY constraint failed:
api_usage.session_id referencia research_sessions(id) pero nunca se
borraba antes de la sesión padre (con PRAGMA foreign_keys = ON).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:00:54 +00:00
ChemaVXandClaude Opus 4.8 41e4e3f5d6 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>
2026-06-09 13:55:52 +00:00
Renovate Bot 4bfd27db2c chore(deps): update dependency youtube-transcript-api to v0.6.3
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m5s
2026-05-29 12:00:36 +00:00
Renovate Botandchemavx 30509dab4a chore(deps): update dependency trafilatura to v1.12.2
Build & Deploy ResearchOwl / build-and-push (push) Successful in 45s
2026-05-28 12:00:45 +00:00
Renovate Bot f6ad57e3c7 chore(deps): update dependency sqlite-vec to v0.1.9
Build & Deploy ResearchOwl / build-and-push (push) Successful in 49s
2026-05-26 12:00:47 +00:00
4 changed files with 118 additions and 25 deletions
+4 -4
View File
@@ -1,5 +1,5 @@
# Core # Core
fastapi==0.136.3 fastapi==0.137.0
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.0 trafilatura==1.12.2
youtube-transcript-api==0.6.2 youtube-transcript-api==0.6.3
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.6 sqlite-vec==0.1.9
aiosqlite==0.20.0 aiosqlite==0.20.0
# Processing # Processing
+105 -18
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)
@@ -745,6 +816,23 @@ 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
@@ -787,24 +875,23 @@ async def _scheduler_loop(app: Application):
) )
if summary: if summary:
await app.bot.send_message( await _safe_send(app.bot, c, summary)
c, summary, parse_mode=ParseMode.MARKDOWN
)
else: else:
await app.bot.send_message( await _safe_send(
c, app.bot, 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 app.bot.send_message( await _safe_send(
chat_id, app.bot, 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,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]:
+8 -3
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
@@ -439,7 +442,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} counts = {"sessions": 0, "sources": 0, "chunks": 0, "outputs": 0, "api_usage": 0}
for sid in session_ids: for sid in session_ids:
await self.db.execute( await self.db.execute(
@@ -450,6 +453,8 @@ 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,))