Files
hechakuaa_bot/csj_client.py

706 lines
27 KiB
Python

"""
csj_client.py — Cliente HTTP para el sistema judicial del PJ Paraguay.
Responsabilidades:
- Login (POST /autenticador/login)
- Renovación de sesión cuando el token expira (relogin transparente)
- GET de notificaciones pendientes
- Manejo de errores y reintentos
Lo que NO hace:
- No acusa notificaciones (PUT .../recibir) — fuera del scope de la v0
- No hace nada que tenga efecto procesal
Refs:
- docs/reconocimiento/SISTEMA_NUEVO_RECON.md
- docs/reconocimiento/MANUAL_SANITIZADO.md (estructura de responses)
"""
from __future__ import annotations
import asyncio
import time
from datetime import datetime, timezone
from typing import Any
import httpx
import structlog
import audit
from config import Settings
from utils.sanitize import scrub
logger = structlog.get_logger(__name__)
# Headers que el sistema PJ espera ver (simulan Chrome)
BASE_HEADERS = {
"Accept": "application/json, text/plain, */*",
"Accept-Language": "es-ES,es;q=0.9",
"Connection": "keep-alive",
"Origin": "https://apps.csj.gov.py",
"Referer": "https://apps.csj.gov.py/gestion-partes",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
}
class CSJAuthError(Exception):
"""Credenciales incorrectas u otro error de autenticación no recuperable."""
class CSJPasswordTemporalError(CSJAuthError):
"""El password del PJ es temporal — el titular debe cambiarlo manualmente."""
class CSJConnectionError(Exception):
"""Error de red transitorio, puede reintentarse."""
class CSJDocumentoNoDisponibleError(Exception):
"""El documento principal de la actuación no existe (404)."""
class NotificacionPendiente:
"""Notificación pendiente del sistema judicial."""
__slots__ = (
"cod_notificacion_origen",
"cod_actuacion_caso",
"nro_expediente_numero",
"nro_expediente_anio",
"caratula",
"descripcion_actuacion",
"descripcion_tipo_actuacion",
"fecha",
"juzgado",
"circunscripcion",
"cod_caso_judicial",
"origen",
)
def __init__(self, data: dict[str, Any]) -> None:
self.cod_notificacion_origen: int = data["codNotificacionOrigen"]
# El endpoint de notificaciones usa codActuacionCaso — necesario para descargar PDF
self.cod_actuacion_caso: int | None = data.get("codActuacionCaso")
# Campos documentados como nroExpedienteNumero/Anio no existen en el endpoint real
self.nro_expediente_numero: int | None = data.get("nroExpedienteNumero")
self.nro_expediente_anio: int | None = data.get("nroExpedienteAnio")
self.caratula: str | None = data.get("caratula")
self.descripcion_actuacion: str | None = data.get("descripcionActuacion")
self.descripcion_tipo_actuacion: str | None = data.get("descripcionTipoActuacion")
# fecha: el campo real es fechaNotificacion, no fecha
self.fecha: str | None = data.get("fechaNotificacion") or data.get("fecha")
# juzgado: el campo real es descripcionDespacho
self.juzgado: str | None = data.get("descripcionDespacho") or data.get("juzgado")
# circunscripcion: el campo real es descripcionCircunscripcion
self.circunscripcion: str | None = data.get("descripcionCircunscripcion") or data.get("circunscripcion")
self.cod_caso_judicial: int | None = data.get("codCasoJudicial")
self.origen: int | None = data.get("origen")
@property
def expediente_str(self) -> str:
if self.nro_expediente_numero and self.nro_expediente_anio:
return f"{self.nro_expediente_numero}/{self.nro_expediente_anio}"
return "sin número"
@property
def fecha_formateada(self) -> str:
if not self.fecha:
return "sin fecha"
try:
dt = datetime.fromisoformat(self.fecha.replace("Z", "+00:00"))
return dt.strftime("%d/%m/%Y")
except ValueError:
return self.fecha
class CasoJudicial:
"""Expediente judicial devuelto por búsqueda."""
__slots__ = (
"cod_caso_judicial",
"caratula",
"origen",
"nro_expediente_numero",
"nro_expediente_anio",
"circunscripcion",
"nombre_circunscripcion",
"descripcion_despacho_judicial",
"descripcion_estado_despacho_caso",
"cod_estado_despacho_caso",
"es_corte",
"cantidad_total_elementos",
)
def __init__(self, data: dict[str, Any]) -> None:
self.cod_caso_judicial: int = data["codCasoJudicial"]
self.caratula: str = data.get("caratula") or "sin carátula"
self.origen: int = data.get("origen", 0)
self.nro_expediente_numero: int | None = data.get("nroExpedienteNumero")
self.nro_expediente_anio: int | None = data.get("nroExpedienteAnio")
self.circunscripcion: str | None = data.get("nombreCircunscripcion") or data.get("circunscripcion")
self.descripcion_despacho_judicial: str | None = data.get("descripcionDespachoJudicial")
self.descripcion_estado_despacho_caso: str | None = data.get("descripcionEstadoDespachoCaso")
self.cod_estado_despacho_caso: int = data.get("codEstadoDespachoCaso", 0)
self.es_corte: int = data.get("esCorte", 0)
self.cantidad_total_elementos: int = data.get("cantidadTotalElementos", 0)
@property
def expediente_str(self) -> str:
if self.nro_expediente_numero and self.nro_expediente_anio:
return f"{self.nro_expediente_numero}/{self.nro_expediente_anio}"
return "sin número"
@property
def estado(self) -> str:
return self.descripcion_estado_despacho_caso or "sin estado"
class ActuacionCaso:
"""Actuación/movimiento dentro de un expediente."""
__slots__ = (
"cod_actuacion_caso",
"cod_caso_judicial",
"cod_tipo_actuacion_global",
"descripcion_actuacion",
"descripcion_tipo_actuacion",
"descripcion_despacho",
"descripcion_proceso_caso",
"fecha_actuacion",
"fecha_finalizacion",
"estado",
"grupo",
"origen",
)
def __init__(self, data: dict[str, Any]) -> None:
self.cod_actuacion_caso: int = data["codActuacionCaso"]
self.cod_caso_judicial: int = data["codCasoJudicial"]
self.cod_tipo_actuacion_global: int | None = data.get("codTipoActuacionGlobal")
self.descripcion_actuacion: str | None = data.get("descripcionActuacion")
self.descripcion_tipo_actuacion: str | None = data.get("descripcionTipoActuacion")
self.descripcion_despacho: str | None = data.get("descripcionDespacho")
self.descripcion_proceso_caso: str | None = data.get("descripcionProcesoCaso")
self.fecha_actuacion: str | None = data.get("fechaActuacion")
self.fecha_finalizacion: str | None = data.get("fechaFinalizacion")
self.estado: str | None = data.get("estado")
self.grupo: str | None = data.get("grupo")
self.origen: int | None = data.get("origen")
@property
def fecha_formateada(self) -> str:
if not self.fecha_actuacion:
return "sin fecha"
try:
dt = datetime.fromisoformat(self.fecha_actuacion)
return dt.strftime("%d/%m/%Y")
except ValueError:
return self.fecha_actuacion
@property
def tipo(self) -> str:
return self.descripcion_tipo_actuacion or f"Tipo {self.cod_tipo_actuacion_global}"
class _Session:
"""Sesión autenticada con el PJ."""
def __init__(
self,
bearer_token: str,
cookie_session: str,
rol_id: int,
expires_at: datetime,
) -> None:
self._bearer_token = bearer_token
self.cookie_session = cookie_session
self.rol_id = rol_id
self.expires_at = expires_at
@property
def is_expired(self) -> bool:
return datetime.now(timezone.utc) >= self.expires_at
@property
def needs_refresh(self) -> bool:
"""True si queda menos de 10 minutos para expirar."""
from datetime import timedelta
return datetime.now(timezone.utc) >= self.expires_at - timedelta(minutes=10)
def auth_headers(self) -> dict[str, str]:
return {
"Cookie": f"cookiesession1={self.cookie_session}",
"Authorization": f"Bearer {self._bearer_token}",
"usuario-rol": str(self.rol_id),
}
class CSJClient:
"""
Cliente HTTP del Sistema de Gestión de Partes del PJ Paraguay.
Solo lectura. No realiza ninguna acción con efectos procesales.
Para multi-tenant: pasar usuario y password explícitos al constructor.
Si no se pasan, se leen de settings (credenciales del tenant principal).
"""
def __init__(
self,
settings: Settings,
*,
usuario: str | None = None,
password: str | None = None,
chat_id: int | None = None,
) -> None:
self._settings = settings
self._usuario = usuario
self._password = password
self._chat_id = chat_id
self._session: _Session | None = None
self._http: httpx.AsyncClient | None = None
async def __aenter__(self) -> CSJClient:
self._http = httpx.AsyncClient(
base_url=self._settings.pj_base_url,
headers=BASE_HEADERS,
timeout=httpx.Timeout(30.0, connect=10.0),
follow_redirects=True,
)
return self
async def __aexit__(self, *args: Any) -> None:
if self._http:
await self._http.aclose()
# ──────────────────────────────────────────────────────────────────────────
# Autenticación
# ──────────────────────────────────────────────────────────────────────────
async def _get_cookie_session(self) -> str:
"""Paso 1: GET /login para obtener cookiesession1."""
assert self._http is not None
try:
r = await self._http.get("/login")
r.raise_for_status()
except httpx.HTTPError as e:
raise CSJConnectionError(f"No se pudo obtener cookie de sesión: {e}") from e
cookie = r.cookies.get("cookiesession1")
if not cookie:
# Algunos proxies devuelven el cookie en headers directamente
set_cookie = r.headers.get("set-cookie", "")
if "cookiesession1=" in set_cookie:
cookie = set_cookie.split("cookiesession1=")[1].split(";")[0]
if not cookie:
raise CSJConnectionError("El sistema PJ no devolvió cookiesession1")
return cookie
async def login_with_credentials(self, usuario: str, password: str) -> None:
"""
Login con credenciales explícitas (para multi-tenant).
Levanta CSJAuthError, CSJPasswordTemporalError, CSJConnectionError.
No loggea ni guarda usuario ni password.
"""
assert self._http is not None
t0 = time.monotonic()
try:
cookie_session = await self._get_cookie_session()
r = await self._http.post(
"/autenticador/login",
json={"usuario": usuario, "clave": password, "temporal": False},
headers={"Cookie": f"cookiesession1={cookie_session}"},
)
except httpx.HTTPError as e:
raise CSJConnectionError(f"Error de red en login: {e}") from e
if r.status_code == 401:
raise CSJAuthError("Credenciales incorrectas (401)")
if r.status_code not in (200, 201):
raise CSJConnectionError(f"Login devolvió status inesperado: {r.status_code}")
data = r.json()
atributos = data.get("atributos") or {}
if isinstance(atributos, dict) and atributos.get("clave") == "temporal":
raise CSJPasswordTemporalError(
"La contraseña del PJ es temporal. "
"Cambiala en https://apps.csj.gov.py antes de continuar."
)
par_tokens = data.get("parTokens", {})
bearer_token = par_tokens.get("bearerToken")
if not bearer_token:
raise CSJAuthError(f"Login no devolvió bearerToken. Response: {data}")
expires_str = par_tokens.get("timestampExpiracionUtc", "")
try:
expires_at = datetime.fromisoformat(expires_str.replace("Z", "+00:00"))
except (ValueError, AttributeError):
from datetime import timedelta
expires_at = datetime.now(timezone.utc) + timedelta(minutes=55)
logger.warning("timestampExpiracionUtc_no_parseable_usando_55min")
self._session = _Session(
bearer_token=bearer_token,
cookie_session=cookie_session,
rol_id=self._settings.pj_rol_id,
expires_at=expires_at,
)
duracion_ms = int((time.monotonic() - t0) * 1000)
logger.info(
"login_exitoso",
expires_at=expires_at.isoformat(),
rol_id=self._settings.pj_rol_id,
)
await audit.log("pj_login", chat_id=self._chat_id, detalle={"duracion_ms": duracion_ms}, duracion_ms=duracion_ms)
async def login(self) -> None:
"""
Login usando credenciales del constructor (explícitas o del Settings).
Levanta CSJAuthError, CSJPasswordTemporalError, CSJConnectionError.
"""
if self._usuario is not None and self._password is not None:
await self.login_with_credentials(self._usuario, self._password)
else:
# Credenciales del tenant principal (desde .env)
usuario = self._settings.descifrar_usuario()
password = self._settings.descifrar_password()
try:
await self.login_with_credentials(usuario, password)
finally:
del usuario, password
async def _asegurar_sesion(self) -> _Session:
"""Garantiza que hay una sesión válida. Hace relogin si es necesario."""
if self._session is None or self._session.is_expired:
if self._session is not None: # era válida pero expiró
await audit.log("pj_relogin", chat_id=self._chat_id, detalle={"motivo": "expirado"})
logger.info("sesion_expirada_o_inexistente_relogin")
await self.login()
elif self._session.needs_refresh:
await audit.log("pj_relogin", chat_id=self._chat_id, detalle={"motivo": "proactivo"})
logger.info("sesion_proxima_a_expirar_relogin_proactivo")
await self.login()
assert self._session is not None
return self._session
# ──────────────────────────────────────────────────────────────────────────
# Notificaciones (read-only)
# ──────────────────────────────────────────────────────────────────────────
async def contar_notificaciones(self) -> int:
"""
Retorna la cantidad de notificaciones pendientes.
Request barato (~50 bytes de response). Usar como check antes del listado.
"""
session = await self._asegurar_sesion()
assert self._http is not None
try:
r = await self._http.get(
"/api/gestion-partes/Notificaciones/CantidadNotificacionesPorRecibir",
headers=session.auth_headers(),
)
except httpx.HTTPError as e:
raise CSJConnectionError(f"Error al contar notificaciones: {e}") from e
if r.status_code == 401:
# Token inválido — forzar relogin y reintentar una vez
logger.warning("401_en_contar_notificaciones_reintentando")
await audit.log("pj_relogin", chat_id=self._chat_id, detalle={"motivo": "401"})
self._session = None
session = await self._asegurar_sesion()
r = await self._http.get(
"/api/gestion-partes/Notificaciones/CantidadNotificacionesPorRecibir",
headers=session.auth_headers(),
)
r.raise_for_status()
# El endpoint devuelve un entero directamente o un objeto con un campo
data = r.json()
cantidad: int
if isinstance(data, int):
cantidad = data
elif isinstance(data, dict):
# Intentar campos comunes
for key in ("cantidad", "total", "count", "value"):
if key in data:
cantidad = int(data[key])
break
else:
logger.warning("respuesta_inesperada_en_contar", data=str(data)[:200])
cantidad = -1
else:
logger.warning("respuesta_inesperada_en_contar", data=str(data)[:200])
cantidad = -1
await audit.log("pj_notificaciones_contadas", chat_id=self._chat_id, detalle={"cantidad": cantidad})
return cantidad
async def listar_notificaciones(self, cantidad: int = 50) -> list[NotificacionPendiente]:
"""
Retorna la lista de notificaciones pendientes (sin acusarlas).
cantidad: máximo de notificaciones a traer (default 50, suficiente para uso diario)
"""
session = await self._asegurar_sesion()
assert self._http is not None
try:
r = await self._http.get(
"/api/gestion-partes/Notificaciones/PorRecibir",
params={"offset": 0, "cantidad": cantidad},
headers=session.auth_headers(),
)
except httpx.HTTPError as e:
raise CSJConnectionError(f"Error al listar notificaciones: {e}") from e
if r.status_code == 401:
logger.warning("401_en_listar_notificaciones_reintentando")
await audit.log("pj_relogin", chat_id=self._chat_id, detalle={"motivo": "401"})
self._session = None
session = await self._asegurar_sesion()
r = await self._http.get(
"/api/gestion-partes/Notificaciones/PorRecibir",
params={"offset": 0, "cantidad": cantidad},
headers=session.auth_headers(),
)
if r.status_code == 403:
raise CSJAuthError(
"Acceso denegado al listado de notificaciones (403). "
"La cuenta puede no tener el rol de abogado habilitado en el PJ."
)
r.raise_for_status()
data = r.json()
# El endpoint puede devolver una lista directa o un objeto con items
if isinstance(data, list):
items = data
elif isinstance(data, dict):
items = (
data.get("resultado")
or data.get("items")
or data.get("data")
or data.get("notificaciones")
or []
)
if not items and data:
logger.warning("respuesta_inesperada_en_listar", claves=list(data.keys()), data=str(data)[:300])
else:
logger.warning("respuesta_inesperada_en_listar", data=str(data)[:200])
return []
notifs = []
for item in items:
try:
notifs.append(NotificacionPendiente(item))
except (KeyError, TypeError) as e:
logger.warning("error_parseando_notificacion", error=str(e), item=str(item)[:200])
logger.info("notificaciones_listadas", cantidad=len(notifs))
await audit.log("pj_notificaciones_listadas", chat_id=self._chat_id, detalle={"cantidad": len(notifs)})
return notifs
async def buscar_expedientes(
self,
*,
caratula: str | None = None,
numero: int | None = None,
anio: int | None = None,
cantidad: int = 10,
offset: int = 0,
) -> tuple[list[CasoJudicial], int]:
"""
Busca expedientes por carátula y/o número+año.
Retorna (lista de casos, total de registros).
Al menos uno de caratula o numero debe proveerse.
Parámetros confirmados por reconocimiento 22/05/2026:
- NroExpedienteAnho: año con 'h', no 'ñ'
"""
if not caratula and not numero:
raise ValueError("Se requiere al menos carátula o número de expediente")
session = await self._asegurar_sesion()
assert self._http is not None
params: dict[str, Any] = {
"offset": offset,
"cantidad": cantidad,
"caratula": caratula or "",
}
if numero is not None:
params["NroExpedienteNumero"] = numero
if anio is not None:
params["NroExpedienteAnho"] = anio
try:
r = await self._http.get(
"/api/gestion-partes/CasoJudicial",
params=params,
headers=session.auth_headers(),
)
except httpx.HTTPError as e:
raise CSJConnectionError(f"Error al buscar expedientes: {e}") from e
if r.status_code == 401:
await audit.log("pj_relogin", chat_id=self._chat_id, detalle={"motivo": "401"})
self._session = None
session = await self._asegurar_sesion()
r = await self._http.get(
"/api/gestion-partes/CasoJudicial",
params=params,
headers=session.auth_headers(),
)
r.raise_for_status()
data = r.json()
items = data.get("resultado") or []
total = data.get("cantidadTotalRegistros") or 0
casos: list[CasoJudicial] = []
for item in items:
try:
casos.append(CasoJudicial(item))
except (KeyError, TypeError) as e:
logger.warning("error_parseando_caso", error=str(e), item=str(item)[:200])
logger.info("expedientes_encontrados", total=total, devueltos=len(casos))
query_tipo = "numero" if numero is not None else "caratula"
await audit.log("pj_expediente_buscado", chat_id=self._chat_id, detalle={"query_tipo": query_tipo, "resultados": len(casos)})
return casos, total
async def listar_actuaciones(
self,
cod_caso_judicial: int,
origen: int,
cantidad: int = 10,
offset: int = 0,
incluir_notificaciones: bool = False,
) -> tuple[list[ActuacionCaso], int]:
"""
Retorna las actuaciones de un expediente.
URL confirmada en logs de producción 19/06/2026:
GET /api/gestion-partes/CasoJudicial/{codCasoJudicial}/{origen}:0/actuaciones
El PJ usa ":" como separador entre cod_caso_judicial y origen
(igual que en descargar_documento_principal()), no "/".
"""
session = await self._asegurar_sesion()
assert self._http is not None
url = f"/api/gestion-partes/CasoJudicial/{cod_caso_judicial}/{origen}:0/actuaciones"
params: dict[str, Any] = {
"offset": offset,
"cantidad": cantidad,
"incluirNotificaciones": str(incluir_notificaciones).lower(),
}
try:
r = await self._http.get(url, params=params, headers=session.auth_headers())
except httpx.HTTPError as e:
raise CSJConnectionError(f"Error al listar actuaciones: {e}") from e
if r.status_code == 401:
await audit.log("pj_relogin", chat_id=self._chat_id, detalle={"motivo": "401"})
self._session = None
session = await self._asegurar_sesion()
r = await self._http.get(url, params=params, headers=session.auth_headers())
r.raise_for_status()
data = r.json()
items = data.get("resultado") or []
total = data.get("cantidadTotalRegistros") or 0
actuaciones: list[ActuacionCaso] = []
for item in items:
try:
actuaciones.append(ActuacionCaso(item))
except (KeyError, TypeError) as e:
logger.warning("error_parseando_actuacion", error=str(e), item=str(item)[:200])
logger.info(
"actuaciones_listadas",
cod_caso=cod_caso_judicial,
total=total,
devueltas=len(actuaciones),
)
await audit.log("pj_actuaciones_listadas", chat_id=self._chat_id, detalle={"cod_caso": cod_caso_judicial, "total": total})
return actuaciones, total
async def descargar_documento_principal(
self,
cod_caso_judicial: int,
origen_caso: int,
cod_actuacion_caso: int,
origen_actuacion: int | None = None,
) -> bytes:
"""
Descarga el documento principal (PDF) de una actuación.
Retorna los bytes del PDF listos para enviar a Telegram.
URL confirmada 24/05/2026:
GET /api/gestion-partes/CasoJudicial/{cod_caso}/{origen_caso}:0
/actuaciones/{cod_actuacion}:{origen_actuacion}:0/documentos/principal
⚠️ El separador en este endpoint es ':' (dos puntos), NO '/' (barra).
Son convenciones distintas del mismo backend. No "corregir" el patrón.
Levanta CSJDocumentoNoDisponibleError si el documento no existe (404).
"""
if origen_actuacion is None:
origen_actuacion = origen_caso
session = await self._asegurar_sesion()
assert self._http is not None
url = (
f"/api/gestion-partes/CasoJudicial/{cod_caso_judicial}"
f"/{origen_caso}:0/actuaciones/{cod_actuacion_caso}:{origen_actuacion}:0"
f"/documentos/principal"
)
logger.info("descargando_documento_pdf", url=scrub(url))
try:
r = await self._http.get(url, headers=session.auth_headers())
except httpx.HTTPError as e:
raise CSJConnectionError(f"Error al descargar documento: {e}") from e
if r.status_code == 401:
await audit.log("pj_relogin", chat_id=self._chat_id, detalle={"motivo": "401"})
self._session = None
session = await self._asegurar_sesion()
r = await self._http.get(url, headers=session.auth_headers())
if r.status_code == 404:
raise CSJDocumentoNoDisponibleError(
f"Documento no disponible para actuación {cod_actuacion_caso}"
)
r.raise_for_status()
logger.info("documento_descargado", bytes=len(r.content))
await audit.log(
"pj_pdf_descargado",
chat_id=self._chat_id,
detalle={"cod_caso": cod_caso_judicial, "cod_actuacion": cod_actuacion_caso, "size_kb": len(r.content) // 1024},
)
return r.content