Build & Deploy ResearchOwl / build-and-push (push) Successful in 10s
`/upload_short` contaba lo que la respuesta de la subida decía del vídeo. Eso es la palabra de la API sobre sí misma, y todo el flujo de revisión descansa en ella: el informe de fundamento se lee ANTES de publicar sólo si subir no publica. El 2026-08-12, mirando un vídeo recién subido, la respuesta decía `privacyStatus: private` y el vídeo se veía sin sesión — resultó ser un clic humano en Studio y no un fallo, pero el episodio dejó claro que no había forma de distinguir un caso del otro. Ahora se contrasta: oEmbed contesta 200 a un vídeo que se ve sin sesión y 404 a uno que no. Sin credenciales, sin tocar el scope — `youtube.upload` no puede preguntar por el estado de un vídeo, y ampliarlo a uno que sí pueda significa darle a un token de subida permiso para vaciar el canal. Dos decisiones que van con esto: - **Sólo el 200 es una prueba.** Un 404 no demuestra que el vídeo sea privado: también lo devuelve uno que YouTube aún no ha indexado. Por eso el negativo se mira dos veces y, si sigue negativo, se cuenta como "no se ve desde fuera", no como "es privado". - **No haber podido comprobar no es haber comprobado que no.** Un fallo de red deja `reachable=None` y el parte lo dice, en vez de heredar la garantía que no tiene. Cuando la API dice privado y el vídeo se ve, el aviso va en la PRIMERA línea del mensaje de Telegram: enterarse tiene que costar cero atención. Verificado contra la realidad — el mismo vídeo daba True antes de ocultarlo y False después; un vídeo borrado y uno privado dan False, y uno público True. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1903 lines
73 KiB
Python
1903 lines
73 KiB
Python
"""
|
|
ResearchOwl Telegram Bot
|
|
Main user interface — all commands handled here
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import re
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import structlog
|
|
from telegram import Update, Message, LinkPreviewOptions
|
|
from telegram.ext import (
|
|
Application, CommandHandler, MessageHandler,
|
|
filters, ContextTypes
|
|
)
|
|
from telegram.constants import ParseMode
|
|
|
|
from src.config import settings
|
|
from src.db.database import get_db, close_db, ResearchDB, ResearchStatus, OutputType
|
|
from src.scraper.exhaustive import ExhaustiveScraper
|
|
from src.processor.processor import OllamaClient, ContentProcessor
|
|
from src.generator.generator import OutputGenerator
|
|
from src.news.monitor import poll_feeds, item_from_row, format_digest
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
# Active research tasks per chat
|
|
_active_tasks: dict[int, asyncio.Task] = {}
|
|
_active_sessions: dict[int, int] = {} # chat_id -> session_id
|
|
|
|
|
|
def is_authorized(user_id: int) -> bool:
|
|
allowed = settings.allowed_user_ids
|
|
return not allowed or user_id in allowed
|
|
|
|
|
|
class ProgressReporter:
|
|
def __init__(self, reply_target: Message = None, *, bot=None, chat_id: int = None):
|
|
self._reply_target = reply_target
|
|
self._bot = bot
|
|
self._chat_id = chat_id
|
|
self._msg: Optional[Message] = None
|
|
|
|
async def start(self, text: str):
|
|
if self._reply_target is not None:
|
|
self._msg = await self._reply_target.reply_text(text, parse_mode=ParseMode.MARKDOWN)
|
|
elif self._bot is not None and self._chat_id is not None:
|
|
self._msg = await self._bot.send_message(self._chat_id, text, parse_mode=ParseMode.MARKDOWN)
|
|
|
|
async def update(self, text: str):
|
|
if not self._msg:
|
|
return
|
|
try:
|
|
await self._msg.edit_text(text, parse_mode=ParseMode.MARKDOWN)
|
|
except Exception:
|
|
pass
|
|
|
|
async def done(self, text: str):
|
|
await self.update(text)
|
|
|
|
|
|
async def send_chunked(message: Message, text: str, parse_mode=None):
|
|
"""Send long text in chunks of 4000 chars (Telegram limit)"""
|
|
max_len = 4000
|
|
for i in range(0, len(text), max_len):
|
|
chunk = text[i:i + max_len]
|
|
await message.reply_text(chunk, parse_mode=parse_mode)
|
|
if len(text) > max_len:
|
|
await asyncio.sleep(0.5)
|
|
|
|
|
|
# ─── Shared research logic ────────────────────────────────────────────────────
|
|
|
|
async def run_scheduled_research(bot, chat_id: int, topic: str,
|
|
session_id: int, db: ResearchDB,
|
|
progress_message=None,
|
|
silent_completion: bool = False):
|
|
if progress_message is not None:
|
|
reporter = ProgressReporter(progress_message)
|
|
else:
|
|
reporter = ProgressReporter(bot=bot, chat_id=chat_id)
|
|
|
|
try:
|
|
await reporter.start(f"🔍 Iniciando scraping de `{topic}`…")
|
|
|
|
async def on_progress(iter_num, total_sources):
|
|
await reporter.update(
|
|
f"🔍 Scraping — iteración `{iter_num}` | `{total_sources}` fuentes encontradas"
|
|
)
|
|
|
|
scraper = ExhaustiveScraper(db, session_id, topic, on_progress)
|
|
final_stats = await scraper.run()
|
|
|
|
await db.update_session(session_id, status=ResearchStatus.SATURATED)
|
|
scraped = final_stats.get("scraped", 0)
|
|
|
|
await reporter.update(f"⚡ Procesando `{scraped}` fuentes…")
|
|
|
|
ollama = OllamaClient()
|
|
if await ollama.is_available():
|
|
processor = ContentProcessor(db, ollama)
|
|
|
|
async def proc_progress(total_chunks, total_words):
|
|
await reporter.update(
|
|
f"⚡ Scoring chunks… (`{total_chunks}` procesados)"
|
|
)
|
|
|
|
await processor.process_session(session_id, topic, proc_progress)
|
|
chunk_count = await db.get_chunks_count(session_id)
|
|
if silent_completion:
|
|
await reporter.done(
|
|
f"🔍 Investigación completada — analizando novedades…"
|
|
)
|
|
else:
|
|
await reporter.done(
|
|
f"✅ Listo — `{scraped}` fuentes · `{chunk_count}` chunks · usa /generate <tipo>"
|
|
)
|
|
else:
|
|
await reporter.done(
|
|
f"⚠️ Ollama no disponible — `{scraped}` fuentes scraped.\n"
|
|
f"Usa /generate para generar contenido."
|
|
)
|
|
|
|
except asyncio.CancelledError:
|
|
await db.update_session(session_id, status=ResearchStatus.FINISHED)
|
|
try:
|
|
await reporter.done("🛑 Investigación cancelada.")
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
logger.error("Research task failed", error=str(e))
|
|
try:
|
|
await reporter.done(f"❌ Error: {str(e)[:200]}")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ─── Commands ─────────────────────────────────────────────────────────────────
|
|
|
|
async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
if not is_authorized(update.effective_user.id):
|
|
return
|
|
await update.message.reply_text(
|
|
"🦉 *ResearchOwl* — Exhaustive Research Engine\n\n"
|
|
"Commands:\n"
|
|
"`/research <topic>` — Start exhaustive research\n"
|
|
"`/status` — Check current research progress\n"
|
|
"`/finish` — Stop research and proceed to generation\n"
|
|
"`/process` — Manually trigger chunk processing\n"
|
|
"`/generate <type>` — Generate output\n"
|
|
" Tipos: podcast|blog|report|thread\n"
|
|
" Extended: podcast_extended|blog_extended|report_extended\n"
|
|
"`/generate short_en` — Short vertical (vídeo) + informe de claims\n"
|
|
"`/short_spec` — Último shot spec como fichero JSON; edítalo y "
|
|
"mándamelo de vuelta para re-renderizar gratis\n"
|
|
"`/upload_short` — Subir el Short a YouTube (privado, a revisar)\n"
|
|
"`/sources` — List all sources found\n"
|
|
"`/outputs` — List generated outputs\n"
|
|
"`/export` — Exportar último output como PDF\n"
|
|
"`/publish` — Publicar último output en Ghost como borrador\n"
|
|
"`/compare <tema1> vs <tema2>` — Análisis comparativo\n"
|
|
"`/costs` — Show API usage costs\n"
|
|
"`/watch <topic> [h]` — Schedule periodic research\n"
|
|
"`/unwatch <topic>` — Remove a watch\n"
|
|
"`/watches` — List your watched topics\n"
|
|
"`/cancel` — Cancel current research\n"
|
|
"`/help` — Show this message",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
|
|
|
|
async def cmd_research(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
if not is_authorized(update.effective_user.id):
|
|
return
|
|
|
|
chat_id = update.effective_chat.id
|
|
topic = " ".join(ctx.args).strip() if ctx.args else ""
|
|
|
|
if not topic:
|
|
await update.message.reply_text(
|
|
"❌ Please provide a topic.\nExample: `/research Roswell incident`",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
return
|
|
|
|
if chat_id in _active_tasks and not _active_tasks[chat_id].done():
|
|
await update.message.reply_text(
|
|
"⚠️ Research already in progress. Use /status or /finish first."
|
|
)
|
|
return
|
|
|
|
async def run_research():
|
|
db_conn = await get_db()
|
|
db = ResearchDB(db_conn)
|
|
try:
|
|
session_id = await db.create_session(topic, chat_id)
|
|
_active_sessions[chat_id] = session_id
|
|
await run_scheduled_research(
|
|
ctx.bot, chat_id, topic, session_id, db,
|
|
progress_message=update.message
|
|
)
|
|
finally:
|
|
await db_conn.close()
|
|
|
|
task = asyncio.create_task(run_research())
|
|
_active_tasks[chat_id] = task
|
|
|
|
|
|
async def cmd_status(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
if not is_authorized(update.effective_user.id):
|
|
return
|
|
|
|
chat_id = update.effective_chat.id
|
|
db_conn = await get_db()
|
|
db = ResearchDB(db_conn)
|
|
|
|
try:
|
|
session = await db.get_active_session(chat_id)
|
|
if not session:
|
|
# Try to find last session
|
|
cursor = await db_conn.execute(
|
|
"SELECT * FROM research_sessions WHERE telegram_chat_id = ? ORDER BY created_at DESC LIMIT 1",
|
|
(chat_id,)
|
|
)
|
|
row = await cursor.fetchone()
|
|
session = dict(row) if row else None
|
|
|
|
if not session:
|
|
await update.message.reply_text("No research sessions found. Start with /research <topic>")
|
|
return
|
|
|
|
stats = await db.get_session_stats(session["id"])
|
|
is_active = chat_id in _active_tasks and not _active_tasks[chat_id].done()
|
|
|
|
status_emoji = {"running": "🔄", "saturated": "✅", "finished": "🏁", "error": "❌"}
|
|
emoji = status_emoji.get(session["status"], "❓")
|
|
|
|
await update.message.reply_text(
|
|
f"{emoji} *Research Status*\n\n"
|
|
f"📝 Topic: `{session['topic']}`\n"
|
|
f"🔁 Status: `{session['status']}`\n"
|
|
f"🔢 Iterations: `{session.get('iterations', 0)}`\n"
|
|
f"📚 Total sources: `{stats.get('total') or 0}`\n"
|
|
f"✅ Scraped: `{stats.get('scraped') or 0}`\n"
|
|
f"⏭️ Skipped: `{stats.get('skipped') or 0}`\n"
|
|
f"❌ Failed: `{stats.get('failed') or 0}`\n"
|
|
f"⏳ Pending: `{stats.get('pending') or 0}`\n"
|
|
f"💬 Chunks: `{session.get('total_chunks', 0)}`\n"
|
|
f"📖 Words: `{session.get('total_words', 0):,}`\n"
|
|
f"{'🟢 Active — stats update each iteration' if is_active else '⚫ Idle'}",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
finally:
|
|
await db_conn.close()
|
|
|
|
|
|
async def cmd_finish(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
if not is_authorized(update.effective_user.id):
|
|
return
|
|
|
|
chat_id = update.effective_chat.id
|
|
task = _active_tasks.get(chat_id)
|
|
|
|
if task and not task.done():
|
|
task.cancel()
|
|
await update.message.reply_text(
|
|
"🛑 Stopping research...\n"
|
|
"Use `/generate podcast|blog|report|thread` to generate output.",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
else:
|
|
await update.message.reply_text(
|
|
"No active research. Use `/generate` to create output from last session.",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
|
|
|
|
async def cmd_generate(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
if not is_authorized(update.effective_user.id):
|
|
return
|
|
|
|
chat_id = update.effective_chat.id
|
|
output_arg = ctx.args[0].lower() if ctx.args else ""
|
|
rest = [a.lower() for a in ctx.args[1:]]
|
|
lang = "en" if "en" in rest else "es"
|
|
# `/generate blog en dry` forces SEO dry-run for this one call (proposes SEO to
|
|
# Telegram, writes a bare draft). Global default still comes from SEO_AUTOFILL.
|
|
seo_override = "dryrun" if ("dry" in rest or "dryrun" in rest) else None
|
|
|
|
# El Short no es un output de texto: sale del pipeline de shortsmith y se
|
|
# entrega como vídeo + informe de claims. Se desvía antes del type_map.
|
|
if output_arg in ("short_en", "short", "corto"):
|
|
await cmd_short(update, ctx)
|
|
return
|
|
|
|
type_map = {
|
|
"podcast": OutputType.PODCAST,
|
|
"blog": OutputType.BLOG,
|
|
"report": OutputType.REPORT,
|
|
"thread": OutputType.THREAD,
|
|
"hilo": OutputType.THREAD,
|
|
"informe": OutputType.REPORT,
|
|
"report_extended": OutputType.REPORT_EXTENDED,
|
|
"blog_extended": OutputType.BLOG_EXTENDED,
|
|
"podcast_extended": OutputType.PODCAST_EXTENDED,
|
|
"informe_extended": OutputType.REPORT_EXTENDED,
|
|
}
|
|
|
|
if output_arg not in type_map:
|
|
await update.message.reply_text(
|
|
"❌ Invalid output type.\n"
|
|
"Use: `/generate podcast|blog|report|thread|short_en`",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
return
|
|
|
|
output_type = type_map[output_arg]
|
|
|
|
db_conn = await get_db()
|
|
db = ResearchDB(db_conn)
|
|
|
|
try:
|
|
# Usa la sesión activa si existe, si no la más reciente
|
|
session_id = _active_sessions.get(chat_id)
|
|
if session_id:
|
|
cursor = await db_conn.execute(
|
|
"SELECT * FROM research_sessions WHERE id = ?",
|
|
(session_id,)
|
|
)
|
|
else:
|
|
cursor = await db_conn.execute(
|
|
"""SELECT * FROM research_sessions WHERE telegram_chat_id = ?
|
|
ORDER BY created_at DESC LIMIT 1""",
|
|
(chat_id,)
|
|
)
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
await update.message.reply_text("No research sessions found. Start with /research <topic>")
|
|
return
|
|
|
|
session = dict(row)
|
|
session_id = session["id"]
|
|
|
|
backend = "Claude Haiku" if settings.anthropic_api_key else f"Ollama ({settings.ollama_model})"
|
|
lang_label = " (EN)" if lang == "en" else ""
|
|
msg = await update.message.reply_text(
|
|
f"⚙️ Generating *{output_type}{lang_label}* for: `{session['topic']}`\n"
|
|
f"Using {backend}...\n"
|
|
f"This may take 2-5 minutes ☕",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
|
|
async def gen_progress(text):
|
|
try:
|
|
await msg.edit_text(text)
|
|
except Exception:
|
|
pass
|
|
|
|
ollama = OllamaClient()
|
|
processor = ContentProcessor(db, ollama)
|
|
generator = OutputGenerator(db, ollama, processor)
|
|
|
|
output = await generator.generate(session_id, output_type, gen_progress,
|
|
lang=lang, seo_override=seo_override)
|
|
|
|
# Send as file if very long
|
|
if len(output) > 8000:
|
|
import tempfile
|
|
import re as _re
|
|
ext_map = {
|
|
OutputType.PODCAST: "script.md",
|
|
OutputType.BLOG: "post.md",
|
|
OutputType.REPORT: "report.md",
|
|
OutputType.THREAD: "thread.txt",
|
|
OutputType.REPORT_EXTENDED: "report_extended.md",
|
|
OutputType.BLOG_EXTENDED: "blog_extended.md",
|
|
OutputType.PODCAST_EXTENDED: "script_extended.md",
|
|
}
|
|
# Use the topic from the output header (written at generation time)
|
|
# instead of the pre-fetched session dict which may be stale.
|
|
_m = _re.search(r'^Topic:\s*(.+)$', output[:500], _re.MULTILINE)
|
|
_topic = _m.group(1).strip() if _m else session["topic"]
|
|
filename = f"researchowl_{_topic[:30].replace(' ', '_')}_{ext_map[output_type]}"
|
|
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
|
|
f.write(output)
|
|
tmp_path = f.name
|
|
|
|
with open(tmp_path, "rb") as f:
|
|
await update.message.reply_document(
|
|
document=f,
|
|
filename=filename,
|
|
caption=f"📄 *{output_type.upper()}* — {session['topic']}\n"
|
|
f"Generated by ResearchOwl 🦉",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
os.unlink(tmp_path)
|
|
else:
|
|
await send_chunked(update.message, output)
|
|
|
|
# SEO autofill / dry-run summary — a SEPARATE short message so it is never
|
|
# buried inside the long .md document. None on the flag-off path.
|
|
if getattr(generator, "last_publish_notice", None):
|
|
try:
|
|
await update.message.reply_text(
|
|
generator.last_publish_notice,
|
|
parse_mode=ParseMode.MARKDOWN,
|
|
link_preview_options=LinkPreviewOptions(is_disabled=True),
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Failed to send SEO summary message", error=str(e))
|
|
|
|
try:
|
|
stats = await db.get_usage_stats(session_id)
|
|
total_cost = sum(s.get("total_cost", 0) for s in stats)
|
|
if total_cost > settings.cost_alert_threshold:
|
|
await update.message.reply_text(
|
|
f"⚠️ Coste acumulado de esta sesión: `${total_cost:.4f}`"
|
|
f" (umbral: `${settings.cost_alert_threshold:.2f}`)",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
except Exception as e:
|
|
logger.error("Generate failed", error=str(e))
|
|
await update.message.reply_text(f"❌ Generation failed: {str(e)[:200]}")
|
|
finally:
|
|
await db_conn.close()
|
|
|
|
|
|
async def _session_row(db_conn, chat_id: int):
|
|
"""La sesión activa del chat si la hay, si no la más reciente."""
|
|
session_id = _active_sessions.get(chat_id)
|
|
if session_id:
|
|
cursor = await db_conn.execute(
|
|
"SELECT * FROM research_sessions WHERE id = ?", (session_id,))
|
|
else:
|
|
cursor = await db_conn.execute(
|
|
"""SELECT * FROM research_sessions WHERE telegram_chat_id = ?
|
|
ORDER BY created_at DESC LIMIT 1""", (chat_id,))
|
|
row = await cursor.fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def _claims_message(result) -> str:
|
|
"""El informe de claims: la puerta de revisión humana.
|
|
|
|
Se manda SIEMPRE y como mensaje aparte, incluso con cero avisos. Un éxito
|
|
silencioso enseña al lector a dejar de mirar. En texto plano a propósito:
|
|
lleva citas y comillas del modelo, y un Markdown desbalanceado haría que
|
|
Telegram rechazara justo el mensaje que no puede faltar.
|
|
"""
|
|
lines = []
|
|
if result.grounding:
|
|
lines.append(result.grounding.summary())
|
|
else:
|
|
why = ("esta pasada no llegó a mirarlo" if result.spec
|
|
else "no se llegó a escribir un spec")
|
|
lines.append(f"⚠️ Sin comprobación de fundamento: {why}.")
|
|
|
|
# shortsmith manda dos cosas por el mismo canal: textos que no cupieron al
|
|
# dibujar y avisos de la narración. Se separan aquí porque piden acciones
|
|
# distintas — uno se arregla acortando una cadena, el otro puede significar
|
|
# que el Short salió mudo.
|
|
trimmed = [w for w in (result.render_warnings or []) if not w.get("kind")]
|
|
spoken = [w for w in (result.render_warnings or []) if w.get("kind")]
|
|
|
|
if trimmed:
|
|
lines.append("")
|
|
# Un recorte grave no es un titular más pequeño, es uno ilegible: se
|
|
# separa para que no se pierda entre los cosméticos.
|
|
severe = [w for w in trimmed if w.get("severe")]
|
|
lines.append(f"✂️ {len(trimmed)} textos recortados al dibujar:")
|
|
for w in trimmed[:5]:
|
|
mark = "🔴 " if w.get("severe") else ""
|
|
lines.append(f" • {mark}[{w.get('template', '?')}] "
|
|
f"{str(w.get('text', ''))[:60]}")
|
|
if severe:
|
|
lines.append(f" 🔴 {len(severe)} quedaron ILEGIBLES (dibujados a menos "
|
|
"de la mitad): acorta ese texto y reenvía el spec.")
|
|
|
|
if spoken:
|
|
lines.append("")
|
|
for w in spoken[:5]:
|
|
icon = "🔇" if w.get("kind") == "narration" else "⏱"
|
|
lines.append(f"{icon} {str(w.get('text', ''))[:160]}")
|
|
|
|
if result.notes:
|
|
lines.append("")
|
|
lines.extend(f"📏 {n}" for n in result.notes)
|
|
|
|
lines.append("")
|
|
if result.duration_s:
|
|
lines.append(f"Duración: {result.duration_s:.0f}s · "
|
|
f"{len(result.spec.get('shots', []))} shots · "
|
|
f"intentos hasta válido: {result.attempts}")
|
|
lines.append(f"Coste: ${result.cost_usd:.4f}")
|
|
if not result.article_url:
|
|
lines.append("⚠️ Esta sesión no tiene URL de artículo: publica antes el blog "
|
|
"(`/generate blog en`) para que el Short pueda enlazarlo.")
|
|
return "\n".join(lines)
|
|
|
|
|
|
async def _send_spec_file(message, result, session_id: int, reason: str):
|
|
"""Fallback universal: el spec vuelve como fichero pase lo que pase.
|
|
|
|
La parte cara es la generación, no el render. Un spec que no se pudo
|
|
renderizar se edita a mano y se reenvía; uno que se tira hay que pagarlo
|
|
otra vez.
|
|
"""
|
|
import io
|
|
payload = result.spec_json or result.raw_response
|
|
if not payload:
|
|
await message.reply_text(f"❌ {reason}\n(no hay ni spec que devolver)")
|
|
return
|
|
suffix = "json" if result.spec else "txt"
|
|
await message.reply_document(
|
|
document=io.BytesIO(payload.encode("utf-8")),
|
|
filename=f"short_{session_id}_spec.{suffix}",
|
|
caption=f"⚠️ Sin vídeo — {reason[:800]}",
|
|
)
|
|
|
|
|
|
async def cmd_short(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
"""`/generate short_en` — spec → fundamento → render → vídeo a revisar.
|
|
|
|
La subida a YouTube NO entra aquí: es fase 3, y el humano de en medio es
|
|
justo lo más valioso del proceso.
|
|
"""
|
|
if not is_authorized(update.effective_user.id):
|
|
return
|
|
|
|
chat_id = update.effective_chat.id
|
|
db_conn = await get_db()
|
|
db = ResearchDB(db_conn)
|
|
|
|
try:
|
|
session = await _session_row(db_conn, chat_id)
|
|
if not session:
|
|
await update.message.reply_text(
|
|
"No research sessions found. Start with /research <topic>")
|
|
return
|
|
session_id = session["id"]
|
|
|
|
from src.generator.short import ShortProducer, ShortsDisabled
|
|
|
|
reporter = ProgressReporter(update.message)
|
|
await reporter.start(f"🎬 Writing shot spec for: {session['topic']}")
|
|
|
|
producer = ShortProducer(db, ContentProcessor(db, OllamaClient()))
|
|
try:
|
|
result = await producer.produce(session_id, reporter.update)
|
|
except ShortsDisabled:
|
|
await reporter.done(
|
|
"🚫 Los Shorts están desactivados (`SHORTSMITH_ENABLED=false`).")
|
|
return
|
|
|
|
await _deliver_short(update.message, reporter, result, session)
|
|
|
|
except Exception as e:
|
|
logger.error("Short generation failed", error=str(e), exc_info=True)
|
|
await update.message.reply_text(f"❌ Short failed: {str(e)[:300]}")
|
|
finally:
|
|
await db_conn.close()
|
|
|
|
|
|
async def _deliver_short(message, reporter, result, session) -> None:
|
|
"""El final común de `/generate short_en` y del re-render: vídeo si lo hay,
|
|
spec de vuelta si no, e informe de claims SIEMPRE, en su propio mensaje."""
|
|
if result.has_video:
|
|
await reporter.done("✅ Short renderizado")
|
|
caption = f"🎬 {result.title}"
|
|
if result.article_url:
|
|
caption += f"\n{result.article_url}"
|
|
caption += f"\n\n{session['topic']} · {result.duration_s:.0f}s"
|
|
with open(result.video_path, "rb") as f:
|
|
await message.reply_video(
|
|
video=f,
|
|
filename=f"short_{session['id']}.mp4",
|
|
caption=caption[:1024],
|
|
supports_streaming=True,
|
|
write_timeout=180,
|
|
)
|
|
else:
|
|
await reporter.done("⚠️ Short sin vídeo — te devuelvo el spec")
|
|
await _send_spec_file(message, result, session["id"],
|
|
result.failure or "razón desconocida")
|
|
|
|
await message.reply_text(_claims_message(result))
|
|
|
|
|
|
async def cmd_short_spec(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
"""Devuelve el último shot spec como fichero, para editarlo a mano y
|
|
volver a renderizar sin pagar otra generación."""
|
|
if not is_authorized(update.effective_user.id):
|
|
return
|
|
|
|
chat_id = update.effective_chat.id
|
|
db_conn = await get_db()
|
|
db = ResearchDB(db_conn)
|
|
|
|
try:
|
|
session = await _session_row(db_conn, chat_id)
|
|
if not session:
|
|
await update.message.reply_text("No sessions found.")
|
|
return
|
|
|
|
output = await db.get_latest_output(session["id"], OutputType.SHORT_EN)
|
|
if not output:
|
|
await update.message.reply_text(
|
|
"No hay ningún shot spec en esta sesión. Genera uno con "
|
|
"`/generate short_en`.", parse_mode=ParseMode.MARKDOWN)
|
|
return
|
|
|
|
import io
|
|
from datetime import datetime
|
|
created = datetime.utcfromtimestamp(output["created_at"]).strftime("%Y-%m-%d %H:%M")
|
|
await update.message.reply_document(
|
|
document=io.BytesIO(output["content"].encode("utf-8")),
|
|
filename=f"short_{session['id']}_spec.json",
|
|
caption=f"🎬 Shot spec — {session['topic']}\n{created} UTC\n\n"
|
|
f"Edítalo y mándamelo de vuelta como fichero para "
|
|
f"re-renderizar sin pagar otra generación. La banda sonora "
|
|
f"también: audio.preset acepta sonar, pulse o static.",
|
|
)
|
|
except Exception as e:
|
|
logger.error("short_spec failed", error=str(e))
|
|
await update.message.reply_text(f"❌ {str(e)[:200]}")
|
|
finally:
|
|
await db_conn.close()
|
|
|
|
|
|
#: Tope de tamaño de un spec adjunto. El de Socorro son ~6 KB; 256 KB ya no es
|
|
#: un shot spec, es otra cosa que ha llegado aquí por accidente.
|
|
MAX_SPEC_FILE_BYTES = 256 * 1024
|
|
|
|
_SPEC_FILENAME = re.compile(r"short_(\d+)")
|
|
|
|
|
|
def _session_from_filename(name: Optional[str]) -> Optional[int]:
|
|
"""La sesión que declara el nombre del fichero, si la declara.
|
|
|
|
`/short_spec` nombra el fichero `short_{id}_spec.json` y Telegram conserva
|
|
el nombre al reenviarlo, así que el id del nombre manda sobre la sesión
|
|
activa: el spec editado es de ESA sesión aunque el chat haya investigado
|
|
otra cosa entre medias.
|
|
"""
|
|
match = _SPEC_FILENAME.search(name or "")
|
|
return int(match.group(1)) if match else None
|
|
|
|
|
|
async def handle_spec_document(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
"""Un `.json` adjunto es un shot spec editado: validar y re-renderizar.
|
|
|
|
La vuelta de `/short_spec`. Sin LLM en este camino — renderizar de nuevo
|
|
es gratis, la parte cara fue la generación.
|
|
"""
|
|
if not is_authorized(update.effective_user.id):
|
|
return
|
|
doc = update.message.document
|
|
if not doc:
|
|
return
|
|
if (doc.file_size or 0) > MAX_SPEC_FILE_BYTES:
|
|
await update.message.reply_text(
|
|
"Ese fichero pesa demasiado para ser un shot spec.")
|
|
return
|
|
|
|
db_conn = await get_db()
|
|
db = ResearchDB(db_conn)
|
|
try:
|
|
tg_file = await doc.get_file()
|
|
raw = bytes(await tg_file.download_as_bytearray())
|
|
try:
|
|
spec = json.loads(raw.decode("utf-8"))
|
|
except (ValueError, UnicodeDecodeError) as e:
|
|
await update.message.reply_text(
|
|
f"No puedo leer ese JSON: {str(e)[:200]}")
|
|
return
|
|
if not isinstance(spec, dict) or "shots" not in spec:
|
|
await update.message.reply_text(
|
|
"Ese JSON no parece un shot spec (no tiene `shots`). El de "
|
|
"esta sesión te lo da /short_spec.")
|
|
return
|
|
|
|
session = None
|
|
declared = _session_from_filename(doc.file_name)
|
|
if declared:
|
|
session = await db.get_session(declared)
|
|
if not session:
|
|
session = await _session_row(db_conn, update.effective_chat.id)
|
|
if not session:
|
|
await update.message.reply_text(
|
|
"No hay sesiones. Empieza con /research <tema>")
|
|
return
|
|
|
|
from src.generator.short import ShortProducer, ShortsDisabled
|
|
|
|
reporter = ProgressReporter(update.message)
|
|
await reporter.start(
|
|
f"🎞 Re-renderizando spec editado — sesión #{session['id']}: "
|
|
f"{session['topic']}")
|
|
producer = ShortProducer(db, ContentProcessor(db, OllamaClient()))
|
|
try:
|
|
result = await producer.rerender(session["id"], spec, reporter.update)
|
|
except ShortsDisabled:
|
|
await reporter.done(
|
|
"🚫 Los Shorts están desactivados (`SHORTSMITH_ENABLED=false`).")
|
|
return
|
|
|
|
await _deliver_short(update.message, reporter, result, session)
|
|
|
|
except Exception as e:
|
|
logger.error("Spec re-render failed", error=str(e), exc_info=True)
|
|
await update.message.reply_text(f"❌ Re-render fallido: {str(e)[:300]}")
|
|
finally:
|
|
await db_conn.close()
|
|
|
|
|
|
async def cmd_upload_short(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
"""`/upload_short [force]` — sube a YouTube el Short ya renderizado.
|
|
|
|
Comando aparte, como `/publish` con Ghost, y por la misma razón: el informe
|
|
de fundamento no sirve de nada si el vídeo ya está en el canal cuando lo
|
|
lees. Sube en privado con los metadatos puestos; publicar sigue siendo un
|
|
clic humano en Studio.
|
|
"""
|
|
if not is_authorized(update.effective_user.id):
|
|
return
|
|
|
|
chat_id = update.effective_chat.id
|
|
force = bool(ctx.args) and ctx.args[0].lower() in ("force", "-f", "otra")
|
|
db_conn = await get_db()
|
|
db = ResearchDB(db_conn)
|
|
|
|
try:
|
|
from src.generator.youtube import (
|
|
YouTubeDisabled, YouTubeError, YouTubeNotConfigured,
|
|
YouTubeUploader, build_metadata,
|
|
)
|
|
|
|
uploader = YouTubeUploader()
|
|
if not uploader.is_configured():
|
|
await update.message.reply_text(
|
|
"❌ YouTube no configurado. Faltan `YOUTUBE_CLIENT_ID`, "
|
|
"`YOUTUBE_CLIENT_SECRET` o `YOUTUBE_REFRESH_TOKEN`.\n"
|
|
"Sácalos con `python scripts/youtube_oauth.py` y mételos en "
|
|
"Infisical.", parse_mode=ParseMode.MARKDOWN)
|
|
return
|
|
|
|
session = await _session_row(db_conn, chat_id)
|
|
if not session:
|
|
await update.message.reply_text(
|
|
"No hay sesiones. Empieza con /research <tema>")
|
|
return
|
|
session_id = session["id"]
|
|
|
|
output = await db.get_latest_output(session_id, OutputType.SHORT_EN)
|
|
if not output:
|
|
await update.message.reply_text(
|
|
"Esta sesión no tiene ningún Short. Genera uno con "
|
|
"`/generate short_en`.", parse_mode=ParseMode.MARKDOWN)
|
|
return
|
|
|
|
video_path = Path(settings.shorts_dir) / f"{session_id}.mp4"
|
|
if not video_path.exists():
|
|
# El spec sobrevive al purgado; el MP4 no. Es recuperable y barato:
|
|
# renderizar de nuevo no vuelve a pagar la generación.
|
|
await update.message.reply_text(
|
|
f"El spec está guardado pero el vídeo ya no está en disco "
|
|
f"(`{video_path.name}`). Vuelve a renderizarlo con "
|
|
f"`/generate short_en`.", parse_mode=ParseMode.MARKDOWN)
|
|
return
|
|
|
|
if (_video_predates_spec(video_path.stat().st_mtime,
|
|
output["created_at"]) and not force):
|
|
await update.message.reply_text(
|
|
"⚠️ El vídeo en disco es ANTERIOR al último spec guardado: se "
|
|
"regeneró el spec pero el render no llegó a dejar vídeo nuevo. "
|
|
"Subirlo pondría metadatos nuevos a un vídeo viejo.\n\n"
|
|
"Re-renderiza con `/generate short_en` (o mándame el spec como "
|
|
"fichero `.json`). `/upload_short force` lo sube igualmente.",
|
|
parse_mode=ParseMode.MARKDOWN)
|
|
return
|
|
|
|
if output.get("published_url") and not force:
|
|
await update.message.reply_text(
|
|
f"Este Short ya está subido:\n{output['published_url']}\n\n"
|
|
f"Si quieres subirlo otra vez: `/upload_short force`",
|
|
parse_mode=ParseMode.MARKDOWN)
|
|
return
|
|
|
|
try:
|
|
spec = json.loads(output["content"])
|
|
except ValueError:
|
|
await update.message.reply_text(
|
|
"El spec guardado no es JSON válido; no puedo sacar los "
|
|
"metadatos. Míralo con /short_spec.")
|
|
return
|
|
|
|
article_url = await db.get_article_url(session_id)
|
|
metadata = build_metadata(spec, session["topic"], article_url)
|
|
|
|
reporter = ProgressReporter(update.message)
|
|
await reporter.start("📤 Subiendo a YouTube…")
|
|
try:
|
|
video = await uploader.upload(video_path, metadata, reporter.update)
|
|
except YouTubeDisabled:
|
|
await reporter.done(
|
|
"🚫 La subida está desactivada (`YOUTUBE_ENABLED=false`).")
|
|
return
|
|
except YouTubeNotConfigured as e:
|
|
await reporter.done(f"❌ {e}")
|
|
return
|
|
|
|
await db.set_output_url(output["id"], video.watch_url)
|
|
await reporter.done("✅ Subido")
|
|
await update.message.reply_text(
|
|
_upload_message(video, metadata, article_url))
|
|
|
|
except Exception as e:
|
|
logger.error("Upload to YouTube failed", error=str(e), exc_info=True)
|
|
await update.message.reply_text(f"❌ Subida fallida: {str(e)[:400]}")
|
|
finally:
|
|
await db_conn.close()
|
|
|
|
|
|
#: Margen para relojes y redondeos del filesystem. Un render legítimo escribe
|
|
#: el MP4 ~1 minuto DESPUÉS de guardarse el spec; el caso malo (spec regenerado
|
|
#: con render fallido) deja un vídeo horas más viejo, no segundos.
|
|
_STALE_VIDEO_MARGIN_S = 5.0
|
|
|
|
|
|
def _video_predates_spec(video_mtime: float, spec_created_at: float) -> bool:
|
|
"""True si el MP4 en disco es anterior al último spec guardado.
|
|
|
|
Pasa cuando `/generate short_en` se repite y el render falla: `produce`
|
|
guarda el spec ANTES de renderizar, así que en disco queda el vídeo de la
|
|
vuelta anterior. Subirlo con los metadatos del spec nuevo es un mismatch
|
|
silencioso — el vídeo dice una cosa y el título otra.
|
|
"""
|
|
return video_mtime + _STALE_VIDEO_MARGIN_S < spec_created_at
|
|
|
|
|
|
def _upload_message(video, metadata: dict, article_url: Optional[str]) -> str:
|
|
"""El parte de la subida. Texto plano: lleva el título del modelo, y un
|
|
Markdown desbalanceado haría que Telegram rechazara el mensaje que trae el
|
|
enlace — justo el que no puede faltar."""
|
|
lines = [f"🎬 {video.title}", "", f"Revisar y publicar: {video.studio_url}",
|
|
f"Enlace del vídeo: {video.watch_url}", ""]
|
|
|
|
if video.visibility_contradiction:
|
|
# Primera línea del mensaje, no una nota al pie: si esto pasa, el vídeo
|
|
# ya está en la calle mientras lees el informe de fundamento.
|
|
lines.insert(0, "🚨 EL VÍDEO SE VE SIN INICIAR SESIÓN, aunque YouTube "
|
|
"dijo que lo subía en privado. Ocúltalo en Studio antes "
|
|
"de nada — el enlace de abajo lleva ahí.\n")
|
|
elif video.privacy_status == "private":
|
|
lines.append(
|
|
"🔒 Está PRIVADO. Los vídeos subidos por API desde un proyecto sin "
|
|
"auditar se quedan así: el candado es del proyecto, no del vídeo, y "
|
|
"no se abre desde Studio. Para levantarlo hay que pasar la auditoría "
|
|
"de cumplimiento de Google.")
|
|
else:
|
|
lines.append(f"👁 Visibilidad: {video.privacy_status}")
|
|
|
|
# Lo comprobado, aparte de lo que dijo la API: son dos cosas distintas y el
|
|
# 2026-08-12 se demostró que conviene no confundirlas.
|
|
if video.reachable is False:
|
|
lines.append("✔ Comprobado desde fuera: no se ve sin sesión.")
|
|
elif video.reachable is None:
|
|
lines.append("⚠️ No se pudo comprobar la visibilidad desde fuera; me "
|
|
"queda sólo lo que dijo la API. Míralo en Studio.")
|
|
|
|
if video.forced_private:
|
|
lines.append("⚠️ Pediste otra visibilidad y YouTube la forzó a privada. "
|
|
"Es exactamente la firma de ese candado.")
|
|
if video.rejection_reason:
|
|
lines.append(f"⚠️ YouTube marcó el vídeo: {video.rejection_reason}")
|
|
if not article_url:
|
|
lines.append("⚠️ Sin URL de artículo: la descripción va sin enlace. "
|
|
"Publica el blog y vuelve a subir con `/upload_short force`.")
|
|
|
|
tags = (metadata.get("snippet") or {}).get("tags") or []
|
|
lines += ["", f"Etiquetas: {', '.join(tags[:8])}"]
|
|
return "\n".join(lines)
|
|
|
|
|
|
async def cmd_sources(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
if not is_authorized(update.effective_user.id):
|
|
return
|
|
|
|
chat_id = update.effective_chat.id
|
|
db_conn = await get_db()
|
|
db = ResearchDB(db_conn)
|
|
|
|
try:
|
|
cursor = await db_conn.execute(
|
|
"SELECT * FROM research_sessions WHERE telegram_chat_id = ? ORDER BY created_at DESC LIMIT 1",
|
|
(chat_id,)
|
|
)
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
await update.message.reply_text("No sessions found.")
|
|
return
|
|
|
|
session_id = row["id"]
|
|
sources = await db.get_all_sources(session_id)
|
|
|
|
by_type: dict = {}
|
|
for s in sources:
|
|
t = s["source_type"]
|
|
by_type.setdefault(t, []).append(s)
|
|
|
|
lines = [f"📚 *Sources for session #{session_id}*\n"]
|
|
for stype, srcs in by_type.items():
|
|
scraped = sum(1 for s in srcs if s["status"] == "scraped")
|
|
lines.append(f"\n*{stype.upper()}* ({scraped}/{len(srcs)} scraped)")
|
|
for s in srcs[:5]: # show top 5 per type
|
|
quality = s.get("quality_score", 0)
|
|
status_icon = {"scraped": "✅", "failed": "❌", "pending": "⏳", "skipped": "⏭️"}.get(s["status"], "❓")
|
|
title = (s.get("title") or s["url"])[:50]
|
|
lines.append(f"{status_icon} {title} (q:{quality:.1f})")
|
|
if len(srcs) > 5:
|
|
lines.append(f" ... and {len(srcs)-5} more")
|
|
|
|
await send_chunked(update.message, "\n".join(lines), parse_mode=ParseMode.MARKDOWN)
|
|
finally:
|
|
await db_conn.close()
|
|
|
|
|
|
async def cmd_outputs(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
if not is_authorized(update.effective_user.id):
|
|
return
|
|
|
|
chat_id = update.effective_chat.id
|
|
db_conn = await get_db()
|
|
db = ResearchDB(db_conn)
|
|
|
|
try:
|
|
cursor = await db_conn.execute(
|
|
"SELECT * FROM research_sessions WHERE telegram_chat_id = ? ORDER BY created_at DESC LIMIT 1",
|
|
(chat_id,)
|
|
)
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
await update.message.reply_text("No sessions found.")
|
|
return
|
|
|
|
outputs = await db.get_outputs(row["id"])
|
|
if not outputs:
|
|
await update.message.reply_text(
|
|
"No outputs generated yet. Use `/generate podcast|blog|report|thread`",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
return
|
|
|
|
lines = [f"📄 *Outputs for: {row['topic']}*\n"]
|
|
for o in outputs:
|
|
from datetime import datetime
|
|
dt = datetime.utcfromtimestamp(o['created_at']).strftime("%Y-%m-%d %H:%M")
|
|
lines.append(f"• `{o['output_type']}` — {dt} ({len(o['content'])} chars)")
|
|
|
|
await update.message.reply_text(
|
|
"\n".join(lines),
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
finally:
|
|
await db_conn.close()
|
|
|
|
|
|
async def cmd_news(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
"""Monitor RSS manual (sin scheduler — F2 lo automatiza). Refresca news_seen
|
|
(siembra en cold-start) y muestra las novedades matcheadas de las últimas 24h."""
|
|
if not is_authorized(update.effective_user.id):
|
|
return
|
|
|
|
db_conn = await get_db()
|
|
db = ResearchDB(db_conn)
|
|
try:
|
|
await update.message.reply_text("📡 Buscando novedades UAP/OVNI…")
|
|
try:
|
|
await poll_feeds(db, settings) # refresca tabla (siembra en cold-start)
|
|
except Exception:
|
|
logger.exception("news poll failed") # best-effort: nunca rompe el comando
|
|
|
|
recent = await db.get_recent_news(24)
|
|
if not recent:
|
|
await update.message.reply_text("Sin novedades UAP/OVNI en las últimas 24h.")
|
|
return
|
|
|
|
items = [item_from_row(r) for r in recent]
|
|
for chunk in format_digest(items):
|
|
# Previews activos a propósito (el digest gana con la tarjeta del link).
|
|
await update.message.reply_text(
|
|
chunk, link_preview_options=LinkPreviewOptions(is_disabled=False)
|
|
)
|
|
finally:
|
|
await db_conn.close()
|
|
|
|
|
|
async def cmd_costs(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
if not is_authorized(update.effective_user.id):
|
|
return
|
|
|
|
chat_id = update.effective_chat.id
|
|
db_conn = await get_db()
|
|
db = ResearchDB(db_conn)
|
|
|
|
try:
|
|
cursor = await db_conn.execute(
|
|
"SELECT * FROM research_sessions WHERE telegram_chat_id = ? ORDER BY created_at DESC LIMIT 1",
|
|
(chat_id,)
|
|
)
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
await update.message.reply_text("No sessions found.")
|
|
return
|
|
|
|
session_id = row["id"]
|
|
topic = row["topic"]
|
|
|
|
by_type = {r["call_type"]: r for r in await db.get_usage_stats(session_id)}
|
|
totals = await db.get_total_usage_stats()
|
|
|
|
lines = [f"📊 *Costes ResearchOwl*\n"]
|
|
lines.append(f"Última sesión (`{topic}`):")
|
|
|
|
session_total = 0.0
|
|
for call_type, label in [("scoring", "Scoring"), ("generation", "Generación")]:
|
|
row_data = by_type.get(call_type)
|
|
if row_data:
|
|
calls = row_data["calls"]
|
|
tokens = row_data["total_tokens"]
|
|
cost = row_data["total_cost"]
|
|
session_total += cost
|
|
lines.append(f" {label}: {calls} llamadas · {tokens:,} tokens · ${cost:.4f}")
|
|
else:
|
|
lines.append(f" {label}: —")
|
|
|
|
lines.append(f" Total: ${session_total:.4f}")
|
|
lines.append("")
|
|
lines.append("Acumulado total:")
|
|
acc_cost = totals.get("total_cost") or 0.0
|
|
acc_sessions = totals.get("sessions") or 0
|
|
lines.append(f" ${acc_cost:.4f} ({acc_sessions} sesiones)")
|
|
|
|
await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.MARKDOWN)
|
|
finally:
|
|
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 [])
|
|
|
|
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`",
|
|
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():
|
|
interval_hours = int(args[-1])
|
|
topic = " ".join(args[:-1]).strip()
|
|
else:
|
|
topic = " ".join(args).strip()
|
|
|
|
if not topic:
|
|
await update.message.reply_text("❌ Debes especificar un tema.")
|
|
return
|
|
|
|
if not (1 <= interval_hours <= 168):
|
|
await update.message.reply_text(
|
|
"❌ El intervalo debe estar entre 1 y 168 horas (1 semana)."
|
|
)
|
|
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 update.message.reply_text(
|
|
f"👁 Watching: `{topic}` — cada {interval_hours}h\n"
|
|
f"{when_msg}\n"
|
|
f"Usa /watches para ver todos tus temas.",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
except Exception as e:
|
|
if "UNIQUE" in str(e):
|
|
await update.message.reply_text(
|
|
f"Ya estás watching `{topic}`", parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
else:
|
|
raise
|
|
finally:
|
|
await db_conn.close()
|
|
|
|
|
|
async def cmd_unwatch(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
if not is_authorized(update.effective_user.id):
|
|
return
|
|
|
|
chat_id = update.effective_chat.id
|
|
topic = " ".join(ctx.args).strip() if ctx.args else ""
|
|
|
|
if not topic:
|
|
await update.message.reply_text(
|
|
"❌ Uso: `/unwatch <tema>`", parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
return
|
|
|
|
db_conn = await get_db()
|
|
db = ResearchDB(db_conn)
|
|
try:
|
|
removed = await db.remove_watch(topic, chat_id)
|
|
if removed:
|
|
await update.message.reply_text(
|
|
f"✅ Ya no vigilas `{topic}`.", parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
else:
|
|
await update.message.reply_text(f"No estabas watching `{topic}`.")
|
|
finally:
|
|
await db_conn.close()
|
|
|
|
|
|
async def cmd_watches(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
if not is_authorized(update.effective_user.id):
|
|
return
|
|
|
|
chat_id = update.effective_chat.id
|
|
db_conn = await get_db()
|
|
db = ResearchDB(db_conn)
|
|
try:
|
|
watches = await db.list_watches(chat_id)
|
|
if not watches:
|
|
await update.message.reply_text(
|
|
"No tienes temas vigilados. Usa `/watch <tema>`",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
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}"
|
|
)
|
|
|
|
await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.MARKDOWN)
|
|
finally:
|
|
await db_conn.close()
|
|
|
|
|
|
async def cmd_process(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
if not is_authorized(update.effective_user.id):
|
|
return
|
|
|
|
chat_id = update.effective_chat.id
|
|
db_conn = await get_db()
|
|
db = ResearchDB(db_conn)
|
|
|
|
try:
|
|
cursor = await db_conn.execute(
|
|
"SELECT * FROM research_sessions WHERE telegram_chat_id = ? ORDER BY created_at DESC LIMIT 1",
|
|
(chat_id,)
|
|
)
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
await update.message.reply_text("No research sessions found. Start with /research <topic>")
|
|
return
|
|
|
|
session = dict(row)
|
|
session_id = session["id"]
|
|
topic = session["topic"]
|
|
|
|
msg = await update.message.reply_text(
|
|
f"🧠 Processing session #{session_id}: `{topic}`\n"
|
|
f"Chunking & scoring with Ollama ({settings.ollama_model})...\n"
|
|
f"This may take a few minutes.",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
|
|
ollama = OllamaClient()
|
|
if not await ollama.is_available():
|
|
await msg.edit_text("❌ Ollama not reachable. Check OLLAMA_URL setting.")
|
|
return
|
|
|
|
processor = ContentProcessor(db, ollama)
|
|
|
|
completion_text = None
|
|
|
|
async def proc_progress(total_chunks, total_words):
|
|
nonlocal completion_text
|
|
completion_text = (
|
|
f"🧠 *Processing complete!*\n"
|
|
f"• Chunks stored: `{total_chunks}`\n"
|
|
f"• Words researched: `{total_words:,}`\n\n"
|
|
f"Ready! Use `/generate podcast|blog|report|thread`"
|
|
)
|
|
try:
|
|
await msg.edit_text(completion_text, parse_mode=ParseMode.MARKDOWN)
|
|
completion_text = None # sent, no need to resend
|
|
except Exception:
|
|
pass
|
|
|
|
await processor.process_session(session_id, topic, proc_progress)
|
|
|
|
# Fallback: if edit_text failed silently, send a new message
|
|
if completion_text:
|
|
await update.message.reply_text(completion_text, parse_mode=ParseMode.MARKDOWN)
|
|
|
|
except Exception as e:
|
|
logger.error("Process command failed", error=str(e))
|
|
await update.message.reply_text(f"❌ Processing failed: {str(e)[:200]}")
|
|
finally:
|
|
await db_conn.close()
|
|
|
|
|
|
async def cmd_cancel(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
if not is_authorized(update.effective_user.id):
|
|
return
|
|
|
|
chat_id = update.effective_chat.id
|
|
task = _active_tasks.get(chat_id)
|
|
if task and not task.done():
|
|
task.cancel()
|
|
await update.message.reply_text("🛑 Research cancelled.")
|
|
else:
|
|
await update.message.reply_text("No active research to cancel.")
|
|
|
|
|
|
async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
await cmd_start(update, ctx)
|
|
|
|
|
|
# ─── Bot setup ────────────────────────────────────────────────────────────────
|
|
|
|
async def _mark_interrupted_on_startup(app: Application) -> None:
|
|
"""Las tareas de research viven solo en memoria (_active_tasks): un
|
|
reinicio del pod las mata sin tocar la DB, y sus sesiones quedan en
|
|
'running' para siempre — parecen activas en /status y get_active_session.
|
|
"""
|
|
db_conn = await get_db()
|
|
try:
|
|
cursor = await db_conn.execute(
|
|
"UPDATE research_sessions SET status = ?, updated_at = ? WHERE status = ?",
|
|
(ResearchStatus.INTERRUPTED, time.time(), ResearchStatus.RUNNING),
|
|
)
|
|
await db_conn.commit()
|
|
if cursor.rowcount:
|
|
logger.info("Orphaned running sessions marked interrupted",
|
|
count=cursor.rowcount)
|
|
except Exception as e:
|
|
logger.warning("Interrupted-mark failed — bot continues", error=str(e))
|
|
finally:
|
|
await db_conn.close()
|
|
|
|
|
|
async def _purge_on_startup(app: Application) -> None:
|
|
db_conn = await get_db()
|
|
try:
|
|
db = ResearchDB(db_conn)
|
|
result = await db.purge_old_sessions(30)
|
|
if result["sessions"] > 0:
|
|
logger.info("Startup purge done", **result)
|
|
except Exception as e:
|
|
logger.warning("Startup purge failed — bot continues", error=str(e))
|
|
finally:
|
|
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):
|
|
# Estado en memoria (no en DB): en un reinicio re-polla una vez, inofensivo
|
|
# porque solo se empujan items notified=0 y se marcan tras enviarlos.
|
|
_last_news_poll: Optional[datetime] = None
|
|
while True:
|
|
db_conn = None
|
|
try:
|
|
db_conn = await get_db()
|
|
db = ResearchDB(db_conn)
|
|
due = await db.get_due_watches()
|
|
for watch in due:
|
|
chat_id = watch["chat_id"]
|
|
topic = watch["topic"]
|
|
if chat_id in _active_tasks and not _active_tasks[chat_id].done():
|
|
continue
|
|
session_id = await db.create_session(topic, chat_id)
|
|
_active_sessions[chat_id] = session_id
|
|
await db.update_watch_run(watch["id"])
|
|
|
|
async def _task(c=chat_id, t=topic, s=session_id):
|
|
inner_db_conn = await get_db()
|
|
inner_db = ResearchDB(inner_db_conn)
|
|
try:
|
|
await run_scheduled_research(app.bot, c, t, s, inner_db,
|
|
silent_completion=True)
|
|
|
|
prev_session = await inner_db.get_previous_session(c, t, s)
|
|
new_urls = await inner_db.get_session_urls(s)
|
|
old_urls = await inner_db.get_session_urls(prev_session["id"]) \
|
|
if prev_session else set()
|
|
new_chunks = await inner_db.get_top_chunks(s, limit=30)
|
|
|
|
try:
|
|
from src.generator.generator import generate_diff_summary
|
|
summary = await generate_diff_summary(
|
|
t, new_urls, old_urls, new_chunks, s, inner_db
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Diff summary failed", error=str(e))
|
|
summary = (
|
|
f"📊 *Actualización disponible — {t}*\n\n"
|
|
f"Usa /generate report para ver el análisis completo."
|
|
)
|
|
|
|
if summary:
|
|
await _safe_send(app.bot, c, summary)
|
|
else:
|
|
await _safe_send(
|
|
app.bot, c,
|
|
f"🔄 *{t}* — sin novedades significativas esta vez."
|
|
)
|
|
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}`"
|
|
)
|
|
|
|
# --- Monitor de noticias (F2) — inerte si NEWS_ENABLED=False.
|
|
# Best-effort: un fallo del news-poll NUNCA tumba el scheduler de
|
|
# watched_topics (va en su propio try/except).
|
|
if settings.NEWS_ENABLED:
|
|
now = datetime.now(timezone.utc)
|
|
interval = timedelta(hours=settings.NEWS_POLL_INTERVAL_HOURS)
|
|
due_news = (_last_news_poll is None
|
|
or (now - _last_news_poll) >= interval)
|
|
if due_news:
|
|
_last_news_poll = now # marca antes de pollear: evita reintentos en bucle si falla
|
|
news_chat_id = settings.news_chat_id
|
|
if not news_chat_id:
|
|
logger.warning("NEWS_ENABLED pero sin chat destino; salto poll")
|
|
else:
|
|
try:
|
|
await poll_feeds(db, settings) # inserta novedades notified=0
|
|
pending = await db.get_unnotified() # TODOS los pendientes
|
|
if pending:
|
|
shown = pending[:settings.NEWS_MAX_ITEMS]
|
|
extra = len(pending) - len(shown)
|
|
items = [item_from_row(r) for r in shown]
|
|
chunks = format_digest(items, header="🛸 Novedades UAP/OVNI")
|
|
if extra > 0 and chunks:
|
|
chunks[-1] += f"\n…y {extra} más."
|
|
for c in chunks:
|
|
await app.bot.send_message(
|
|
chat_id=news_chat_id, text=c,
|
|
link_preview_options=LinkPreviewOptions(is_disabled=False),
|
|
)
|
|
# Marca todos los pendientes (incluidos los "…y N más")
|
|
await db.mark_news_notified([r["id"] for r in pending])
|
|
except Exception as e:
|
|
logger.warning("News poll/notify falló", error=str(e))
|
|
except Exception as e:
|
|
logger.warning("Scheduler loop error", error=str(e))
|
|
finally:
|
|
if db_conn:
|
|
try:
|
|
await db_conn.close()
|
|
except Exception:
|
|
pass
|
|
await asyncio.sleep(60)
|
|
|
|
|
|
async def _start_scheduler(app: Application) -> None:
|
|
asyncio.create_task(_scheduler_loop(app))
|
|
|
|
|
|
async def _on_startup(app: Application) -> None:
|
|
await _mark_interrupted_on_startup(app)
|
|
await _purge_on_startup(app)
|
|
await _start_scheduler(app)
|
|
|
|
|
|
async def _on_shutdown(app: Application) -> None:
|
|
await close_db()
|
|
|
|
|
|
async def cmd_export(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
if not is_authorized(update.effective_user.id):
|
|
return
|
|
|
|
chat_id = update.effective_chat.id
|
|
db_conn = await get_db()
|
|
db = ResearchDB(db_conn)
|
|
|
|
try:
|
|
session = await db.get_latest_session(chat_id)
|
|
if not session:
|
|
await update.message.reply_text("No hay sesiones de investigación.")
|
|
return
|
|
|
|
session_id = session["id"]
|
|
topic = session["topic"]
|
|
|
|
outputs = await db.get_outputs(session_id)
|
|
if not outputs:
|
|
await update.message.reply_text(
|
|
"No hay outputs generados. Usa `/generate <tipo>` primero.",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
return
|
|
|
|
priority = [
|
|
"report_extended", "blog_extended", "podcast_extended",
|
|
"report", "blog", "podcast", "thread",
|
|
]
|
|
chosen = None
|
|
for ptype in priority:
|
|
for o in outputs:
|
|
if o["output_type"] == ptype:
|
|
chosen = o
|
|
break
|
|
if chosen:
|
|
break
|
|
if not chosen:
|
|
# Un short_en es JSON, no prosa: maquetarlo en PDF no tiene sentido.
|
|
# Se usa /short_spec para eso.
|
|
prose = [o for o in outputs if o["output_type"] != OutputType.SHORT_EN]
|
|
if not prose:
|
|
await update.message.reply_text(
|
|
"El único output de esta sesión es un shot spec. "
|
|
"Úsalo con `/short_spec`.", parse_mode=ParseMode.MARKDOWN)
|
|
return
|
|
chosen = prose[0]
|
|
|
|
msg = await update.message.reply_text(
|
|
f"📄 Generando PDF para `{topic}`…",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
|
|
try:
|
|
from src.generator.generator import generate_pdf
|
|
pdf_bytes = generate_pdf(chosen["content"], title=topic)
|
|
except ImportError:
|
|
await msg.edit_text("❌ reportlab no está instalado. Ejecuta: `pip install reportlab`")
|
|
return
|
|
except Exception as e:
|
|
await msg.edit_text(f"❌ Error generando PDF: {str(e)[:200]}")
|
|
return
|
|
|
|
safe_topic = topic[:40].replace(" ", "_").replace("/", "-")
|
|
filename = f"researchowl_{safe_topic}_{chosen['output_type']}.pdf"
|
|
|
|
import io
|
|
await update.message.reply_document(
|
|
document=io.BytesIO(pdf_bytes),
|
|
filename=filename,
|
|
caption=f"📄 *{chosen['output_type'].upper()}* — {topic}\nExportado por ResearchOwl 🦉",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
try:
|
|
await msg.delete()
|
|
except Exception:
|
|
pass
|
|
|
|
except Exception as e:
|
|
logger.error("Export failed", error=str(e))
|
|
await update.message.reply_text(f"❌ Export failed: {str(e)[:200]}")
|
|
finally:
|
|
await db_conn.close()
|
|
|
|
|
|
async def cmd_purge(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
if not is_authorized(update.effective_user.id):
|
|
return
|
|
|
|
args = ctx.args or []
|
|
|
|
if not args:
|
|
days = 30
|
|
else:
|
|
try:
|
|
days = int(args[0])
|
|
except ValueError:
|
|
await update.message.reply_text(
|
|
"❌ Uso: `/purge [días]`", parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
return
|
|
if days < 0:
|
|
await update.message.reply_text("❌ El número de días debe ser ≥ 0.")
|
|
return
|
|
if days == 0 and not (len(args) >= 2 and args[1] == "confirm"):
|
|
await update.message.reply_text(
|
|
"⚠️ Esto borrará *todas* las sesiones completadas.\n"
|
|
"Envía `/purge 0 confirm` para confirmar.",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
return
|
|
|
|
db_conn = await get_db()
|
|
try:
|
|
db = ResearchDB(db_conn)
|
|
result = await db.purge_old_sessions(days)
|
|
await update.message.reply_text(
|
|
f"🗑️ Purged: {result['sessions']} sessions, "
|
|
f"{result['sources']} sources, "
|
|
f"{result['chunks']} chunks, "
|
|
f"{result['outputs']} outputs, "
|
|
f"{result.get('shorts', 0)} vídeos"
|
|
)
|
|
except Exception as e:
|
|
logger.error("Purge command failed", error=str(e))
|
|
await update.message.reply_text(f"❌ Purge failed: {str(e)[:200]}")
|
|
finally:
|
|
await db_conn.close()
|
|
|
|
|
|
async def cmd_publish(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
if not is_authorized(update.effective_user.id):
|
|
return
|
|
|
|
chat_id = update.effective_chat.id
|
|
db_conn = await get_db()
|
|
db = ResearchDB(db_conn)
|
|
|
|
try:
|
|
from src.generator.generator import GhostPublisher, _extract_title
|
|
|
|
ghost = GhostPublisher()
|
|
if not ghost.is_configured():
|
|
await update.message.reply_text(
|
|
"❌ Ghost no configurado. Asegúrate de que `GHOST_URL` y `GHOST_API_KEY` están definidos.",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
return
|
|
|
|
session_id = _active_sessions.get(chat_id)
|
|
if session_id:
|
|
cursor = await db_conn.execute(
|
|
"SELECT * FROM research_sessions WHERE id = ?",
|
|
(session_id,)
|
|
)
|
|
else:
|
|
cursor = await db_conn.execute(
|
|
"SELECT * FROM research_sessions WHERE telegram_chat_id = ? ORDER BY created_at DESC LIMIT 1",
|
|
(chat_id,)
|
|
)
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
await update.message.reply_text("No hay sesiones. Usa /research primero.")
|
|
return
|
|
|
|
session = dict(row)
|
|
outputs = await db.get_outputs(session["id"])
|
|
if not outputs:
|
|
await update.message.reply_text(
|
|
"No hay outputs generados. Usa `/generate blog|report` primero.",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
return
|
|
|
|
priority = ["blog_extended", "blog", "report_extended", "report",
|
|
"podcast_extended", "podcast", "thread"]
|
|
chosen = None
|
|
for ptype in priority:
|
|
for o in outputs:
|
|
if o["output_type"] == ptype:
|
|
chosen = o
|
|
break
|
|
if chosen:
|
|
break
|
|
if not chosen:
|
|
# Nunca un short_en: su contenido es el shot spec en JSON.
|
|
prose = [o for o in outputs if o["output_type"] != OutputType.SHORT_EN]
|
|
if not prose:
|
|
await update.message.reply_text(
|
|
"El único output de esta sesión es un shot spec — eso no se "
|
|
"publica en Ghost.")
|
|
return
|
|
chosen = prose[-1]
|
|
|
|
msg = await update.message.reply_text("📤 Publicando en Ghost como borrador…")
|
|
|
|
title = _extract_title(chosen["content"]) or session["topic"]
|
|
result = await ghost.publish_draft(title, chosen["content"])
|
|
post = result["posts"][0]
|
|
admin_url = f"{ghost.url}/ghost/#/editor/post/{post['id']}"
|
|
|
|
# La URL pública queda apuntada en la fila del output: el Short la
|
|
# necesita después para enlazar al artículo (best-effort).
|
|
if post.get("slug"):
|
|
try:
|
|
await db.set_output_url(chosen["id"], f"{ghost.url}/{post['slug']}/")
|
|
except Exception as e:
|
|
logger.warning("No se pudo guardar la URL del artículo", error=str(e))
|
|
|
|
await msg.edit_text(
|
|
f"✅ *Publicado en Ghost como borrador*\n\n"
|
|
f"📝 Título: `{title}`\n"
|
|
f"🔗 Editar: {admin_url}",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error("Publish to Ghost failed", error=str(e))
|
|
await update.message.reply_text(f"❌ Error publicando en Ghost: {str(e)[:200]}")
|
|
finally:
|
|
await db_conn.close()
|
|
|
|
|
|
async def cmd_compare(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|
if not is_authorized(update.effective_user.id):
|
|
return
|
|
|
|
chat_id = update.effective_chat.id
|
|
text = " ".join(ctx.args).strip() if ctx.args else ""
|
|
|
|
import re
|
|
match = re.split(r'\s+vs\.?\s+|\s+versus\s+', text, maxsplit=1, flags=re.IGNORECASE)
|
|
if len(match) != 2 or not match[0].strip() or not match[1].strip():
|
|
await update.message.reply_text(
|
|
"❌ Uso: `/compare <tema1> vs <tema2>`\n"
|
|
"Ejemplo: `/compare energía solar vs energía nuclear`",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
return
|
|
|
|
topic_a = match[0].strip()
|
|
topic_b = match[1].strip()
|
|
|
|
if chat_id in _active_tasks and not _active_tasks[chat_id].done():
|
|
await update.message.reply_text(
|
|
"⚠️ Ya hay una investigación en curso. Usa /cancel primero."
|
|
)
|
|
return
|
|
|
|
msg = await update.message.reply_text(
|
|
f"🔍 Comparando `{topic_a}` vs `{topic_b}`…\n"
|
|
f"Esto lanzará dos investigaciones en paralelo y tardará varios minutos.",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
|
|
async def run_compare():
|
|
db_conn_a = await get_db()
|
|
db_conn_b = await get_db()
|
|
db_a = ResearchDB(db_conn_a)
|
|
db_b = ResearchDB(db_conn_b)
|
|
|
|
try:
|
|
session_id_a = await db_a.create_session(topic_a, chat_id)
|
|
session_id_b = await db_b.create_session(topic_b, chat_id)
|
|
_active_sessions[chat_id] = session_id_a
|
|
|
|
await msg.edit_text(
|
|
f"🔍 Investigando en paralelo:\n"
|
|
f"• `{topic_a}`\n"
|
|
f"• `{topic_b}`\n\n"
|
|
f"Esto puede tardar 10-20 minutos…",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
|
|
async def research_topic(session_id, topic, db):
|
|
scraper = ExhaustiveScraper(db, session_id, topic)
|
|
await scraper.run()
|
|
await db.update_session(session_id, status=ResearchStatus.SATURATED)
|
|
ollama = OllamaClient()
|
|
if await ollama.is_available():
|
|
processor = ContentProcessor(db, ollama)
|
|
await processor.process_session(session_id, topic)
|
|
|
|
await msg.edit_text(
|
|
f"🔍 Scraping en paralelo:\n• `{topic_a}`\n• `{topic_b}`…",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
|
|
await asyncio.gather(
|
|
research_topic(session_id_a, topic_a, db_a),
|
|
research_topic(session_id_b, topic_b, db_b),
|
|
)
|
|
|
|
await msg.edit_text(
|
|
"✍️ Generando análisis comparativo…",
|
|
parse_mode=ParseMode.MARKDOWN
|
|
)
|
|
|
|
ollama = OllamaClient()
|
|
processor_a = ContentProcessor(db_a, ollama)
|
|
processor_b = ContentProcessor(db_b, ollama)
|
|
|
|
context_a = await processor_a.rag_query(session_id_a, topic_a, top_k=40)
|
|
context_b = await processor_b.rag_query(session_id_b, topic_b, top_k=40)
|
|
|
|
if not context_a:
|
|
chunks = await db_a.get_top_chunks(session_id_a, limit=20)
|
|
context_a = "\n\n---\n\n".join(c["content"] for c in chunks)
|
|
if not context_b:
|
|
chunks = await db_b.get_top_chunks(session_id_b, limit=20)
|
|
context_b = "\n\n---\n\n".join(c["content"] for c in chunks)
|
|
|
|
from src.generator.generator import generate_comparison
|
|
comparison = await generate_comparison(
|
|
topic_a, topic_b, context_a, context_b, session_id_a, db_a
|
|
)
|
|
|
|
from datetime import datetime, timezone
|
|
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
header = (
|
|
f"---\n"
|
|
f"ResearchOwl | COMPARISON\n"
|
|
f"Topic A: {topic_a}\n"
|
|
f"Topic B: {topic_b}\n"
|
|
f"Generated: {now}\n"
|
|
f"---\n\n"
|
|
)
|
|
full_output = header + comparison
|
|
|
|
await db_a.save_output(session_id_a, OutputType.REPORT, full_output)
|
|
|
|
if len(full_output) > 8000:
|
|
import io
|
|
filename = (
|
|
f"compare_{topic_a[:20]}_{topic_b[:20]}.md"
|
|
.replace(" ", "_")
|
|
)
|
|
await update.message.reply_document(
|
|
document=io.BytesIO(full_output.encode()),
|
|
filename=filename,
|
|
caption=f"📊 Comparación: {topic_a} vs {topic_b}"
|
|
)
|
|
try:
|
|
await msg.delete()
|
|
except Exception:
|
|
pass
|
|
else:
|
|
await msg.edit_text(full_output, parse_mode=ParseMode.MARKDOWN)
|
|
|
|
except asyncio.CancelledError:
|
|
await msg.edit_text("🛑 Comparación cancelada.")
|
|
except Exception as e:
|
|
logger.error("Compare task failed", error=str(e))
|
|
try:
|
|
await msg.edit_text(f"❌ Error: {str(e)[:200]}")
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
await db_conn_a.close()
|
|
await db_conn_b.close()
|
|
|
|
task = asyncio.create_task(run_compare())
|
|
_active_tasks[chat_id] = task
|
|
|
|
|
|
def create_bot() -> Application:
|
|
app = (
|
|
Application.builder()
|
|
.token(settings.telegram_bot_token)
|
|
.post_init(_on_startup)
|
|
.post_shutdown(_on_shutdown)
|
|
.build()
|
|
)
|
|
|
|
app.add_handler(CommandHandler("start", cmd_start))
|
|
app.add_handler(CommandHandler("help", cmd_help))
|
|
app.add_handler(CommandHandler("research", cmd_research))
|
|
app.add_handler(CommandHandler("status", cmd_status))
|
|
app.add_handler(CommandHandler("finish", cmd_finish))
|
|
app.add_handler(CommandHandler("generate", cmd_generate))
|
|
app.add_handler(CommandHandler("short_spec", cmd_short_spec))
|
|
# Un .json adjunto es un spec editado que vuelve para re-renderizarse.
|
|
app.add_handler(MessageHandler(filters.Document.FileExtension("json"),
|
|
handle_spec_document))
|
|
app.add_handler(CommandHandler("upload_short", cmd_upload_short))
|
|
app.add_handler(CommandHandler("sources", cmd_sources))
|
|
app.add_handler(CommandHandler("outputs", cmd_outputs))
|
|
app.add_handler(CommandHandler("news", cmd_news))
|
|
app.add_handler(CommandHandler("export", cmd_export))
|
|
app.add_handler(CommandHandler("costs", cmd_costs))
|
|
app.add_handler(CommandHandler("watch", cmd_watch))
|
|
app.add_handler(CommandHandler("unwatch", cmd_unwatch))
|
|
app.add_handler(CommandHandler("watches", cmd_watches))
|
|
app.add_handler(CommandHandler("process", cmd_process))
|
|
app.add_handler(CommandHandler("cancel", cmd_cancel))
|
|
app.add_handler(CommandHandler("purge", cmd_purge))
|
|
app.add_handler(CommandHandler("publish", cmd_publish))
|
|
app.add_handler(CommandHandler("compare", cmd_compare))
|
|
|
|
return app
|
|
|
|
|
|
def run():
|
|
logger.info("Starting ResearchOwl bot")
|
|
app = create_bot()
|
|
app.run_polling(allowed_updates=Update.ALL_TYPES)
|