969 lines
44 KiB
Python
969 lines
44 KiB
Python
"""
|
|
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 <número>, /exp <número>/<año>, o /exp <texto de carátula>\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 <número>/<año> o /pdf <número>/<año> <posición>\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
|