""" 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 por reconocimiento 22/05/2026: GET /api/gestion-partes/CasoJudicial/{codCasoJudicial}/{origen}/0/actuaciones El segundo posicional (/0/) es fijo en todos los casos observados. """ 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