Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dff236093 |
+4
-4
@@ -1,5 +1,5 @@
|
||||
# Core
|
||||
fastapi==0.137.0
|
||||
fastapi==0.136.3
|
||||
uvicorn==0.30.0
|
||||
python-telegram-bot==21.5
|
||||
httpx==0.27.0
|
||||
@@ -8,14 +8,14 @@ aiohttp==3.10.0
|
||||
# Scraping
|
||||
beautifulsoup4==4.12.3
|
||||
lxml==5.2.2
|
||||
trafilatura==1.12.2
|
||||
youtube-transcript-api==0.6.3
|
||||
trafilatura==1.12.0
|
||||
youtube-transcript-api==0.6.2
|
||||
pdfplumber==0.11.9
|
||||
feedparser==6.0.12
|
||||
duckduckgo-search==6.2.6
|
||||
|
||||
# Storage & Embeddings
|
||||
sqlite-vec==0.1.9
|
||||
sqlite-vec==0.1.6
|
||||
aiosqlite==0.20.0
|
||||
|
||||
# Processing
|
||||
|
||||
+18
-105
@@ -5,9 +5,8 @@ Main user interface — all commands handled here
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import structlog
|
||||
from telegram import Update, Message
|
||||
@@ -537,62 +536,22 @@ 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 = list(ctx.args or [])
|
||||
args = ctx.args or []
|
||||
|
||||
if not args:
|
||||
await update.message.reply_text(
|
||||
"❌ 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`",
|
||||
"❌ Uso: `/watch <tema> [horas]`\nEjemplo: `/watch Incidente Roswell 24`",
|
||||
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 and args[-1].isdigit():
|
||||
if args[-1].isdigit():
|
||||
interval_hours = int(args[-1])
|
||||
topic = " ".join(args[:-1]).strip()
|
||||
else:
|
||||
@@ -608,31 +567,14 @@ 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, next_run_at=next_run_at)
|
||||
await db.add_watch(topic, chat_id, interval_hours)
|
||||
await update.message.reply_text(
|
||||
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.",
|
||||
parse_mode=ParseMode.MARKDOWN
|
||||
)
|
||||
@@ -691,27 +633,14 @@ 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 · "
|
||||
f"próxima a las {local_time} ({day_word}) · en {eta}"
|
||||
f"{i}. {status} `{w['topic']}` — cada {w['interval_hours']}h · próxima en {eta}"
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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):
|
||||
while True:
|
||||
db_conn = None
|
||||
@@ -875,23 +787,24 @@ async def _scheduler_loop(app: Application):
|
||||
)
|
||||
|
||||
if summary:
|
||||
await _safe_send(app.bot, c, summary)
|
||||
await app.bot.send_message(
|
||||
c, summary, parse_mode=ParseMode.MARKDOWN
|
||||
)
|
||||
else:
|
||||
await _safe_send(
|
||||
app.bot, c,
|
||||
f"🔄 *{t}* — sin novedades significativas esta vez."
|
||||
await app.bot.send_message(
|
||||
c,
|
||||
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:
|
||||
await inner_db_conn.close()
|
||||
|
||||
task = asyncio.create_task(_task())
|
||||
_active_tasks[chat_id] = task
|
||||
await _safe_send(
|
||||
app.bot, chat_id,
|
||||
f"🔄 Investigación automática iniciada: `{topic}`"
|
||||
await app.bot.send_message(
|
||||
chat_id,
|
||||
f"🔄 Investigación automática iniciada: `{topic}`",
|
||||
parse_mode=ParseMode.MARKDOWN
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Scheduler loop error", error=str(e))
|
||||
|
||||
@@ -44,7 +44,6 @@ 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]:
|
||||
|
||||
+3
-8
@@ -378,16 +378,13 @@ class ResearchDB:
|
||||
|
||||
# --- Watched Topics ---
|
||||
|
||||
async def add_watch(self, topic: str, chat_id: int, interval_hours: int,
|
||||
next_run_at: Optional[float] = None) -> int:
|
||||
async def add_watch(self, topic: str, chat_id: int, interval_hours: int) -> 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, next_run_at, now)
|
||||
(topic, chat_id, interval_hours, now + interval_hours * 3600, now)
|
||||
)
|
||||
await self.db.commit()
|
||||
return cursor.lastrowid
|
||||
@@ -442,7 +439,7 @@ class ResearchDB:
|
||||
)
|
||||
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:
|
||||
await self.db.execute(
|
||||
@@ -453,8 +450,6 @@ class ResearchDB:
|
||||
counts["chunks"] += cur.rowcount
|
||||
cur = await self.db.execute("DELETE FROM outputs WHERE session_id = ?", (sid,))
|
||||
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,))
|
||||
counts["sources"] += cur.rowcount
|
||||
cur = await self.db.execute("DELETE FROM research_sessions WHERE id = ?", (sid,))
|
||||
|
||||
Reference in New Issue
Block a user