- utils/rate_limiter.py: ventana deslizante 20 req/60s por chat_id, aplicado en todos los handlers de telegram_bot.py - database.py: tabla invite_codes (un solo uso por código), funciones crear_invite_code() y verificar_y_consumir_invite_code() - onboarding.py: verifica primero en DB (un solo uso), fallback al INVITE_CODE del .env (ilimitado, para uso interno) - scripts/generar_codigo.py: genera 1 o N códigos de invitación - scripts/rotar_fernet_key.py: re-cifra todos los tenants y el .env con nueva FERNET_KEY, backup automático de .env antes de escribir Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
859 lines
40 KiB
Python
859 lines
40 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
|
|
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 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"
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# 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:
|
|
await update.effective_message.reply_text( # type: ignore[union-attr]
|
|
"Primero tenés que registrarte\\. 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,
|
|
)
|
|
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 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 tenant and tenant.get("estado") == "activo":
|
|
texto = _escape_md(_texto_tono("texto_no_reconocido", tenant))
|
|
else:
|
|
texto = _escape_md("Solo respondo comandos específicos. Escribí /ayuda para ver la lista.")
|
|
|
|
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",
|
|
)
|
|
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) -> 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
|
|
)
|
|
|
|
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)}\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_notif = notif.cod_notificacion_origen
|
|
pdf_cb = f"pdf:{cod_caso}:{origen}:{cod_notif}" if (cod_caso and origen is not None) 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
|