Compare commits
7
Commits
4dff236093
...
9eabcd3f04
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eabcd3f04 | ||
|
|
fd9aaa193b | ||
|
|
ca0eb059e8 | ||
|
|
41e4e3f5d6 | ||
|
|
4bfd27db2c | ||
|
|
30509dab4a | ||
|
|
f6ad57e3c7 |
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
# Core
|
# Core
|
||||||
fastapi==0.115.0
|
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
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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,))
|
||||||
|
|||||||
Reference in New Issue
Block a user