Análisis ofensivo → defensas implementadas:
1. /health/history sin auth (CRÍTICO)
- Requiere Bearer token (HEALTH_AUTH_TOKEN en .env)
- chat_ids enmascarados como hash SHA256[:8] opaco ("usr_a3b4c5d6")
- Accesos denegados loggeados con IP + User-Agent
2. /health revela inteligencia operacional (MEDIO)
- Sin auth: solo {"status", "timestamp"} — sin conteo de tenants ni timing
- Con auth: respuesta completa con métricas internas
- Misma URL, auth desbloquea detalle
3. Variables sensibles en proceso env (CRÍTICO)
- Después de cargar Settings(), se limpian FERNET_KEY, TELEGRAM_BOT_TOKEN,
PJ_USUARIO_ENC, PJ_PASSWORD_ENC, WEBHOOK_SECRET, HEALTH_AUTH_TOKEN, INVITE_CODE
- Protege contra /proc/<pid>/environ y herencia por subprocesos
4. Race condition TOCTOU en invite codes (MEDIO)
- SELECT+UPDATE reemplazado por UPDATE WHERE usado=0
- rowcount=0 → False; imposible que dos requests simultáneos consuman el mismo código
5. Timing oracle en invite codes (BAJO-MEDIO)
- Delay mínimo de 800ms en _paso_codigo() independientemente del resultado
- secrets.compare_digest() para comparación del código del .env
6. Information disclosure en respuestas a no-registrados (BAJO)
- _rechazar_no_registrado: "Para usar este bot escribí /start." (sin revelar comandos)
- handle_texto_libre: silencio total para usuarios sin registro (no confirmar que el bot existe)
7. Nginx rate limiting (DEPLOY.md)
- limit_req_zone pedrito_webhook: 30r/s, burst=50
- limit_req_zone pedrito_health: 10r/m, burst=5
- Headers de seguridad: X-Content-Type-Options, X-Frame-Options
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
549 lines
24 KiB
Python
549 lines
24 KiB
Python
"""
|
|
onboarding.py — Máquina de estados del flujo de registro.
|
|
|
|
Estado en memoria por chat_id. Si el bot se reinicia, el usuario hace /start de nuevo
|
|
(aceptable en este sprint).
|
|
|
|
Estados:
|
|
INICIO → mostró bienvenida con botones de consentimiento
|
|
ESPERANDO_NOMBRE → preguntó nombre, esperando texto libre
|
|
ESPERANDO_TONO → mostró botones de tono, esperando selección
|
|
ESPERANDO_TZ → mostró botones de timezone, esperando selección
|
|
ESPERANDO_TZ_CUSTOM → eligió "Otra", esperando texto con nombre de zona
|
|
ESPERANDO_USUARIO → preguntó cédula del PJ, esperando texto
|
|
ESPERANDO_PASSWORD → preguntó password del PJ, esperando texto (borrar tras procesar)
|
|
ESPERANDO_SALIR → mostró confirmación de /salir, esperando botón
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import secrets
|
|
import time
|
|
from pathlib import Path
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
import structlog
|
|
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
|
|
from telegram.constants import ParseMode
|
|
from telegram.ext import ContextTypes
|
|
|
|
import audit
|
|
import vault as vault_mod
|
|
from config import Settings
|
|
from csj_client import CSJAuthError, CSJClient, CSJConnectionError, CSJPasswordTemporalError
|
|
from database import delete_tenant, get_tenant, save_tenant, verificar_y_consumir_invite_code
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
# Estados (strings internos)
|
|
_ESPERANDO_CODIGO = "codigo"
|
|
_INICIO = "inicio"
|
|
_ESPERANDO_NOMBRE = "nombre"
|
|
_ESPERANDO_TONO = "tono"
|
|
_ESPERANDO_TZ = "tz"
|
|
_ESPERANDO_TZ_CUSTOM = "tz_custom"
|
|
_ESPERANDO_USUARIO = "usuario"
|
|
_ESPERANDO_PASSWORD = "password"
|
|
_ESPERANDO_SALIR = "salir"
|
|
|
|
_MAX_INTENTOS = 3
|
|
_MAX_INTENTOS_CODIGO = 3
|
|
|
|
_TZ_MAP = {
|
|
"py": "America/Asuncion",
|
|
"ar": "America/Argentina/Buenos_Aires",
|
|
"uy": "America/Montevideo",
|
|
}
|
|
|
|
|
|
def _esc(text: str) -> str:
|
|
"""Escapa MarkdownV2 para texto plano dentro de mensajes de onboarding."""
|
|
text = text.replace("\\", "\\\\")
|
|
for ch in "_*[]()~`>#+-=|{}.!":
|
|
text = text.replace(ch, f"\\{ch}")
|
|
return text
|
|
|
|
|
|
class OnboardingFlow:
|
|
"""Máquina de estados del flujo de registro multi-tenant."""
|
|
|
|
def __init__(self, settings: Settings) -> None:
|
|
self._settings = settings
|
|
self._states: dict[int, str] = {}
|
|
self._temp: dict[int, dict] = {} # datos parciales por chat_id
|
|
self._attempts: dict[int, int] = {} # intentos de credenciales fallidos
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Helpers de estado
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
def is_in_onboarding(self, chat_id: int) -> bool:
|
|
return chat_id in self._states
|
|
|
|
def _clear(self, chat_id: int) -> None:
|
|
self._states.pop(chat_id, None)
|
|
self._temp.pop(chat_id, None)
|
|
self._attempts.pop(chat_id, None)
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Puntos de entrada desde telegram_bot.py
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
async def iniciar_registro(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Llamado desde cmd_start cuando el usuario no está registrado."""
|
|
chat_id = update.effective_chat.id # type: ignore[union-attr]
|
|
self._temp[chat_id] = {}
|
|
self._attempts[chat_id] = 0
|
|
await audit.log("onboarding_inicio", chat_id=chat_id)
|
|
|
|
if self._settings.invite_code:
|
|
self._states[chat_id] = _ESPERANDO_CODIGO
|
|
await update.effective_message.reply_text( # type: ignore[union-attr]
|
|
"Hola\\! Para registrarte en este bot necesitás un código de acceso\\.\n\n"
|
|
"Escribí el código que te compartió el administrador:",
|
|
parse_mode=ParseMode.MARKDOWN_V2,
|
|
)
|
|
return
|
|
|
|
await self._mostrar_consentimiento(chat_id, update)
|
|
|
|
async def _mostrar_consentimiento(self, chat_id: int, update: Update) -> None:
|
|
self._states[chat_id] = _INICIO
|
|
texto = (
|
|
"Hola\\! Soy Pedrito, el asistente judicial del estudio\\.\n\n"
|
|
"Antes de empezar, necesito que sepas:\n"
|
|
"• Voy a consultar el sistema del Poder Judicial en tu nombre\n"
|
|
"• Guardo tus credenciales cifradas — solo yo las puedo leer\n"
|
|
"• Solo hago consultas\\. Nunca acuso notificaciones por vos\\.\n"
|
|
"• Podés borrar tus datos cuando quieras con /salir\n\n"
|
|
"¿Aceptás estas condiciones?"
|
|
)
|
|
keyboard = InlineKeyboardMarkup([
|
|
[
|
|
InlineKeyboardButton("✅ Sí, acepto", callback_data="onb:consent:si"),
|
|
InlineKeyboardButton("❌ No, gracias", callback_data="onb:consent:no"),
|
|
]
|
|
])
|
|
await update.effective_message.reply_text( # type: ignore[union-attr]
|
|
texto, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard
|
|
)
|
|
|
|
async def iniciar_salir(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Llamado desde cmd_salir."""
|
|
chat_id = update.effective_chat.id # type: ignore[union-attr]
|
|
self._states[chat_id] = _ESPERANDO_SALIR
|
|
|
|
texto = (
|
|
"¿Estás seguro que querés borrar tus datos?\n"
|
|
"Esto eliminará tus credenciales y preferencias del sistema\\."
|
|
)
|
|
keyboard = InlineKeyboardMarkup([
|
|
[
|
|
InlineKeyboardButton("✅ Sí, borrar todo", callback_data="onb:salir:si"),
|
|
InlineKeyboardButton("❌ Cancelar", callback_data="onb:salir:no"),
|
|
]
|
|
])
|
|
await update.effective_message.reply_text( # type: ignore[union-attr]
|
|
texto, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard
|
|
)
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Handlers de entrada (llamados desde telegram_bot.py)
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
async def handle_mensaje(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Procesa texto libre durante el onboarding."""
|
|
chat_id = update.effective_chat.id # type: ignore[union-attr]
|
|
estado = self._states.get(chat_id)
|
|
if not estado:
|
|
return
|
|
texto_msg = (update.effective_message.text or "").strip() # type: ignore[union-attr]
|
|
|
|
if estado == _ESPERANDO_CODIGO:
|
|
await self._paso_codigo(chat_id, texto_msg, update)
|
|
elif estado == _ESPERANDO_NOMBRE:
|
|
await self._paso_nombre(chat_id, texto_msg, update)
|
|
elif estado == _ESPERANDO_TZ_CUSTOM:
|
|
await self._paso_tz_custom(chat_id, texto_msg, update)
|
|
elif estado == _ESPERANDO_USUARIO:
|
|
await self._paso_usuario(chat_id, texto_msg, update)
|
|
elif estado == _ESPERANDO_PASSWORD:
|
|
await self._paso_password(chat_id, texto_msg, update, context)
|
|
|
|
async def handle_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Procesa botones de onboarding (callback_data = 'onb:accion:valor')."""
|
|
query = update.callback_query
|
|
await query.answer() # type: ignore[union-attr]
|
|
chat_id = update.effective_chat.id # type: ignore[union-attr]
|
|
|
|
parts = (query.data or "").split(":") # type: ignore[union-attr]
|
|
if len(parts) < 3:
|
|
return
|
|
accion, valor = parts[1], parts[2]
|
|
|
|
if accion == "consent":
|
|
await self._paso_consentimiento(chat_id, valor, update)
|
|
elif accion == "tono":
|
|
await self._paso_tono(chat_id, valor, update)
|
|
elif accion == "tz":
|
|
await self._paso_tz(chat_id, valor, update)
|
|
elif accion == "reintentar":
|
|
await self._pedir_usuario(chat_id, update)
|
|
elif accion == "cancelar_onb":
|
|
self._clear(chat_id)
|
|
await update.effective_message.reply_text( # type: ignore[union-attr]
|
|
"Onboarding cancelado\\. Escribí /start cuando quieras intentarlo de nuevo\\.",
|
|
parse_mode=ParseMode.MARKDOWN_V2,
|
|
)
|
|
elif accion == "salir":
|
|
await self._procesar_salir(chat_id, valor, update, context)
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Pasos del flujo
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
async def _paso_codigo(self, chat_id: int, codigo: str, update: Update) -> None:
|
|
codigo = codigo.strip()
|
|
# Registrar tiempo de inicio para garantizar respuesta de duración constante.
|
|
# Objetivo: ocultar la diferencia de tiempo entre "código no existe en DB"
|
|
# (~0.001ms) y "código existe pero ya fue usado" (~3-5ms de DB lookup).
|
|
t0 = time.monotonic()
|
|
|
|
# Primero: verificar contra la tabla de códigos de un solo uso (DB)
|
|
valido = await verificar_y_consumir_invite_code(codigo, chat_id)
|
|
|
|
# Fallback: código global del .env (ilimitado, para uso interno/pruebas)
|
|
# secrets.compare_digest previene timing attacks sobre el valor del .env
|
|
if not valido and self._settings.invite_code:
|
|
valido = secrets.compare_digest(
|
|
codigo.encode("utf-8"),
|
|
self._settings.invite_code.encode("utf-8"),
|
|
)
|
|
|
|
# Garantizar un tiempo mínimo de 800ms independientemente del resultado.
|
|
# Esto hace que medir tiempos de respuesta no revele información sobre los códigos.
|
|
elapsed = time.monotonic() - t0
|
|
_MIN_RESP_SECS = 0.8
|
|
if elapsed < _MIN_RESP_SECS:
|
|
await asyncio.sleep(_MIN_RESP_SECS - elapsed)
|
|
|
|
if valido:
|
|
await audit.log("onboarding_codigo_correcto", chat_id=chat_id)
|
|
await self._mostrar_consentimiento(chat_id, update)
|
|
return
|
|
|
|
intentos = self._attempts.get(chat_id, 0) + 1
|
|
self._attempts[chat_id] = intentos
|
|
await audit.log("onboarding_codigo_incorrecto", chat_id=chat_id, resultado="error", detalle={"intento": intentos})
|
|
|
|
if intentos >= _MAX_INTENTOS_CODIGO:
|
|
self._clear(chat_id)
|
|
await update.effective_message.reply_text( # type: ignore[union-attr]
|
|
"Código incorrecto\\. Contactá al administrador para obtener acceso\\.",
|
|
parse_mode=ParseMode.MARKDOWN_V2,
|
|
)
|
|
return
|
|
|
|
await update.effective_message.reply_text( # type: ignore[union-attr]
|
|
"Código incorrecto\\. Intentá de nuevo:",
|
|
parse_mode=ParseMode.MARKDOWN_V2,
|
|
)
|
|
|
|
async def _paso_consentimiento(self, chat_id: int, valor: str, update: Update) -> None:
|
|
if valor == "no":
|
|
self._clear(chat_id)
|
|
await audit.log("onboarding_consentimiento_rechazado", chat_id=chat_id)
|
|
await update.effective_message.reply_text( # type: ignore[union-attr]
|
|
"Entendido\\. Escribí /start cuando quieras empezar\\.",
|
|
parse_mode=ParseMode.MARKDOWN_V2,
|
|
)
|
|
return
|
|
|
|
await audit.log("onboarding_consentimiento_aceptado", chat_id=chat_id)
|
|
self._states[chat_id] = _ESPERANDO_NOMBRE
|
|
await update.effective_message.reply_text( # type: ignore[union-attr]
|
|
"¿Cómo querés que te llame?\n\\(Escribí tu nombre o apodo\\)",
|
|
parse_mode=ParseMode.MARKDOWN_V2,
|
|
)
|
|
|
|
async def _paso_nombre(self, chat_id: int, nombre: str, update: Update) -> None:
|
|
if not nombre:
|
|
await update.effective_message.reply_text( # type: ignore[union-attr]
|
|
"Por favor escribí tu nombre o apodo\\.",
|
|
parse_mode=ParseMode.MARKDOWN_V2,
|
|
)
|
|
return
|
|
|
|
nombre = nombre[:50]
|
|
self._temp[chat_id]["nombre_preferido"] = nombre
|
|
self._states[chat_id] = _ESPERANDO_TONO
|
|
await audit.log("onboarding_nombre_registrado", chat_id=chat_id)
|
|
|
|
nombre_esc = _esc(nombre)
|
|
texto = f"¿Cómo preferís que me comunique con vos, {nombre_esc}?"
|
|
keyboard = InlineKeyboardMarkup([
|
|
[
|
|
InlineKeyboardButton("📜 Formal", callback_data="onb:tono:formal"),
|
|
InlineKeyboardButton("😊 Cotidiano", callback_data="onb:tono:cotidiano"),
|
|
InlineKeyboardButton("🤙 Informal", callback_data="onb:tono:informal"),
|
|
]
|
|
])
|
|
await update.effective_message.reply_text( # type: ignore[union-attr]
|
|
texto, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard
|
|
)
|
|
|
|
async def _paso_tono(self, chat_id: int, tono: str, update: Update) -> None:
|
|
if tono not in ("formal", "cotidiano", "informal"):
|
|
return
|
|
self._temp[chat_id]["tono"] = tono
|
|
self._states[chat_id] = _ESPERANDO_TZ
|
|
await audit.log("onboarding_tono_seleccionado", chat_id=chat_id, detalle={"tono": tono})
|
|
|
|
texto = (
|
|
"¿Desde dónde vas a usar el bot?\n"
|
|
"\\(Para mostrarte las horas correctas\\)"
|
|
)
|
|
keyboard = InlineKeyboardMarkup([
|
|
[InlineKeyboardButton("🇵🇾 Paraguay (Asunción)", callback_data="onb:tz:py")],
|
|
[InlineKeyboardButton("🇦🇷 Argentina (Buenos Aires)", callback_data="onb:tz:ar")],
|
|
[InlineKeyboardButton("🇺🇾 Uruguay (Montevideo)", callback_data="onb:tz:uy")],
|
|
[InlineKeyboardButton("🌐 Otra zona horaria", callback_data="onb:tz:otra")],
|
|
])
|
|
await update.effective_message.reply_text( # type: ignore[union-attr]
|
|
texto, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard
|
|
)
|
|
|
|
async def _paso_tz(self, chat_id: int, valor: str, update: Update) -> None:
|
|
if valor == "otra":
|
|
self._states[chat_id] = _ESPERANDO_TZ_CUSTOM
|
|
await update.effective_message.reply_text( # type: ignore[union-attr]
|
|
"Escribí el nombre de tu zona horaria\\.\n"
|
|
"Ejemplos: `America/Lima`, `America/Bogota`, `Europe/Madrid`",
|
|
parse_mode=ParseMode.MARKDOWN_V2,
|
|
)
|
|
return
|
|
|
|
tz = _TZ_MAP.get(valor, "America/Asuncion")
|
|
self._temp[chat_id]["timezone"] = tz
|
|
await audit.log("onboarding_timezone_seleccionada", chat_id=chat_id, detalle={"tz": tz})
|
|
await self._pedir_usuario(chat_id, update)
|
|
|
|
async def _paso_tz_custom(self, chat_id: int, tz_str: str, update: Update) -> None:
|
|
try:
|
|
ZoneInfo(tz_str)
|
|
except (ZoneInfoNotFoundError, KeyError):
|
|
await update.effective_message.reply_text( # type: ignore[union-attr]
|
|
f"No reconozco la zona \"{_esc(tz_str)}\"\\.\n"
|
|
"Probá con algo como `America/Lima` o `America/Bogota`\\.",
|
|
parse_mode=ParseMode.MARKDOWN_V2,
|
|
)
|
|
return
|
|
|
|
self._temp[chat_id]["timezone"] = tz_str
|
|
await audit.log("onboarding_timezone_seleccionada", chat_id=chat_id, detalle={"tz": tz_str})
|
|
await self._pedir_usuario(chat_id, update)
|
|
|
|
async def _pedir_usuario(self, chat_id: int, update: Update) -> None:
|
|
self._states[chat_id] = _ESPERANDO_USUARIO
|
|
await update.effective_message.reply_text( # type: ignore[union-attr]
|
|
"Ahora necesito tus credenciales del sistema judicial "
|
|
"\\(apps\\.csj\\.gov\\.py\\)\\.\n\n"
|
|
"¿Cuál es tu número de cédula de identidad?\n"
|
|
"\\(Es el usuario que usás para entrar al sistema\\)",
|
|
parse_mode=ParseMode.MARKDOWN_V2,
|
|
)
|
|
|
|
async def _paso_usuario(self, chat_id: int, texto: str, update: Update) -> None:
|
|
cedula = texto.strip().replace(".", "").replace("-", "").replace(" ", "")
|
|
if not cedula.isdigit():
|
|
await update.effective_message.reply_text( # type: ignore[union-attr]
|
|
"La cédula debe contener solo dígitos\\. Intentá de nuevo\\.",
|
|
parse_mode=ParseMode.MARKDOWN_V2,
|
|
)
|
|
return
|
|
|
|
self._temp[chat_id]["usuario"] = cedula
|
|
self._states[chat_id] = _ESPERANDO_PASSWORD
|
|
|
|
await update.effective_message.reply_text( # type: ignore[union-attr]
|
|
"¿Y tu contraseña del sistema judicial?\n\n"
|
|
"⚠️ Voy a borrar este mensaje apenas lo procese\\. "
|
|
"No lo reenvíes a nadie\\.",
|
|
parse_mode=ParseMode.MARKDOWN_V2,
|
|
)
|
|
|
|
async def _paso_password(
|
|
self,
|
|
chat_id: int,
|
|
password: str,
|
|
update: Update,
|
|
context: ContextTypes.DEFAULT_TYPE,
|
|
) -> None:
|
|
# Borrar el mensaje del usuario inmediatamente
|
|
try:
|
|
await context.bot.delete_message(
|
|
chat_id=chat_id,
|
|
message_id=update.effective_message.message_id, # type: ignore[union-attr]
|
|
)
|
|
except Exception:
|
|
pass # puede fallar si el bot no tiene permisos de borrado en el chat
|
|
|
|
usuario = self._temp[chat_id].get("usuario", "")
|
|
if not usuario:
|
|
await context.bot.send_message(
|
|
chat_id=chat_id,
|
|
text="Error interno\\. Escribí /start para reiniciar\\.",
|
|
parse_mode=ParseMode.MARKDOWN_V2,
|
|
)
|
|
self._clear(chat_id)
|
|
return
|
|
|
|
await context.bot.send_message(chat_id=chat_id, text="⏳ Verificando tus credenciales...")
|
|
await audit.log("onboarding_credenciales_verificando", chat_id=chat_id)
|
|
|
|
credentials_enc: str | None = None
|
|
error_tipo: str | None = None
|
|
|
|
try:
|
|
async with CSJClient(self._settings, usuario=usuario, password=password, chat_id=chat_id) as client:
|
|
await client.login()
|
|
# Login exitoso — cifrar antes de borrar password
|
|
credentials_enc = vault_mod.cifrar_credenciales(
|
|
chat_id=chat_id,
|
|
usuario=usuario,
|
|
password=password,
|
|
fernet_key=self._settings.fernet_key,
|
|
)
|
|
except CSJPasswordTemporalError:
|
|
error_tipo = "temporal"
|
|
except CSJAuthError:
|
|
error_tipo = "credenciales"
|
|
except CSJConnectionError:
|
|
error_tipo = "conexion"
|
|
finally:
|
|
# Limpiar password de memoria lo antes posible
|
|
password = "" # noqa: F841
|
|
del password
|
|
|
|
if credentials_enc is not None:
|
|
await audit.log("onboarding_credenciales_ok", chat_id=chat_id)
|
|
await save_tenant(
|
|
chat_id,
|
|
nombre_preferido=self._temp[chat_id].get("nombre_preferido", ""),
|
|
tono=self._temp[chat_id].get("tono", "cotidiano"),
|
|
timezone=self._temp[chat_id].get("timezone", "America/Asuncion"),
|
|
credentials_enc=credentials_enc,
|
|
estado="activo",
|
|
consentimiento_aceptado=1,
|
|
)
|
|
await self._completado(chat_id, context)
|
|
else:
|
|
await audit.log("onboarding_credenciales_error", chat_id=chat_id, resultado="error", detalle={"tipo": error_tipo})
|
|
await self._error_credenciales(chat_id, error_tipo, context)
|
|
|
|
async def _completado(self, chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
nombre = self._temp[chat_id].get("nombre_preferido", "")
|
|
tz_str = self._temp[chat_id].get("timezone", "America/Asuncion")
|
|
ciudad = {
|
|
"America/Asuncion": "Paraguay",
|
|
"America/Argentina/Buenos_Aires": "Argentina",
|
|
"America/Montevideo": "Uruguay",
|
|
}.get(tz_str, tz_str)
|
|
|
|
self._clear(chat_id)
|
|
|
|
texto = (
|
|
f"✅ ¡Todo listo, {_esc(nombre)}\\!\n\n"
|
|
f"Estás conectado al sistema del Poder Judicial\\.\n"
|
|
f"Voy a revisar tus notificaciones cada hora en horario hábil\n"
|
|
f"\\(lunes a viernes, 7:00\\-18:00 hora de {_esc(ciudad)}\\)\\.\n\n"
|
|
f"Escribí /notif para revisar ahora mismo\\."
|
|
)
|
|
await context.bot.send_message(
|
|
chat_id=chat_id,
|
|
text=texto,
|
|
parse_mode=ParseMode.MARKDOWN_V2,
|
|
)
|
|
logger.info("onboarding_completado", chat_id=chat_id)
|
|
await audit.log("onboarding_completado", chat_id=chat_id)
|
|
|
|
async def _error_credenciales(
|
|
self, chat_id: int, error_tipo: str | None, context: ContextTypes.DEFAULT_TYPE
|
|
) -> None:
|
|
self._attempts[chat_id] = self._attempts.get(chat_id, 0) + 1
|
|
|
|
if error_tipo == "temporal":
|
|
self._clear(chat_id)
|
|
await context.bot.send_message(
|
|
chat_id=chat_id,
|
|
text=(
|
|
"⚠️ Tu contraseña del sistema judicial es temporal\\. "
|
|
"Cambiala en apps\\.csj\\.gov\\.py y luego escribí /start de nuevo\\."
|
|
),
|
|
parse_mode=ParseMode.MARKDOWN_V2,
|
|
)
|
|
return
|
|
|
|
if self._attempts[chat_id] >= _MAX_INTENTOS:
|
|
self._clear(chat_id)
|
|
await context.bot.send_message(
|
|
chat_id=chat_id,
|
|
text=(
|
|
"❌ Demasiados intentos fallidos\\. "
|
|
"Escribí /start cuando quieras intentarlo de nuevo\\."
|
|
),
|
|
parse_mode=ParseMode.MARKDOWN_V2,
|
|
)
|
|
return
|
|
|
|
if error_tipo == "conexion":
|
|
detalle = "El sistema del PJ parece estar temporalmente caído\\."
|
|
else:
|
|
detalle = "El usuario o la contraseña no son correctos\\."
|
|
|
|
texto = (
|
|
f"❌ No pude conectarme con esas credenciales\\.\n\n"
|
|
f"{detalle}\n\n"
|
|
"¿Querés intentar de nuevo?"
|
|
)
|
|
keyboard = InlineKeyboardMarkup([
|
|
[
|
|
InlineKeyboardButton("🔄 Intentar de nuevo", callback_data="onb:reintentar:si"),
|
|
InlineKeyboardButton("❌ Cancelar", callback_data="onb:cancelar_onb:si"),
|
|
]
|
|
])
|
|
await context.bot.send_message(
|
|
chat_id=chat_id,
|
|
text=texto,
|
|
parse_mode=ParseMode.MARKDOWN_V2,
|
|
reply_markup=keyboard,
|
|
)
|
|
|
|
async def _procesar_salir(
|
|
self,
|
|
chat_id: int,
|
|
valor: str,
|
|
update: Update,
|
|
context: ContextTypes.DEFAULT_TYPE,
|
|
) -> None:
|
|
if valor == "no":
|
|
self._clear(chat_id)
|
|
await update.effective_message.reply_text("Operación cancelada\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr]
|
|
return
|
|
|
|
await delete_tenant(chat_id)
|
|
|
|
snapshot_path = Path(self._settings.data_dir) / f"snapshot_{chat_id}.json"
|
|
if snapshot_path.exists():
|
|
snapshot_path.unlink()
|
|
|
|
self._clear(chat_id)
|
|
|
|
await update.effective_message.reply_text( # type: ignore[union-attr]
|
|
"✅ Tus datos fueron eliminados\\. "
|
|
"Escribí /start si querés registrarte de nuevo\\.",
|
|
parse_mode=ParseMode.MARKDOWN_V2,
|
|
)
|
|
logger.info("tenant_dado_de_baja", chat_id=chat_id)
|
|
await audit.log("tenant_eliminado", chat_id=chat_id, detalle={"origen": "salir"})
|