Initial commit — Pedrito Hechakuaa v0
Bot de Telegram para monitoreo de notificaciones judiciales del PJ Paraguay. Multi-tenant con onboarding conversacional, audit log, y polling automático. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
482
onboarding.py
Normal file
482
onboarding.py
Normal file
@@ -0,0 +1,482 @@
|
||||
"""
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Estados (strings internos)
|
||||
_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
|
||||
|
||||
_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._states[chat_id] = _INICIO
|
||||
self._temp[chat_id] = {}
|
||||
self._attempts[chat_id] = 0
|
||||
|
||||
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
|
||||
)
|
||||
await audit.log("onboarding_inicio", chat_id=chat_id)
|
||||
|
||||
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_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_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"})
|
||||
Reference in New Issue
Block a user