""" telegram_bot.py — Bot de Telegram para Pedrito Hechakuaa (multi-tenant). Responsabilidades: - Enviar notificaciones proactivas a cada abogado - Responder comandos: /start (onboarding), /notif, /estado, /exp, /pdf, /salir, /ayuda - Autorización: cualquiera puede iniciar /start; los demás comandos requieren estado='activo' en la DB """ from __future__ import annotations import re import unicodedata from datetime import datetime, timezone from typing import TYPE_CHECKING from zoneinfo import ZoneInfo import structlog from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.constants import ParseMode from telegram.ext import ( Application, CallbackQueryHandler, CommandHandler, ContextTypes, MessageHandler, filters, ) import audit import vault as vault_mod from config import Settings from utils.rate_limiter import RateLimiter from csj_client import ( CSJAuthError, CSJClient, CSJConnectionError, CSJDocumentoNoDisponibleError, ) from database import get_tenant from onboarding import OnboardingFlow from pdf_extractor import extraer_extracto from storage import Snapshot if TYPE_CHECKING: from csj_client import ActuacionCaso, CasoJudicial, NotificacionPendiente logger = structlog.get_logger(__name__) PJ_URL = "https://apps.csj.gov.py/gestion-partes/notificaciones-electronicas" # ────────────────────────────────────────────────────────────────────────────── # Mapeo de frases libres a comandos (sin LLM) # ────────────────────────────────────────────────────────────────────────────── _FRASES_COMANDOS: dict[str, str] = { "novedades": "notif", "actualizaciones": "notif", "que hay nuevo": "notif", "qué hay nuevo": "notif", "hay algo nuevo": "notif", "alguna novedad": "notif", "notificaciones": "notif", "notificacion": "notif", "notificación": "notif", "revisar": "notif", "actualizar": "notif", "estado": "estado", "como estas": "estado", "cómo estás": "estado", "ayuda": "ayuda", "help": "ayuda", "que podes hacer": "ayuda", "qué podés hacer": "ayuda", "comandos": "ayuda", } def _normalizar(texto: str) -> str: """Lowercase, sin acentos, sin puntuación — para comparación de frases.""" texto = texto.lower().strip() texto = unicodedata.normalize("NFD", texto) texto = "".join(c for c in texto if unicodedata.category(c) != "Mn") texto = re.sub(r"[^\w\s]", " ", texto) return re.sub(r"\s+", " ", texto).strip() # ────────────────────────────────────────────────────────────────────────────── # Helpers de formato # ────────────────────────────────────────────────────────────────────────────── def _escape_md(text: str) -> str: """Escapa caracteres especiales para MarkdownV2 de Telegram.""" # Backslash primero (antes de introducir nuevos con los demás reemplazos) text = text.replace("\\", "\\\\") for char in "_*[]()~`>#+-=|{}.!": text = text.replace(char, f"\\{char}") return text def _hora_local(dt: datetime, tenant: dict) -> str: tz = ZoneInfo(tenant.get("timezone") or "America/Asuncion") return dt.astimezone(tz).strftime("%H:%M") def _fecha_hora_local(dt: datetime, tenant: dict) -> str: tz = ZoneInfo(tenant.get("timezone") or "America/Asuncion") return dt.astimezone(tz).strftime("%d/%m/%Y %H:%M") # ────────────────────────────────────────────────────────────────────────────── # Personalización de tono (Parte C1) # ────────────────────────────────────────────────────────────────────────────── _TONOS: dict[str, dict[str, str]] = { "notificacion_intro": { "formal": "Estimado/a {nombre}, se ha registrado una nueva actuación en el expediente {exp}.", "cotidiano": "Hola {nombre}! Apareció algo nuevo en {exp}.", "informal": "Ey {nombre}! Mirá lo que salió en {exp}.", }, "sin_novedades": { "formal": "No se registraron novedades desde las {hora}.", "cotidiano": "Sin novedades desde las {hora} de hoy.", "informal": "Todo tranquilo desde las {hora}.", }, "error_conexion": { "formal": "No fue posible conectarse al sistema judicial. Se reintentará en la próxima ronda.", "cotidiano": "No pude conectarme al PJ. Lo vuelvo a intentar más tarde.", "informal": "El PJ no responde. Reintento después.", }, "texto_no_reconocido": { "formal": "Solo proceso instrucciones específicas. Consultá /ayuda para ver los comandos disponibles.", "cotidiano": "No entendí eso. Mirá /ayuda para ver qué puedo hacer.", "informal": "Eso no lo manejo. Fijate en /ayuda.", }, } def _texto_tono(clave: str, tenant: dict, **kwargs: str) -> str: tono = tenant.get("tono") or "cotidiano" if tono not in ("formal", "cotidiano", "informal"): tono = "cotidiano" template = _TONOS.get(clave, {}).get(tono, "") return template.format(**kwargs) if kwargs else template # ────────────────────────────────────────────────────────────────────────────── # Helpers de expedientes (sin cambios de lógica) # ────────────────────────────────────────────────────────────────────────────── def parsear_argumento_exp(arg: str) -> dict: arg = arg.strip() m = re.match(r"^(\d+)/(\d+)$", arg) if m: numero = int(m.group(1)) anio = int(m.group(2)) if anio < 100: anio += 2000 return {"numero": numero, "anio": anio} if re.match(r"^\d+$", arg): return {"numero": int(arg)} return {"caratula": arg} def _label_busqueda(parsed: dict) -> str: if "caratula" in parsed: return parsed["caratula"] if "anio" in parsed: return f"{parsed['numero']}/{parsed['anio']}" return str(parsed["numero"]) def _formatear_detalle_caso( caso: CasoJudicial, actuaciones: list[ActuacionCaso], total_actuaciones: int, ) -> str: lines: list[str] = [] lines.append(f"📂 *Exp\\. {_escape_md(caso.expediente_str)}*") lines.append(_escape_md(caso.caratula)) partes_loc: list[str] = [] if caso.circunscripcion: partes_loc.append(_escape_md(caso.circunscripcion)) partes_loc.append(_escape_md(caso.estado)) lines.append(" \\| ".join(partes_loc)) if actuaciones: lines.append("") lines.append("*Últimas actuaciones:*") for act in actuaciones: fecha = _escape_md(act.fecha_formateada) tipo = _escape_md(act.tipo) desc = _escape_md(act.descripcion_actuacion or "sin descripción") lines.append(f"• {fecha} — {tipo}: {desc}") if total_actuaciones > len(actuaciones): lines.append("") lines.append(f"_\\({total_actuaciones} actuaciones en total\\)_") return "\n".join(lines) def _formatear_lista_casos( casos: list[CasoJudicial], total: int, busqueda: str, ) -> str: lines: list[str] = [] if total > len(casos): header = ( f"Se encontraron *{total}* expedientes para \"{_escape_md(busqueda)}\"" f" \\(mostrando {len(casos)}\\):" ) else: header = f"Se encontraron *{total}* expedientes para \"{_escape_md(busqueda)}\":" lines.append(header) for i, caso in enumerate(casos, 1): caratula = caso.caratula if len(caratula) > 60: caratula = caratula[:57] + "..." partes_loc = [] if caso.circunscripcion: partes_loc.append(caso.circunscripcion) partes_loc.append(caso.estado) lines.append("") lines.append( f"*{i}\\. Exp\\. {_escape_md(caso.expediente_str)}* — {_escape_md(caratula)}" ) lines.append(f" {_escape_md(' | '.join(partes_loc))}") lines.append("") lines.append(_escape_md("Para ver detalle: /exp {numero}/{anio}")) return "\n".join(lines) # ────────────────────────────────────────────────────────────────────────────── # PedritoBot # ────────────────────────────────────────────────────────────────────────────── class PedritoBot: """ Bot de Telegram para Pedrito Hechakuaa. Cualquier usuario puede iniciar el onboarding con /start. Los demás comandos requieren estado='activo' en la DB. """ def __init__( self, token: str, settings: Settings, onboarding: OnboardingFlow, ) -> None: self._token = token self._settings = settings self._onboarding = onboarding self._app: Application | None = None self._on_notif_request: object = None # callable(chat_id) → coroutine self._rl = RateLimiter(max_requests=20, window_seconds=60) def set_notif_callback(self, callback: object) -> None: """Registra el callable que dispara una revisión manual. Recibe chat_id como arg.""" self._on_notif_request = callback async def _check_rl(self, chat_id: int) -> bool: """Retorna False y loggea si el chat_id excedió el rate limit.""" if not self._rl.is_allowed(chat_id): await audit.log("rate_limit_excedido", chat_id=chat_id) logger.warning("rate_limit_excedido", chat_id=chat_id) return False return True def _snapshot_for(self, chat_id: int) -> Snapshot: path = self._settings.data_dir / f"snapshot_{chat_id}.json" return Snapshot(path) def _csj_client_for(self, tenant: dict) -> CSJClient: """Crea un CSJClient con las credenciales descifradas del tenant.""" chat_id: int = tenant["chat_id"] credentials_enc: str = tenant.get("credentials_enc") or "" usuario, password = vault_mod.descifrar_credenciales( chat_id, credentials_enc, self._settings.fernet_key ) return CSJClient(self._settings, usuario=usuario, password=password) # ────────────────────────────────────────────────────────────────────────── # Autorización # ────────────────────────────────────────────────────────────────────────── async def _get_tenant_activo(self, chat_id: int) -> dict | None: """Retorna el tenant si existe y está activo, None en caso contrario.""" tenant = await get_tenant(chat_id) if tenant and tenant.get("estado") == "activo": return tenant return None async def _rechazar_no_registrado(self, update: Update) -> None: # Mensaje mínimo: no revela qué hace el bot ni su arquitectura interna. # Solo dice que hay un comando /start, lo cual es estándar en todos los bots de Telegram. await update.effective_message.reply_text( # type: ignore[union-attr] "Para usar este bot escribí /start\\.", parse_mode=ParseMode.MARKDOWN_V2, ) # ────────────────────────────────────────────────────────────────────────── # Comandos # ────────────────────────────────────────────────────────────────────────── async def cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: chat_id = update.effective_chat.id # type: ignore[union-attr] if not await self._check_rl(chat_id): return await audit.log("comando_recibido", chat_id=chat_id, detalle={"comando": "start"}) tenant = await get_tenant(chat_id) if tenant and tenant.get("estado") == "activo": nombre = tenant.get("nombre_preferido") or "" texto = ( f"Hola {_escape_md(nombre)}\\! Ya estás registrado y activo\\.\n" "Tus expedientes se revisan automáticamente cada hora\\.\n\n" "Escribí /ayuda para ver qué puedo hacer\\." ) await update.effective_message.reply_text(texto, parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr] elif tenant and tenant.get("estado") == "pendiente" and self._onboarding.is_in_onboarding(chat_id): await update.effective_message.reply_text( # type: ignore[union-attr] "Ya tenés un registro en curso\\. Seguí desde donde estabas, " "o escribí /start de nuevo para empezar desde cero\\.", parse_mode=ParseMode.MARKDOWN_V2, ) else: await self._onboarding.iniciar_registro(update, context) async def cmd_ayuda(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: chat_id = update.effective_chat.id # type: ignore[union-attr] if not await self._check_rl(chat_id): return await audit.log("comando_recibido", chat_id=chat_id, detalle={"comando": "ayuda"}) if not await self._get_tenant_activo(chat_id): await self._rechazar_no_registrado(update) return texto = ( "*Comandos de Pedrito Hechakuaa*\n\n" "/notif — revisar notificaciones pendientes ahora\n" "/estado — último chequeo y estadísticas\n" "/exp — buscar expediente por número o carátula\n" " _Ej: /exp 198/2026 o /exp Rodriguez_\n" "/pdf — descargar el PDF de la actuación más reciente\n" " _Ej: /pdf 198/2026_\n" "/salir — borrar tus datos y cancelar el monitoreo\n" "/ayuda — esta lista\n\n" "El bot revisa automáticamente en horario hábil \\(lunes a viernes, 7:00\\-18:00\\)\\." ) await update.effective_message.reply_text(texto, parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr] async def cmd_estado(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: chat_id = update.effective_chat.id # type: ignore[union-attr] if not await self._check_rl(chat_id): return await audit.log("comando_recibido", chat_id=chat_id, detalle={"comando": "estado"}) tenant = await self._get_tenant_activo(chat_id) if not tenant: await self._rechazar_no_registrado(update) return snapshot = self._snapshot_for(chat_id) last = snapshot.last_check if last: last_str = _escape_md(_fecha_hora_local(last, tenant)) else: last_str = "nunca" nombre = _escape_md(tenant.get("nombre_preferido") or "") tono = _escape_md(tenant.get("tono") or "cotidiano") tz_str = _escape_md(tenant.get("timezone") or "America/Asuncion") texto = ( f"*Estado de Pedrito Hechakuaa*\n\n" f"Hola {nombre}\\!\n" f"Último chequeo: {last_str}\n" f"Notificaciones registradas: {snapshot.total_vistas}\n" f"Tono: {tono}\n" f"Zona horaria: {tz_str}\n\n" "Para revisar ahora: /notif" ) await update.effective_message.reply_text(texto, parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr] async def cmd_notif(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: chat_id = update.effective_chat.id # type: ignore[union-attr] if not await self._check_rl(chat_id): return await audit.log("comando_recibido", chat_id=chat_id, detalle={"comando": "notif"}) if not await self._get_tenant_activo(chat_id): await self._rechazar_no_registrado(update) return await update.effective_message.reply_text("Revisando notificaciones...") # type: ignore[union-attr] if self._on_notif_request: import asyncio asyncio.create_task(self._on_notif_request(chat_id)) # type: ignore[arg-type] else: await update.effective_message.reply_text( # type: ignore[union-attr] "El poller no está disponible todavía\\. Intentá en un momento\\.", parse_mode=ParseMode.MARKDOWN_V2, ) async def cmd_salir(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: chat_id = update.effective_chat.id # type: ignore[union-attr] if not await self._check_rl(chat_id): return await audit.log("comando_recibido", chat_id=chat_id, detalle={"comando": "salir"}) tenant = await get_tenant(chat_id) if not tenant: await update.effective_message.reply_text( # type: ignore[union-attr] "No tenés datos registrados en este bot\\.", parse_mode=ParseMode.MARKDOWN_V2, ) return await self._onboarding.iniciar_salir(update, context) async def cmd_resetear(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: caller_id = update.effective_chat.id # type: ignore[union-attr] await audit.log("comando_recibido", chat_id=caller_id, detalle={"comando": "resetear"}) if caller_id != self._settings.telegram_chat_id: return # ignorar silenciosamente # Argumento opcional: chat_id a resetear. Sin arg → resetear al propio admin. target_id = caller_id if context.args: try: target_id = int(context.args[0]) except ValueError: await update.effective_message.reply_text("chat_id inválido.") # type: ignore[union-attr] return from database import delete_tenant, get_tenant as _get_tenant existing = await _get_tenant(target_id) if existing is None: await update.effective_message.reply_text( # type: ignore[union-attr] "No encontré ningún usuario registrado con ese ID." ) return await delete_tenant(target_id) snapshot_path = self._settings.data_dir / f"snapshot_{target_id}.json" if snapshot_path.exists(): snapshot_path.unlink() self._rl.reset(target_id) logger.info("resetear_tenant", target_chat_id=target_id, por=caller_id) await audit.log("tenant_reseteado", chat_id=target_id, detalle={"por": caller_id}) await update.effective_message.reply_text( # type: ignore[union-attr] f"Listo. El usuario {target_id} fue reseteado. Puede escribir /start para registrarse de nuevo." ) async def cmd_exp(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: chat_id = update.effective_chat.id # type: ignore[union-attr] if not await self._check_rl(chat_id): return await audit.log("comando_recibido", chat_id=chat_id, detalle={"comando": "exp"}) tenant = await self._get_tenant_activo(chat_id) if not tenant: await self._rechazar_no_registrado(update) return args = context.args if not args: await update.effective_message.reply_text( # type: ignore[union-attr] "Uso: /exp , /exp /, o /exp \n" "Ejemplo: /exp 198/2026" ) return arg = " ".join(args) parsed = parsear_argumento_exp(arg) await update.effective_message.reply_text("⏳ Buscando expediente...") # type: ignore[union-attr] try: client = self._csj_client_for(tenant) except ValueError as e: logger.error("error_descifrar_credenciales_cmd_exp", chat_id=chat_id, error=str(e)) await update.effective_message.reply_text("❌ Error interno con tus credenciales\\. Escribí /salir y volvé a registrarte\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr] return async with client: try: casos, total = await client.buscar_expedientes( caratula=parsed.get("caratula"), numero=parsed.get("numero"), anio=parsed.get("anio"), cantidad=5, ) except CSJConnectionError as e: logger.error("error_buscar_expedientes", chat_id=chat_id, error=str(e)) await update.effective_message.reply_text("❌ No pude conectarme al sistema judicial\\. Intentá de nuevo en un momento\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr] return except CSJAuthError as e: logger.error("error_auth_buscar_expedientes", chat_id=chat_id, error=str(e)) await update.effective_message.reply_text("❌ Error de autenticación con el PJ\\. Revisá tus credenciales con /salir y /start\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr] return if not casos: busqueda = _label_busqueda(parsed) await update.effective_message.reply_text( # type: ignore[union-attr] f"No encontré expedientes para \"{busqueda}\".\n" "Probá con la carátula o con número/año." ) return if len(casos) == 1: caso = casos[0] actuaciones: list[ActuacionCaso] = [] total_actuaciones = 0 try: actuaciones, total_actuaciones = await client.listar_actuaciones( cod_caso_judicial=caso.cod_caso_judicial, origen=caso.origen, cantidad=3, ) except Exception as e: logger.warning("error_listar_actuaciones_degradado", chat_id=chat_id, error=str(e)) texto = _formatear_detalle_caso(caso, actuaciones, total_actuaciones) await update.effective_message.reply_text(texto, parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr] else: busqueda = _label_busqueda(parsed) texto = _formatear_lista_casos(casos, total, busqueda) await update.effective_message.reply_text(texto, parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr] async def cmd_pdf(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: chat_id = update.effective_chat.id # type: ignore[union-attr] if not await self._check_rl(chat_id): return await audit.log("comando_recibido", chat_id=chat_id, detalle={"comando": "pdf"}) tenant = await self._get_tenant_activo(chat_id) if not tenant: await self._rechazar_no_registrado(update) return args = context.args if not args: await update.effective_message.reply_text( # type: ignore[union-attr] "Uso: /pdf / o /pdf / \n" "Ejemplo: /pdf 198/2026 o /pdf 198/2026 2 (segunda más reciente)" ) return partes = " ".join(args).strip().split() exp_arg = partes[0] posicion = 1 if len(partes) > 1: try: posicion = max(1, int(partes[1])) except ValueError: pass parsed = parsear_argumento_exp(exp_arg) if "numero" not in parsed or "anio" not in parsed: await update.effective_message.reply_text( # type: ignore[union-attr] "Para /pdf necesitás especificar número Y año.\nEjemplo: /pdf 198/2026" ) return await update.effective_message.reply_text("⏳ Buscando expediente...") # type: ignore[union-attr] try: client = self._csj_client_for(tenant) except ValueError as e: logger.error("error_descifrar_credenciales_cmd_pdf", chat_id=chat_id, error=str(e)) await update.effective_message.reply_text("❌ Error interno con tus credenciales\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr] return async with client: try: casos, _ = await client.buscar_expedientes( numero=parsed["numero"], anio=parsed["anio"], cantidad=1, ) except (CSJConnectionError, CSJAuthError) as e: logger.error("error_buscar_expediente_pdf", chat_id=chat_id, error=str(e)) await update.effective_message.reply_text("❌ No pude conectarme al sistema judicial\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr] return if not casos: await update.effective_message.reply_text( # type: ignore[union-attr] f"No encontré el expediente {parsed['numero']}/{parsed['anio']}." ) return caso = casos[0] try: actuaciones, _ = await client.listar_actuaciones( cod_caso_judicial=caso.cod_caso_judicial, origen=caso.origen, cantidad=max(posicion, 10), ) except (CSJConnectionError, CSJAuthError) as e: logger.error("error_listar_actuaciones_pdf", chat_id=chat_id, error=str(e)) await update.effective_message.reply_text("❌ No pude obtener las actuaciones del expediente\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr] return if not actuaciones: await update.effective_message.reply_text("Este expediente no tiene actuaciones registradas\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr] return if posicion > len(actuaciones): await update.effective_message.reply_text( # type: ignore[union-attr] f"El expediente solo tiene {len(actuaciones)} actuaciones disponibles.\n" f"Usá un número entre 1 y {len(actuaciones)}." ) return actuacion = actuaciones[posicion - 1] origen_actuacion = actuacion.origen if actuacion.origen is not None else caso.origen try: pdf_bytes = await client.descargar_documento_principal( cod_caso_judicial=caso.cod_caso_judicial, origen_caso=caso.origen, cod_actuacion_caso=actuacion.cod_actuacion_caso, origen_actuacion=origen_actuacion, ) except CSJDocumentoNoDisponibleError: await update.effective_message.reply_text("Esta actuación no tiene documento adjunto disponible\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr] return except (CSJConnectionError, CSJAuthError) as e: logger.error("error_descargando_pdf_cmd", chat_id=chat_id, error=str(e)) await update.effective_message.reply_text("❌ No pude descargar el documento\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr] return tipo = actuacion.tipo fecha = actuacion.fecha_formateada despacho = actuacion.descripcion_despacho or caso.descripcion_despacho_judicial or "" safe_tipo = re.sub(r"[^\w]", "_", tipo)[:30] filename = f"{safe_tipo}_{caso.nro_expediente_numero}_{caso.nro_expediente_anio}_{fecha.replace('/', '-')}.pdf" caption = f"📎 {tipo}\nExp. {caso.expediente_str} — {fecha}" if despacho: caption += f"\n{despacho}" await update.effective_message.reply_document( # type: ignore[union-attr] document=pdf_bytes, filename=filename, caption=caption, ) extracto = extraer_extracto(pdf_bytes) if extracto: await update.effective_message.reply_text( # type: ignore[union-attr] f"📄 *Contenido del documento:*\n{_escape_md(extracto)}\n\n" "⚠️ _Verificá el original antes de actuar\\._", parse_mode=ParseMode.MARKDOWN_V2, ) await audit.log("bot_pdf_enviado", chat_id=chat_id, detalle={"cod_caso": caso.cod_caso_judicial, "cod_actuacion": actuacion.cod_actuacion_caso}, tenant=tenant) # ────────────────────────────────────────────────────────────────────────── # Handler de texto libre (para onboarding) # ────────────────────────────────────────────────────────────────────────── async def handle_mensaje(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Procesa texto libre — redirige al onboarding si el usuario está en ese flujo.""" chat_id = update.effective_chat.id # type: ignore[union-attr] if not await self._check_rl(chat_id): return await audit.log("mensaje_recibido", chat_id=chat_id) if self._onboarding.is_in_onboarding(chat_id): await self._onboarding.handle_mensaje(update, context) async def _intentar_mapear_comando( self, update: Update, context: ContextTypes.DEFAULT_TYPE, texto: str, ) -> bool: """ Intenta mapear texto libre a un comando conocido sin LLM. Retorna True si encontró match y ejecutó el handler correspondiente. """ texto_norm = _normalizar(texto) texto_lower = texto.lower() # Regex especiales sobre texto original (preserva la "/") m_exp = re.search(r"\bexp(?:ediente)?\b\s+(\d+(?:/\d+)?)", texto_lower) if m_exp: context.args = [m_exp.group(1)] # type: ignore[assignment] await self.cmd_exp(update, context) return True m_pdf = re.search(r"\bpdf\b\s+(\d+/\d+)", texto_lower) if m_pdf: context.args = [m_pdf.group(1)] # type: ignore[assignment] await self.cmd_pdf(update, context) return True # Matching por frases del diccionario for frase, comando in _FRASES_COMANDOS.items(): if _normalizar(frase) in texto_norm: if comando == "notif": await self.cmd_notif(update, context) elif comando == "estado": await self.cmd_estado(update, context) elif comando == "ayuda": await self.cmd_ayuda(update, context) return True return False async def handle_texto_libre(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Responde a texto que no es comando ni está en un flujo de onboarding.""" chat_id = update.effective_chat.id # type: ignore[union-attr] if not await self._check_rl(chat_id): return if self._onboarding.is_in_onboarding(chat_id): return # el onboarding lo maneja en handle_mensaje, no responder acá tenant = await get_tenant(chat_id) if not (tenant and tenant.get("estado") == "activo"): # Usuarios sin registro: silencio total. return texto_original = (update.effective_message.text or "") # type: ignore[union-attr] if await self._intentar_mapear_comando(update, context, texto_original): return texto = _escape_md(_texto_tono("texto_no_reconocido", tenant)) await update.effective_message.reply_text(texto, parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr] # ────────────────────────────────────────────────────────────────────────── # Handlers de callbacks # ────────────────────────────────────────────────────────────────────────── async def handle_onb_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Callbacks de onboarding (botones onb:...).""" chat_id = update.effective_chat.id # type: ignore[union-attr] if not await self._check_rl(chat_id): return await audit.log("callback_recibido", chat_id=chat_id, detalle={"tipo": "onb"}) await self._onboarding.handle_callback(update, context) async def handle_pdf_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handler del botón 'Ver PDF' en notificaciones proactivas.""" query = update.callback_query chat_id = update.effective_chat.id # type: ignore[union-attr] if not await self._check_rl(chat_id): return await audit.log("callback_recibido", chat_id=chat_id, detalle={"tipo": "pdf"}) if not await self._get_tenant_activo(chat_id): # type: ignore[arg-type] await query.answer("No estás autorizado.", show_alert=True) # type: ignore[union-attr] return await query.answer() # type: ignore[union-attr] _, cod_caso, origen, cod_actuacion = query.data.split(":") # type: ignore[union-attr] tenant = await get_tenant(chat_id) if not tenant: await query.message.reply_text("No pudiste ser identificado\\. Escribí /start\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr] return await query.message.reply_text("⏳ Descargando documento...") # type: ignore[union-attr] try: client = self._csj_client_for(tenant) except ValueError as e: logger.error("error_descifrar_credenciales_pdf_callback", chat_id=chat_id, error=str(e)) await query.message.reply_text("❌ Error interno con tus credenciales\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr] return try: async with client: pdf_bytes = await client.descargar_documento_principal( cod_caso_judicial=int(cod_caso), origen_caso=int(origen), cod_actuacion_caso=int(cod_actuacion), ) except CSJDocumentoNoDisponibleError: await query.message.reply_text("Esta actuación no tiene documento adjunto disponible\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr] return except (CSJConnectionError, CSJAuthError) as e: logger.error("error_descargando_pdf_callback", chat_id=chat_id, error=str(e)) await query.message.reply_text("❌ No pude descargar el documento\\. Intentá de nuevo en un momento\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr] return await query.message.reply_document( # type: ignore[union-attr] document=pdf_bytes, filename=f"Actuacion_{cod_caso}_{cod_actuacion}.pdf", ) extracto = extraer_extracto(pdf_bytes) if extracto: await query.message.reply_text( # type: ignore[union-attr] f"📄 *Contenido del documento:*\n{_escape_md(extracto)}\n\n" "⚠️ _Verificá el original antes de actuar\\._", parse_mode=ParseMode.MARKDOWN_V2, ) await audit.log("bot_pdf_enviado", chat_id=chat_id, detalle={"cod_caso": int(cod_caso), "cod_actuacion": int(cod_actuacion)}, tenant=await get_tenant(chat_id)) # ────────────────────────────────────────────────────────────────────────── # Mensajes proactivos (llamados desde el poller) # ────────────────────────────────────────────────────────────────────────── async def enviar_notificacion( self, notif: NotificacionPendiente, tenant: dict, extracto: str | None = None, ) -> None: """Envía una notificación nueva al tenant específico, con su tono.""" assert self._app is not None chat_id: int = tenant["chat_id"] caratula = notif.caratula or "sin carátula" tipo = notif.descripcion_actuacion or notif.descripcion_tipo_actuacion or "sin tipo" juzgado = notif.juzgado or "sin juzgado" fecha = notif.fecha_formateada nombre = tenant.get("nombre_preferido") or "" intro = _texto_tono( "notificacion_intro", tenant, nombre=nombre, exp=notif.expediente_str ) extracto_bloque = "" if extracto: extracto_bloque = ( f"\n\n📄 *Extracto del documento:*\n" f"{_escape_md(extracto)}\n\n" f"⚠️ _Este es el texto crudo del documento\\. Verificá el original antes de actuar\\._" ) texto = ( f"{_escape_md(intro)}\n\n" f"📋 *Nueva notificación judicial*\n\n" f"{_escape_md(caratula)}\n" f"Exp\\. {_escape_md(notif.expediente_str)} \\— {_escape_md(juzgado)}\n\n" f"Tipo: {_escape_md(tipo)}\n" f"Fecha: {_escape_md(fecha)}" f"{extracto_bloque}\n\n" f"⚠️ Para acusar esta notificación entrá directamente en el sistema del PJ\\." ) cod_caso = notif.cod_caso_judicial origen = notif.origen cod_actuacion = notif.cod_actuacion_caso pdf_cb = f"pdf:{cod_caso}:{origen}:{cod_actuacion}" if (cod_caso and origen is not None and cod_actuacion) else None botones = [] if pdf_cb: botones.append(InlineKeyboardButton("📄 Ver PDF", callback_data=pdf_cb)) botones.append(InlineKeyboardButton("Ir al sistema del PJ", url=PJ_URL)) keyboard = InlineKeyboardMarkup([botones]) try: await self._app.bot.send_message( chat_id=chat_id, text=texto, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard, ) await audit.log("bot_notificacion_enviada", chat_id=chat_id, detalle={"cod_notificacion": notif.cod_notificacion_origen}, tenant=tenant) except Exception as e: logger.error("error_enviando_notificacion", chat_id=chat_id, error=str(e)) async def enviar_sin_novedades(self, tenant: dict, snapshot: Snapshot) -> None: """Confirma que no hay novedades al tenant que pidió /notif, con su tono y timezone.""" assert self._app is not None chat_id: int = tenant["chat_id"] last = snapshot.last_check hora_str = _hora_local(last, tenant) if last else "antes" mensaje = _texto_tono("sin_novedades", tenant, hora=hora_str) texto = f"✅ {_escape_md(mensaje)}" try: await self._app.bot.send_message( chat_id=chat_id, text=texto, parse_mode=ParseMode.MARKDOWN_V2, ) except Exception as e: logger.error("error_enviando_sin_novedades", chat_id=chat_id, error=str(e)) async def enviar_error_conexion(self, mensaje: str, tenant: dict) -> None: """Avisa al tenant que hubo 5 errores de conexión consecutivos.""" assert self._app is not None chat_id: int = tenant["chat_id"] msg_tono = _texto_tono("error_conexion", tenant) texto = f"⚠️ {_escape_md(msg_tono)}" try: await self._app.bot.send_message( chat_id=chat_id, text=texto, parse_mode=ParseMode.MARKDOWN_V2, ) except Exception as e: logger.error("error_enviando_error_conexion", chat_id=chat_id, error=str(e)) async def enviar_password_temporal(self, tenant: dict) -> None: """Avisa al tenant que su contraseña del PJ es temporal y suspende su monitoreo.""" assert self._app is not None chat_id: int = tenant["chat_id"] texto = ( "⚠️ *Acción requerida: contraseña temporal en el PJ*\n\n" "Tu contraseña del sistema judicial es temporal y hay que cambiarla\\.\n\n" "Entrá en https://apps\\.csj\\.gov\\.py, cambiala, " "y luego escribí /start para reactivarte\\." ) try: await self._app.bot.send_message( chat_id=chat_id, text=texto, parse_mode=ParseMode.MARKDOWN_V2, ) except Exception as e: logger.error("error_enviando_password_temporal", chat_id=chat_id, error=str(e)) async def enviar_error_auth(self, tenant: dict) -> None: """Avisa al tenant que sus credenciales son inválidas y suspende su monitoreo.""" assert self._app is not None chat_id: int = tenant["chat_id"] texto = ( "❌ *Error de autenticación con el PJ*\n\n" "No pude conectarme con tus credenciales actuales\\.\n\n" "Escribí /salir para borrar tus datos y /start para registrarte de nuevo\\." ) try: await self._app.bot.send_message( chat_id=chat_id, text=texto, parse_mode=ParseMode.MARKDOWN_V2, ) except Exception as e: logger.error("error_enviando_error_auth", chat_id=chat_id, error=str(e)) # ────────────────────────────────────────────────────────────────────────── # Lifecycle # ────────────────────────────────────────────────────────────────────────── def build(self) -> Application: """Construye la app de Telegram con todos los handlers registrados.""" self._app = Application.builder().token(self._token).build() self._app.add_handler(CommandHandler("start", self.cmd_start)) self._app.add_handler(CommandHandler("ayuda", self.cmd_ayuda)) self._app.add_handler(CommandHandler("estado", self.cmd_estado)) self._app.add_handler(CommandHandler("notif", self.cmd_notif)) self._app.add_handler(CommandHandler("salir", self.cmd_salir)) self._app.add_handler(CommandHandler("resetear", self.cmd_resetear)) self._app.add_handler(CommandHandler("exp", self.cmd_exp)) self._app.add_handler(CommandHandler("pdf", self.cmd_pdf)) self._app.add_handler( CallbackQueryHandler(self.handle_pdf_callback, pattern=r"^pdf:") ) self._app.add_handler( CallbackQueryHandler(self.handle_onb_callback, pattern=r"^onb:") ) self._app.add_handler( MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_mensaje) ) self._app.add_handler( MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_texto_libre) ) return self._app