commit 77439196953d0d1ebc4b23e209ce1e1ae5858ed8 Author: markosbenitez Date: Mon May 25 10:58:32 2026 -0300 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..37c6d1c --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# ─── Cifrado ─────────────────────────────────────────────────────────────────── +# Generar con: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +FERNET_KEY= + +# ─── Credenciales del PJ (cifradas con Fernet) ───────────────────────────────── +# Completar corriendo: uv run python scripts/cifrar_credenciales.py +PJ_USUARIO_ENC= +PJ_PASSWORD_ENC= + +# ─── Telegram ────────────────────────────────────────────────────────────────── +# Token del bot: hablarle a @BotFather en Telegram → /newbot +TELEGRAM_BOT_TOKEN= + +# Tu chat_id personal: hablarle a @userinfobot en Telegram +TELEGRAM_CHAT_ID= + +# (Opcional) Chat IDs adicionales habilitados, separados por coma +# Cada persona tiene que hablarle a @userinfobot para obtener su ID +# Ejemplo: TELEGRAM_EXTRA_IDS=111222333,444555666 +TELEGRAM_EXTRA_IDS= + +# ─── Configuración del polling (opcional — defaults razonables) ───────────────── +# Intervalo de chequeo en minutos (default: 60) +# POLL_INTERVAL_MINUTES=60 + +# Horario de polling, hora de inicio y fin (0-23, zona Asunción UTC-4) +# POLL_HORA_INICIO=7 +# POLL_HORA_FIN=18 + +# ─── Otros (no tocar salvo que sepas lo que hacés) ────────────────────────────── +# LOG_LEVEL=INFO +# DATA_DIR=./data +# PJ_BASE_URL=https://apps.csj.gov.py +# PJ_ROL_ID=16 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a2c5081 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Credenciales y configuración local — NUNCA al repo +.env + +# Entorno virtual +.venv/ + +# Estado del bot y logs +data/ +logs/ + +# Python +__pycache__/ +*.py[cod] +*.pyo +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +dist/ +*.egg-info/ + +# macOS +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c508349 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,295 @@ +# Pedrito Hechakuaa — Instrucciones para Claude Code + +## Qué es esto + +Pedrito Hechakuaa: un bot de Telegram que monitorea notificaciones pendientes +en el sistema judicial de Paraguay (`apps.csj.gov.py`) y avisa cuando aparece +algo nuevo. Uso propio de un solo abogado (single-tenant). + +**Scope estricto de la v0:** +- Login al PJ + polling de notificaciones pendientes +- Diff vs snapshot anterior → solo notifica lo nuevo +- Telegram como canal de salida +- Correr en local o VPS con docker-compose + +**Fuera del scope de v0 (no agregar sin consulta explícita):** +- Motor de reglas de plazos +- LLM / conversación libre +- Multi-tenant +- Web de onboarding +- Actuaciones de expedientes (es v1) +- Acuse de notificaciones (nunca en MVP) + +--- + +## ⚠️ Invariantes de seguridad — NUNCA se violan, aunque sea v0 + +``` +✅ Credenciales cifradas con Fernet en .env — desencriptar en memoria, usar, descartar +✅ .env en .gitignore desde el primer commit +✅ Todo log pasa por scrub() en utils/sanitize.py antes de escribirse +✅ Audit trail mínimo: quién hizo qué, cuándo, resultado — SIN payload sensible +✅ El bot solo responde al TELEGRAM_CHAT_ID configurado (no es público) + +❌ PUT /Notificaciones/{id}/recibir — NUNCA. Acusa notificaciones, efecto procesal irreversible +❌ Password, bearer token, o cualquier credencial en logs (ni en DEBUG) +❌ print() para debug — SIEMPRE usar logger +❌ .env con valores reales en el repo +``` + +**`scrub()`** (`from utils.sanitize import scrub`): función en `utils/sanitize.py` que recibe cualquier +string y reemplaza patrones sensibles (tokens, credenciales) antes de loggear. +Toda llamada a `logger.*()` con datos externos pasa por acá. + +--- + +## Arquitectura + +``` +main.py ← entrypoint, arranca bot + poller en paralelo +config.py ← Settings via pydantic-settings (ÚNICA fuente de config) +csj_client.py ← HTTP client del PJ (login + notificaciones, read-only) +poller.py ← loop de polling + diff + dispara alertas +telegram_bot.py ← envía mensajes y maneja /comandos básicos +storage.py ← snapshot en JSON local (sin DB por ahora) +utils/ + sanitize.py ← credential_scrubber() — NUNCA loggear sin pasar por acá +``` + +--- + +## Stack (sin cambios hasta nuevo aviso) + +| Capa | Tecnología | +|------|-----------| +| Lenguaje | Python 3.12 con type hints estrictos | +| HTTP client | `httpx` async | +| Bot | `python-telegram-bot v21` async | +| Scheduler | `APScheduler 3` | +| Cifrado | `cryptography` (Fernet) | +| Config | `pydantic-settings` | +| Logging | `structlog` (JSON en prod, ConsoleRenderer en dev) | + +Sin Playwright. Sin Postgres. Sin Redis. Sin LLM. Sin Docker por ahora (opcional). + +**No introducir librerías fuera de esta lista sin justificación explícita.** + +--- + +## Sistema judicial target + +**URL base**: `https://apps.csj.gov.py` +**Docs**: `docs/reconocimiento/SISTEMA_NUEVO_RECON.md` y `MANUAL_SANITIZADO.md` + +### Flujo de autenticación (confirmado por reconocimiento) + +``` +1. GET https://apps.csj.gov.py/login + → Cookie: cookiesession1={hex32} + +2. POST https://apps.csj.gov.py/autenticador/login + Headers: Cookie: cookiesession1={hex32} + Body JSON: {"usuario": "{cedula}", "clave": "{password}", "temporal": false} + → bearerToken (TTL 1h), refreshToken, timestampExpiracionUtc + +3. Todos los requests siguientes llevan: + Cookie: cookiesession1={hex32} + Authorization: Bearer {bearerToken} + usuario-rol: 16 ← 16 = Abogados +``` + +### Endpoints de notificaciones (confirmados) + +``` +GET /api/gestion-partes/Notificaciones/CantidadNotificacionesPorRecibir +→ int (cantidad de notificaciones pendientes) + +GET /api/gestion-partes/Notificaciones/PorRecibir?offset=0&cantidad=50 +→ lista de NotificacionPendiente +``` + +### Campos relevantes de NotificacionPendiente + +```json +{ + "codNotificacionOrigen": 1234567, + "nroExpedienteNumero": 1234, + "nroExpedienteAnio": 2024, + "caratula": "Rodríguez c/ Municipalidad Capital", + "descripcionActuacion": "Auto Interlocutorio", + "descripcionTipoActuacion": "Providencia", + "fecha": "2026-05-20", + "juzgado": "1er Juzgado Civil Capital", + "permisoFirmado": "JWT...", + "codCasoJudicial": 103708, + "origen": 0 +} +``` + +--- + +## Estados de conversación + +El bot es simple pero tiene estados implícitos que deben manejarse explícitamente: + +``` +UNREGISTERED → usuario no autorizado (responder a cualquier message) +REGISTERED_IDLE → autorizado, sin operación en curso (estado normal) +CHECKING → revisión de notificaciones en curso (no disparar otra) +ERROR_AUTH → fallo de auth del PJ (bot pausado hasta intervención) +ERROR_PASSWORD_TEMP → password temporal detectada (bot pausado) +``` + +--- + +## Manejo de errores — mensajes accionables + +**Nunca**: "Error inesperado" o mensajes técnicos al usuario. +**Siempre**: qué pasó + qué puede hacer el usuario. + +| Situación | Mensaje al abogado | +|-----------|-------------------| +| Login fallido por credenciales | "No pude conectarme al PJ. Las credenciales podrían haber cambiado. Contactá al administrador." | +| Password temporal | "Tu contraseña del PJ es temporal. Cambiala en apps.csj.gov.py y luego reiniciá Pedrito Hechakuaa." | +| Error de red transitorio | "No pude conectarme al sistema judicial. Voy a reintentar en la próxima ronda ({hora})." | +| 5 errores consecutivos | "El sistema del PJ estuvo inaccesible por un tiempo. Si el problema persiste, revisá apps.csj.gov.py directamente." | + +Emojis con criterio: ✅ éxito, ❌ error, ⚠️ advertencia, ⏳ procesando. No decorativos. + +--- + +## Estructura del snapshot (storage.py) + +JSON local en `data/snapshot.json`: +```json +{ + "last_check": "2026-05-20T10:00:00Z", + "notificaciones_vistas": [1234567, 1234568, 1234569] +} +``` +Solo guardamos los IDs (`codNotificacionOrigen`) ya notificados. +Diff: cualquier ID en la respuesta del PJ que no esté en esta lista = novedad. + +--- + +## Comandos de Telegram + +- `/start` — muestra estado del bot +- `/notif` — revisa notificaciones ahora (no espera al siguiente polling) +- `/estado` — último check, cantidad de notificaciones activas +- `/ayuda` — lista comandos +- `/exp ` — detalle de expediente por número (ej: `/exp 198` o `/exp 198/2026`) +- `/exp ` — busca expedientes por carátula (ej: `/exp rodríguez`) + +--- + +## Formato de mensajes al abogado + +### Notificación proactiva nueva +``` +📋 Nueva notificación judicial + +{caratula} +Exp. {numero}/{anio} — {juzgado} + +Tipo: {descripcionActuacion} +Fecha: {fecha formateada DD/MM/YYYY} + +⚠️ Para acusar esta notificación entrá directamente +en https://apps.csj.gov.py +``` + +### Sin novedades (solo cuando el abogado lo pide con /notif) +``` +✅ Sin novedades desde las {hora} de hoy. +Tenés {N} notificaciones pendientes en total. +``` + +--- + +## Iniciativa agéntica — clasificación de cambios + +| Nivel | Tipo de cambio | Acción | +|-------|---------------|--------| +| 1 | Refactor interno, naming, código muerto | Ejecutar libremente | +| 2 | Cambio visible en mensajes del bot o UX conversacional | Ejecutar y reportar | +| 3 | Seguridad, credenciales, dependencias nuevas, scope del bot | **Consultar antes** | + +**Argumentos NO válidos para evitar consulta de Nivel 3:** +- "Es una mejora de legibilidad" +- "Es trivial de revertir" +- "Resuelve un bug menor" + +--- + +## Definition of Done — cada feature o fix + +- [ ] Happy path funciona en ejecución real (`python main.py`) +- [ ] Credenciales no aparecen en logs (verificar con `LOG_LEVEL=DEBUG`) +- [ ] Maneja desconexión del PJ con retry + mensaje claro al usuario +- [ ] Maneja timeout de sesión: relogin automático transparente +- [ ] Maneja datos vacíos (cero notificaciones, response inesperado) +- [ ] `credential_scrubber()` usado en cualquier log con datos externos +- [ ] Type hints completos en funciones nuevas o modificadas +- [ ] Sin `print()` en código nuevo + +--- + +## Alerta especial: fragilidad del cliente HTTP + +El `csj_client.py` es la parte más probable de romperse. Ante cualquier reporte +de "el cliente funciona", verificar: +- ¿Se probó con credenciales reales o con mocks? +- ¿Los campos del JSON del PJ son los actuales o los del último test? +- ¿El timeout fue suficiente para la red real? + +Ante cambio de schema del PJ: actualizar `csj_client.py` y documentar en +`OBSERVATIONS.md` qué cambió y cuándo. + +--- + +## Archivos del proyecto + +| Archivo | Propósito | +|---------|-----------| +| `CLAUDE.md` | Este archivo — reglas para Claude Code | +| `OBSERVATIONS.md` | Hallazgos durante el desarrollo (crear si no existe) | +| `TECH_DEBT.md` | Deuda técnica anotada (crear si no existe) | +| `data/snapshot.json` | Estado persistido del poller | + +--- + +## Cómo arrancar + +```bash +# Setup (una sola vez) +pip install uv && uv sync +cp .env.example .env +# Completar .env: FERNET_KEY, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID + +# Cifrar credenciales del PJ +python scripts/cifrar_credenciales.py + +# Arrancar +python main.py + +# Tests +pytest +pytest -k "poller" -v +pytest --co -q # solo listar tests sin correr +``` + +--- + +## Cómo migrar de v0 a v1 + +La v0 está diseñada para migración incremental: + +- `csj_client.py` → `app/scraper/` con el mismo contrato de métodos +- `storage.py` → Postgres con el mismo interface +- `config.py` → se expande con más settings +- `telegram_bot.py` → `app/bot/channels/telegram/` +- `poller.py` → `app/poller/` +- `utils/sanitize.py` → se mantiene igual + +No es código desechable. diff --git a/GUIA_TELEGRAM.md b/GUIA_TELEGRAM.md new file mode 100644 index 0000000..591c88b --- /dev/null +++ b/GUIA_TELEGRAM.md @@ -0,0 +1,211 @@ +# Pedrito Hechakuaa — Guía de uso del bot de Telegram + +**Pedrito** es tu asistente judicial en Telegram. Te avisa cuando tenés notificaciones nuevas en el Poder Judicial y te deja consultar el estado de cualquier expediente sin salir de Telegram. + +--- + +## Cómo empezar a usar Pedrito + +### Paso 1 — Tener Telegram instalado + +Pedrito funciona dentro de Telegram. Si no lo tenés: + +- **Celular**: descargalo desde el App Store (iPhone) o Google Play (Android), se llama **Telegram** +- **Computadora**: también tiene app para Windows, Mac y versión web en [web.telegram.org](https://web.telegram.org) + +Registrate con tu número de celular si es la primera vez. + +--- + +### Paso 2 — Encontrar el bot + +El bot se llama **@hechakuaa_bot**. Hay dos formas de abrirlo: + +**Opción A — Buscar dentro de Telegram:** +1. Tocá la lupa de búsqueda (arriba en la app) +2. Escribí `hechakuaa_bot` +3. Tocá el resultado que aparece con el nombre **pedrito_hechakuaa** + +**Opción B — Link directo:** +El administrador puede mandarte un link directo como este: `https://t.me/hechakuaa_bot`. Al abrirlo desde el celular te lleva directamente al chat con el bot. + +--- + +### Paso 3 — Activarlo + +Una vez en el chat con el bot, tocá el botón **Iniciar** que aparece abajo, o escribí: + +``` +/start +``` + +Pedrito responde confirmando que está activo y monitoreando tu cuenta del PJ. + +> Si el bot no responde o dice que no estás autorizado, avisale al administrador — tu número de Telegram tiene que estar habilitado primero. + +--- + +## Qué hace Pedrito automáticamente + +Sin que vos hagas nada, Pedrito revisa el PJ **cada hora** de lunes a viernes entre las **7:00 y las 18:00**. Si aparece una notificación nueva, te manda un mensaje al instante. + +Fuera de ese horario no te molesta. Si necesitás revisar en cualquier momento (finde, feriado, a las 11 de la noche), usá `/notif`. + +--- + +## Comandos + +### `/start` +Muestra el estado del bot y la lista de comandos disponibles. + +--- + +### `/notif` +Revisa tus notificaciones **ahora mismo**, sin esperar a la próxima revisión automática. + +Útil cuando: +- Estás esperando una notificación urgente +- Querés confirmar que no hay novedades antes de una audiencia +- Es finde o feriado y el bot no está revisando en automático + +**Si hay novedades**, te las manda una por una. +**Si no hay novedades**, te responde con una confirmación: + +``` +✅ Sin novedades desde las 10:30 de hoy. +``` + +--- + +### `/estado` +Muestra cuándo fue la última vez que Pedrito revisó el PJ y cuántas notificaciones tiene registradas. + +``` +Estado de Pedrito Hechakuaa + +Último chequeo: 21/05/2026 10:30 +Notificaciones registradas: 47 + +Para revisar ahora: /notif +``` + +--- + +### `/exp` +Consulta el detalle de un expediente por número o por texto de carátula. + +**Por número y año** (la forma más precisa): +``` +/exp 198/2026 +``` + +**Solo por número** (si no recordás el año): +``` +/exp 198 +``` + +**Por texto de carátula** (si no recordás el número): +``` +/exp rodríguez municipalidad +``` + +--- + +### `/ayuda` +Muestra la lista de comandos disponibles. + +--- + +## Cómo se ven las notificaciones + +Cuando aparece una notificación nueva, Pedrito te manda esto: + +--- + +📋 **Nueva notificación judicial** + +Rodríguez c/ Municipalidad Capital +Exp. 1234/2024 — 1er Juzgado Civil Capital + +Tipo: Auto Interlocutorio +Fecha: 21/05/2026 + +⚠️ Para acusar esta notificación entrá directamente en el sistema del PJ. + +**[ Ir al sistema del PJ ]** + +--- + +El botón **"Ir al sistema del PJ"** te lleva directo a la pantalla de notificaciones en `apps.csj.gov.py`. + +--- + +## Cómo se ve la consulta de expediente + +**Resultado único** — cuando buscás por número y año exacto: + +--- + +📂 **Exp. 198/2026** +Rodríguez c/ Municipalidad Capital +1era Circunscripción | Activo + +**Últimas actuaciones:** +• 20/05/2026 — Auto Interlocutorio: Resolución N° 123 +• 15/05/2026 — Providencia: Autos y vistos +• 10/05/2026 — Notificación: Cédula de notificación + +*(15 actuaciones en total)* + +--- + +**Múltiples resultados** — cuando buscás por carátula o número sin año: + +--- + +Se encontraron **3** expedientes para "rodríguez": + +**1. Exp. 198/2026** — Rodríguez c/ Municipalidad Capital + 1era Circunscripción | Activo + +**2. Exp. 456/2025** — Rodríguez Díaz c/ Empresa SA + 1era Circunscripción | Archivado + +**3. Exp. 89/2024** — Rodríguez c/ Estado Paraguayo + 2da Circunscripción | Activo + +Para ver detalle: /exp {numero}/{anio} + +--- + +**Sin resultados:** + +--- + +No encontré expedientes para "999999/1900". +Probá con la carátula o con número/año. + +--- + +## ⚠️ Importante: Pedrito no acusa notificaciones + +Pedrito solo **lee** el sistema del PJ, nunca escribe. Para acusar una notificación tenés que entrar vos directamente a [apps.csj.gov.py](https://apps.csj.gov.py) y hacerlo desde ahí. Acusar tiene efecto procesal — Pedrito nunca va a hacer eso por vos. + +--- + +## Preguntas frecuentes + +**¿Por qué Pedrito no me respondió?** +Solo responde a la cuenta de Telegram habilitada por el administrador. Si creaste una cuenta nueva o cambiaste de número, avisá para que te habiliten. + +**¿Por qué no recibí una notificación que ya aparece en el PJ?** +Puede haber aparecido fuera del horario de monitoreo automático (antes de las 7 o después de las 18, o en fin de semana). Mandá `/notif` para revisar en el momento. + +**¿Pedrito puede perderse una notificación?** +Si el sistema del PJ estuvo caído o sin conexión durante varias horas, es posible que Pedrito no haya podido revisar. En ese caso te avisa con un mensaje de advertencia. Para estar seguro, mandá `/notif` cuando volvés de un día sin conexión. + +**¿Qué pasa si cambio la contraseña del PJ?** +Pedrito va a dejar de funcionar y te va a avisar por Telegram. Contactá al administrador para que actualice las credenciales — es un proceso de 2 minutos. + +**¿Quién más puede ver mis notificaciones?** +Nadie. Pedrito solo responde a tu chat de Telegram. Si alguien más encuentra el bot, no puede interactuar con él. diff --git a/MANUAL_USUARIO.md b/MANUAL_USUARIO.md new file mode 100644 index 0000000..24aa9f5 --- /dev/null +++ b/MANUAL_USUARIO.md @@ -0,0 +1,323 @@ +# Pedrito Hechakuaa — Manual de usuario + +**Pedrito Hechakuaa** es un bot de Telegram que revisa tu cuenta del sistema judicial del Poder Judicial de Paraguay (`apps.csj.gov.py`) y te avisa cuando aparecen notificaciones nuevas. También podés consultar el estado de cualquier expediente directamente desde Telegram. + +--- + +## ¿Qué hace exactamente? + +- Revisa tus notificaciones pendientes en el PJ cada hora, de lunes a viernes entre las 7:00 y las 18:00 (hora de Asunción). +- Te manda un mensaje por Telegram cuando aparece algo nuevo. +- Te deja consultar el detalle de cualquier expediente por número o por carátula. +- **No acusa notificaciones.** Pedrito Hechakuaa es de solo lectura — nunca toca nada que tenga efecto procesal. Para acusar, entrás vos directamente a `apps.csj.gov.py`. + +--- + +## Instalación y configuración + +### Lo que necesitás antes de empezar + +- Python 3.12 o superior instalado +- Una cuenta en Telegram +- Un bot de Telegram creado (lo creás vos, gratis, en 2 minutos) +- Tu chat ID de Telegram +- Las credenciales del sistema del PJ (cédula y contraseña) + +--- + +### Paso 1 — Verificar Python + +Abrí la terminal y ejecutá: + +```bash +python3 --version +``` + +Tiene que decir `3.12.x` o superior. Si dice `3.11` o menos, instalá Python 3.12 desde [python.org](https://www.python.org) o con `brew install python@3.12` en Mac. + +> Si no tenés Python 3.12 pero sí tenés `uv` instalado, no hay problema — `uv` descarga la versión correcta automáticamente. + +--- + +### Paso 2 — Instalar uv + +`uv` es el gestor de dependencias que usa el proyecto. Si no lo tenés: + +```bash +pip install uv +``` + +Verificar que quedó instalado: + +```bash +uv --version +``` + +--- + +### Paso 3 — Instalar dependencias + +Dentro de la carpeta del proyecto: + +```bash +uv sync +``` + +Esto crea un entorno virtual e instala todo. Tarda entre 30 y 60 segundos la primera vez. + +--- + +### Paso 4 — Configurar el archivo .env + +```bash +cp .env.example .env +``` + +Abrí el `.env` con cualquier editor de texto y completá los cuatro valores obligatorios: + +**4a. Generar la FERNET_KEY** + +Esta clave cifra tus credenciales del PJ. Ejecutá esto en la terminal y copiá el resultado: + +```bash +uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +``` + +Pegalo en el `.env` en la línea `FERNET_KEY=`. + +> Guardala también en un lugar seguro (gestor de contraseñas, por ejemplo). Si perdés esta clave, tendrás que volver a cifrar las credenciales del PJ. + +**4b. Crear el bot de Telegram y obtener el token** + +1. Abrí Telegram y buscá **@BotFather** +2. Mandále `/newbot` +3. Seguí las instrucciones: te va a pedir un nombre y un username para el bot +4. Al final te da un token con este formato: `123456789:AAFxxx...` +5. Pegalo en el `.env` en la línea `TELEGRAM_BOT_TOKEN=` + +**4c. Obtener tu chat ID** + +1. En Telegram buscá **@userinfobot** +2. Mandále cualquier mensaje +3. Te responde con tu ID numérico (algo como `98765432`) +4. Pegalo en el `.env` en la línea `TELEGRAM_CHAT_ID=` + +El `.env` debería quedar así (los demás valores podés dejarlos comentados): + +``` +FERNET_KEY=tu-clave-generada +TELEGRAM_BOT_TOKEN=123456789:AAFxxx... +TELEGRAM_CHAT_ID=98765432 +PJ_USUARIO_ENC= +PJ_PASSWORD_ENC= +``` + +--- + +### Paso 5 — Cifrar las credenciales del PJ + +```bash +uv run python scripts/cifrar_credenciales.py +``` + +El script te pide: + +- Tu **cédula de identidad** (la que usás para entrar a `apps.csj.gov.py`) +- Tu **contraseña del PJ** (no se muestra mientras escribís) + +Las cifra con tu FERNET_KEY y actualiza automáticamente `PJ_USUARIO_ENC` y `PJ_PASSWORD_ENC` en tu `.env`. La contraseña en texto plano nunca se guarda en ningún lado. + +--- + +### Paso 6 — Arrancar el bot + +```bash +uv run python main.py +``` + +Lo que debería pasar: + +1. El bot se conecta al PJ y revisa tus notificaciones actuales +2. Abrís Telegram, buscás tu bot por su username, y le mandás `/start` +3. El bot responde con el estado inicial +4. A partir de ahí, te avisa automáticamente cuando aparezca algo nuevo + +--- + +## Comandos disponibles + +| Comando | Qué hace | +|---------|---------| +| `/start` | Muestra el estado del bot y los comandos disponibles | +| `/notif` | Revisa las notificaciones ahora mismo, sin esperar la próxima hora | +| `/estado` | Muestra el último chequeo y cuántas notificaciones tiene registradas | +| `/ayuda` | Lista todos los comandos | +| `/exp 198` | Busca el expediente número 198 (sin filtrar por año) | +| `/exp 198/2026` | Busca el expediente 198 del año 2026 | +| `/exp carlos` | Busca expedientes que tengan "carlos" en la carátula | + +--- + +## Cómo se ven las notificaciones + +Cuando aparece una notificación nueva, el bot te manda esto: + +``` +📋 Nueva notificación judicial + +Rodríguez c/ Municipalidad Capital +Exp. 1234/2024 — 1er Juzgado Civil Capital + +Tipo: Auto Interlocutorio +Fecha: 20/05/2026 + +⚠️ Para acusar esta notificación entrá directamente +en https://apps.csj.gov.py +``` + +El botón **"Ir al sistema del PJ"** te lleva directo a la pantalla de notificaciones. + +--- + +## Cómo se ve la consulta de expediente + +**Con un único resultado** (`/exp 198/2026`): + +``` +📂 Exp. 198/2026 +Rodríguez c/ Municipalidad Capital +1era Circunscripción | Activo + +Últimas actuaciones: +• 20/05/2026 — Auto Interlocutorio: Resolución N° 123 +• 15/05/2026 — Providencia: Autos y vistos +• 10/05/2026 — Notificación: Cédula de notificación + +(15 actuaciones en total) +``` + +**Con múltiples resultados** (`/exp rodriguez`): + +``` +Se encontraron 3 expedientes para "rodriguez": + +1. Exp. 198/2026 — Rodríguez c/ Municipalidad Capital + 1era Circunscripción | Activo + +2. Exp. 456/2025 — Rodríguez Díaz c/ Empresa SA + 1era Circunscripción | Archivado + +3. ... + +Para ver detalle: /exp {numero}/{anio} +``` + +**Sin resultados** (`/exp 999999/1900`): + +``` +No encontré expedientes para "999999/1900". +Probá con la carátula o con número/año. +``` + +--- + +## Horario de monitoreo automático + +El bot revisa el PJ **cada hora**, pero solo en horario hábil: + +- **Días**: lunes a viernes +- **Horario**: 7:00 a 18:00 (hora de Asunción, UTC-4) + +Fuera de ese horario, el bot no molesta. Si querés revisar en cualquier momento (finde, feriado, madrugada), usá `/notif`. + +--- + +## Correr como servicio en segundo plano + +Si no querés tener la terminal abierta todo el tiempo: + +**Con screen (la opción más simple):** + +```bash +screen -S pedrito +uv run python main.py +# Presioná Ctrl+A y luego D para dejar el bot corriendo en segundo plano +# Para volver: screen -r pedrito +``` + +**Con systemd (en un servidor Linux):** + +```ini +# /etc/systemd/system/pedrito.service +[Unit] +Description=Pedrito Hechakuaa — monitor judicial +After=network.target + +[Service] +Type=simple +WorkingDirectory=/ruta/al/proyecto +ExecStart=/usr/bin/python3 main.py +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl enable pedrito +sudo systemctl start pedrito +``` + +--- + +## Solución de problemas comunes + +### El bot no arranca + +| Error | Causa | Solución | +|-------|-------|---------| +| `ModuleNotFoundError` | Faltan dependencias | Correr `uv sync` de nuevo en la carpeta del proyecto | +| `ValidationError` o campo vacío | `.env` incompleto | Verificar que `FERNET_KEY`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, `PJ_USUARIO_ENC` y `PJ_PASSWORD_ENC` estén completos | +| `FERNET_KEY inválida` | La clave tiene espacios o fue copiada mal | Regenerar con `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` | +| `PJ_USUARIO_ENC no se pudo descifrar` | Cambiaste la `FERNET_KEY` después de cifrar | Volver a correr `scripts/cifrar_credenciales.py` con la key actual | + +### El bot arranca pero no responde en Telegram + +- Verificar que `TELEGRAM_CHAT_ID` sea **tu** ID personal (el de la cuenta que va a usar el bot), no el ID del bot. +- Asegurate de haberle mandado `/start` primero desde tu cuenta de Telegram. + +### El bot arranca pero falla al conectarse al PJ + +- **Login fallido (401):** las credenciales están incorrectas. Volver a correr `scripts/cifrar_credenciales.py`. +- **Password temporal:** la contraseña del PJ es temporal. Entrá a `apps.csj.gov.py`, cambiala, y reiniciá el bot. +- **Error de red:** el sistema del PJ tiene intermitencias. El bot va a reintentar automáticamente en la próxima ronda. + +### Los campos de una notificación aparecen vacíos o con valores raros + +El formato exacto de los responses del PJ puede diferir de lo documentado (el sistema tiene actualizaciones frecuentes). En ese caso, copiá el mensaje de error del log y consultá con el equipo técnico — se resuelve en minutos. + +--- + +## Seguridad — lo que hace el sistema para proteger tus datos + +- Las credenciales del PJ se cifran con AES-128 (Fernet) antes de guardarse. Nunca quedan en texto plano. +- El `.env` con las credenciales cifradas está excluido del repositorio de código (`.gitignore`). +- El bot solo responde a tu `TELEGRAM_CHAT_ID`. Si alguien más encuentra el bot por su nombre, no puede usarlo. +- Pedrito Hechakuaa nunca acusa notificaciones ni ejecuta ninguna acción con efecto procesal. Es un lector. + +--- + +## Actualizaciones de credenciales del PJ + +Si cambiás la contraseña del PJ, simplemente volvés a correr: + +```bash +uv run python scripts/cifrar_credenciales.py +``` + +Y reiniciás el bot. No hace falta tocar nada más. + +--- + +*Pedrito Hechakuaa — uso personal, single-tenant.* diff --git a/OBSERVATIONS.md b/OBSERVATIONS.md new file mode 100644 index 0000000..573b3eb --- /dev/null +++ b/OBSERVATIONS.md @@ -0,0 +1,34 @@ +# OBSERVATIONS.md — Hallazgos del desarrollo + +Hallazgos técnicos y decisiones ad-hoc tomadas durante el desarrollo. +Actualizar cada vez que se descubra algo relevante sobre el sistema del PJ +o sobre el comportamiento del bot. + +--- + +## Sistema judicial (apps.csj.gov.py) + +### Estructura del response de notificaciones — PRIMER RUN REAL 2026-05-22 +**Fecha**: 2026-05-22 + +`CantidadNotificacionesPorRecibir` devolvió `2` (entero directo ✅). + +`Notificaciones/PorRecibir` devolvió lista vacía (`total: 0, nuevas: 0`) +a pesar de que el contador dijo 2. Comportamiento posible: +- El endpoint de cantidad cuenta algo diferente (notificaciones ya acusadas parcialmente, otro estado) +- El formato del response de la lista no coincide con ninguna de las claves esperadas (`items`, `data`, `notificaciones`) → el array llega bajo otra clave que `csj_client.py` no reconoce y cae al fallback de lista vacía + +**Acción pendiente**: inspeccionar el response crudo de `PorRecibir` con LOG_LEVEL=DEBUG +para ver la estructura exacta del JSON y ajustar el parsing en `csj_client.py`. + +--- + +## Bot de Telegram + +*(agregar hallazgos acá)* + +--- + +## Deuda técnica activa + +Ver `TECH_DEBT.md`. diff --git a/PROMPT_CLAUDE_CODE_EXP.md b/PROMPT_CLAUDE_CODE_EXP.md new file mode 100644 index 0000000..c0a3ced --- /dev/null +++ b/PROMPT_CLAUDE_CODE_EXP.md @@ -0,0 +1,127 @@ +# Prompt para Claude Code — Agregar comando /exp al bot + +## Tarea + +Agregar la feature "detalle de expediente por número" al bot de Telegram. + +## Contexto + +El reconocimiento del 22/05/2026 confirmó todos los endpoints necesarios. +Ver `csj_client_nuevos_metodos.py` en la raíz del proyecto — tiene el código +listo para integrar con documentación inline. + +## Cambios requeridos + +### 1. En `csj_client.py` + +a) Agregar después de la clase `NotificacionPendiente`: + - Clase `CasoJudicial` (ver `csj_client_nuevos_metodos.py`) + - Clase `ActuacionCaso` (ver `csj_client_nuevos_metodos.py`) + +b) Agregar dentro de la clase `CSJClient`: + - Método `buscar_expedientes()` (ver `csj_client_nuevos_metodos.py`) + - Método `listar_actuaciones()` (ver `csj_client_nuevos_metodos.py`) + +c) El import de `httpx` ya existe. No agregar dependencias nuevas. + +### 2. En `telegram_bot.py` + +Agregar el comando `/exp` con este comportamiento: + +``` +/exp 198 → busca número 198 sin filtrar por año +/exp 198/2026 → busca número 198, año 2026 +/exp carlos → busca por carátula (texto, no número) +``` + +**Mensaje de respuesta cuando encuentra un expediente:** + +``` +📂 Exp. {numero}/{anio} +{caratula} +{circunscripcion} | {estado} + +Últimas actuaciones: +• {fecha} — {tipo}: {descripcion_actuacion} +• {fecha} — {tipo}: {descripcion_actuacion} +• {fecha} — {tipo}: {descripcion_actuacion} + +({total} actuaciones en total) +``` + +Mostrar máximo 3 actuaciones más recientes (las primeras del response, +que ya vienen ordenadas por fecha descendente). + +**Cuando encuentra múltiples expedientes** (búsqueda por carátula): + +``` +Se encontraron {N} expedientes para "{búsqueda}": + +1. Exp. {numero}/{anio} — {caratula_truncada_50_chars} + {circunscripcion} | {estado} + +2. ... + +Para ver detalle: /exp {numero}/{anio} +``` + +Mostrar máximo 5 resultados. Si hay más, indicar cuántos hay en total. + +**Cuando no encuentra nada:** + +``` +No encontré expedientes para "{búsqueda}". +Probá con la carátula o con número/año. +``` + +### 3. En `CLAUDE.md` + +Agregar `/exp` a la lista de comandos de Telegram. + +## Parsing del argumento del comando + +```python +def parsear_argumento_exp(arg: str) -> dict: + """ + Parsea el argumento del comando /exp. + + Retorna dict con claves posibles: + - numero (int): número de expediente + - anio (int): año del expediente + - caratula (str): texto para buscar por carátula + + Ejemplos: + "198" → {"numero": 198} + "198/2026" → {"numero": 198, "anio": 2026} + "carlos" → {"caratula": "carlos"} + "198/26" → {"numero": 198, "anio": 2026} # año de 2 dígitos → 2000+anio + """ +``` + +## Notas importantes + +- Usar `scrub()` de `utils/sanitize.py` en cualquier log que incluya + contenido de respuestas del PJ +- El comando solo responde al `TELEGRAM_CHAT_ID` autorizado (igual que el resto) +- Si `listar_actuaciones()` falla pero `buscar_expedientes()` tuvo éxito, + mostrar igual los datos del caso sin actuaciones (degradación elegante) +- `incluirNotificaciones=false` siempre (no mezclar con notificaciones) +- Truncar carátulas largas a 60 caracteres con "..." en listados +- Fechas en formato DD/MM/YYYY + +## Qué NO hacer + +- No agregar paginación interactiva (botones "siguiente página") en esta versión +- No agregar descarga de documentos en esta versión +- No cambiar nada del flujo de notificaciones existente +- No agregar dependencias nuevas + +## Verificación + +Después de implementar, verificar: +- [ ] `/exp 198` responde con el caso correcto +- [ ] `/exp 198/2026` responde con el caso correcto +- [ ] `/exp carlos` muestra lista de casos con "carlos" en carátula +- [ ] `/exp 999999/1900` responde "No encontré expedientes" +- [ ] Sin credenciales en logs (verificar con LOG_LEVEL=DEBUG) +- [ ] Agrega `/exp` a la lista de comandos en CLAUDE.md \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..deca2c8 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# Pedrito Hechakuaa + +Monitor de notificaciones del Poder Judicial de Paraguay vía Telegram. + +Te avisa cuando aparecen notificaciones nuevas en tu cuenta del sistema del PJ. + +## Setup en 5 pasos + +### 1. Prerequisitos + +- Python 3.12+ +- Una cuenta en Telegram +- Un bot de Telegram creado con [@BotFather](https://t.me/BotFather) +- Tu chat_id de Telegram (obtenerlo con [@userinfobot](https://t.me/userinfobot)) +- Credenciales del sistema del PJ (`apps.csj.gov.py`) + +### 2. Instalar + +```bash +# Entrar a la carpeta del proyecto +cd pedrito-hechakuaa + +# Instalar dependencias (recomendado: uv) +pip install uv +uv sync +``` + +### 3. Configurar + +```bash +# Copiar el template +cp .env.example .env + +# Generar una clave de cifrado (guardarla en un lugar seguro también) +uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +# Copiar el resultado en FERNET_KEY= dentro de .env + +# Editar .env y completar: +# - FERNET_KEY (la que generaste arriba) +# - TELEGRAM_BOT_TOKEN (de @BotFather) +# - TELEGRAM_CHAT_ID (de @userinfobot) +``` + +### 4. Cifrar las credenciales del PJ + +```bash +uv run python scripts/cifrar_credenciales.py +``` + +Te va a pedir la cédula y contraseña del PJ, las cifra con Fernet, y actualiza +`PJ_USUARIO_ENC` y `PJ_PASSWORD_ENC` en tu `.env`. Nunca escribe las +credenciales en plano. + +### 5. Arrancar + +```bash +uv run python main.py +``` + +El bot va a: +1. Conectarse al PJ y revisar tus notificaciones actuales +2. Mandarte un mensaje en Telegram con el estado inicial +3. Revisar cada hora en horario hábil (lunes-viernes, 7-18h) +4. Avisarte en Telegram cuando aparezca algo nuevo + +## Comandos de Telegram + +| Comando | Función | +|---------|---------| +| `/start` | Ver estado del bot | +| `/notif` | Revisar notificaciones ahora (no esperar la próxima hora) | +| `/estado` | Ver último chequeo y estadísticas | +| `/ayuda` | Lista de comandos | + +## Correr como servicio (opcional) + +Si querés que corra en background en tu Mac o en un servidor: + +**Con screen (más simple):** +```bash +screen -S pedrito +python main.py +# Ctrl+A, D para detach +# screen -r pedrito para volver +``` + +**Con systemd en Linux:** +```ini +# /etc/systemd/system/pedrito.service +[Unit] +Description=Pedrito Hechakuaa - monitor judicial +After=network.target + +[Service] +Type=simple +WorkingDirectory=/ruta/al/pedrito-hechakuaa +ExecStart=/usr/bin/python3 main.py +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl enable pedrito +sudo systemctl start pedrito +``` + +## Seguridad + +- Las credenciales del PJ se cifran con Fernet (AES-128-CBC con HMAC) +- `.env` nunca se commitea (está en `.gitignore`) +- El bot solo responde al `TELEGRAM_CHAT_ID` que configuraste +- El bot es read-only: nunca acusa notificaciones ni tiene efectos procesales + +## Migración a v1 + +Cuando estés listo para la arquitectura completa (multi-tenant, LLM conversacional, +web de onboarding), ver el directorio `../pedrito-hechakuaa/` con el diseño completo. + +La v0 está pensada para migrar incrementalmente: cada módulo tiene el mismo +contrato que su equivalente en v1. diff --git a/TECH_DEBT.md b/TECH_DEBT.md new file mode 100644 index 0000000..ea3bf20 --- /dev/null +++ b/TECH_DEBT.md @@ -0,0 +1,47 @@ +# TECH_DEBT.md — Deuda técnica + +Deuda técnica conocida y aceptada en v0. Cada ítem se migra en v1 salvo indicación contraria. + +--- + +## Alta prioridad (migrar antes de agregar más usuarios) + +### TD-001: Fernet single-key en lugar de envelope encryption +**Descripción**: Las credenciales usan Fernet con una sola clave maestra en `.env`. +En v1 se reemplaza por envelope encryption (DEK por tenant + MK separada). +**Impacto**: Si la FERNET_KEY se compromete, todas las credenciales son vulnerables. +**Mitigation actual**: El `.env` nunca va al repo; la key no está en el código. +**Destino**: `app/vault/` en v1 (ver `docs/ARCHITECTURE.md §6`). + +### TD-002: Snapshot en JSON local sin concurrencia +**Descripción**: `storage.py` lee/escribe un JSON sin locks. Con un solo proceso no hay problema. +**Impacto**: Cero en v0 (single process). Race condition posible en v1 (multi-worker). +**Destino**: Reemplazar por Postgres con transacciones en v1. + +### TD-003: Sin refresh token implementado +**Descripción**: Cuando el bearer token expira (TTL 1h), el cliente hace relogin completo. +El endpoint de refresh token no fue capturado en el reconocimiento. +**Impacto**: Relogin extra cada hora (costo ~0 ya que no hay captcha). +**Destino**: Capturar el endpoint en sesión 2 de reconocimiento e implementar. + +--- + +## Baja prioridad (mejoras futuras) + +### TD-004: aiogram en lugar de python-telegram-bot +**Descripción**: aiogram tiene FSM nativo más robusto y mejor async que python-telegram-bot. +**Impacto**: Cero en v0 (no usamos FSM complejo). Relevante cuando el bot tenga flujos multi-paso. +**Decisión**: Evaluar al inicio de v1. No migrar en v0. + +### TD-005: structlog sin filtro global de credenciales +**Descripción**: `scrub()` existe pero se llama manualmente en cada log. +Sería más seguro registrarlo como processor global de structlog. +**Destino**: En v1, registrar como processor en la configuración de structlog. + +### TD-006: Sin tests de integración reales +**Descripción**: Los tests unitarios usan mocks. No hay tests que peguen al PJ real. +**Decisión aceptada**: Tests contra el PJ real son `@pytest.mark.manual` por diseño. + +--- + +*Última actualización: 2026-05-20* diff --git a/audit.py b/audit.py new file mode 100644 index 0000000..30eda63 --- /dev/null +++ b/audit.py @@ -0,0 +1,57 @@ +""" +audit.py — Audit log de toda la actividad del bot. + +Tabla audit_log en la misma DB SQLite que tenants. +La función log() es fire-and-tolerant: nunca levanta excepciones +para no romper el flujo principal de la aplicación. +""" +from __future__ import annotations + +import json +from datetime import datetime, timezone +from zoneinfo import ZoneInfo + +import structlog + +logger = structlog.get_logger(__name__) + + +async def log( + evento: str, + chat_id: int | None = None, + detalle: dict | None = None, + resultado: str = "ok", + duracion_ms: int | None = None, + tenant: dict | None = None, +) -> None: + """ + Registra un evento en audit_log. + Si falla (DB no disponible, error de escritura), solo loggea a structlog. + Nunca levanta excepciones. + """ + from database import get_conn # import tardío para evitar circular imports + + now_utc = datetime.now(timezone.utc) + timestamp = now_utc.isoformat() + + timestamp_local: str | None = None + if tenant: + tz_str = tenant.get("timezone") or "America/Asuncion" + try: + timestamp_local = now_utc.astimezone(ZoneInfo(tz_str)).isoformat() + except Exception: + timestamp_local = None + + detalle_json = json.dumps(detalle, ensure_ascii=False) if detalle else None + + try: + conn = get_conn() + await conn.execute( + """INSERT INTO audit_log + (timestamp, timestamp_local, chat_id, evento, detalle, resultado, duracion_ms) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (timestamp, timestamp_local, chat_id, evento, detalle_json, resultado, duracion_ms), + ) + await conn.commit() + except Exception as exc: + logger.error("audit_log_write_error", evento=evento, chat_id=chat_id, error=str(exc)) diff --git a/config.py b/config.py new file mode 100644 index 0000000..1aaab99 --- /dev/null +++ b/config.py @@ -0,0 +1,113 @@ +""" +config.py — Carga y valida variables de entorno. + +Todas las partes de la app importan Settings desde acá. +Las credenciales del PJ se descifran aquí y se exponen como propiedades. +""" +from __future__ import annotations + +from functools import cached_property +from pathlib import Path + +from cryptography.fernet import Fernet, InvalidToken +from pydantic import field_validator, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + # Cifrado + fernet_key: str + + # Credenciales PJ cifradas (valores Fernet base64) + pj_usuario_enc: str + pj_password_enc: str + + # Telegram + telegram_bot_token: str + telegram_chat_id: int # chat ID principal (recibe notificaciones automáticas) + telegram_extra_ids: str = "" # IDs adicionales separados por coma, ej: "111,222" + + @property + def telegram_authorized_ids(self) -> set[int]: + """Todos los chat IDs habilitados para usar el bot.""" + ids = {self.telegram_chat_id} + for raw in self.telegram_extra_ids.split(","): + raw = raw.strip() + if raw: + ids.add(int(raw)) + return ids + + # Configuración del polling + poll_interval_minutes: int = 60 + poll_hora_inicio: int = 7 + poll_hora_fin: int = 18 + pj_rol_id: int = 16 + + # Directorios + data_dir: Path = Path("./data") + db_path: Path = Path("./data/pasante.db") + + # Logging + log_level: str = "INFO" + + # URLs del PJ (no configurables, solo para tests) + pj_base_url: str = "https://apps.csj.gov.py" + + @field_validator("poll_hora_inicio", "poll_hora_fin") + @classmethod + def validar_hora(cls, v: int) -> int: + if not (0 <= v <= 23): + raise ValueError(f"Hora debe ser entre 0 y 23, recibido: {v}") + return v + + @model_validator(mode="after") + def validar_horario(self) -> Settings: + if self.poll_hora_inicio >= self.poll_hora_fin: + raise ValueError( + f"poll_hora_inicio ({self.poll_hora_inicio}) " + f"debe ser menor que poll_hora_fin ({self.poll_hora_fin})" + ) + return self + + @cached_property + def _fernet(self) -> Fernet: + try: + return Fernet(self.fernet_key.encode()) + except Exception as e: + raise ValueError(f"FERNET_KEY inválida: {e}") from e + + def descifrar_usuario(self) -> str: + """Descifra y retorna la cédula del abogado. Usar solo al momento de login.""" + try: + return self._fernet.decrypt(self.pj_usuario_enc.encode()).decode() + except InvalidToken as e: + raise ValueError("PJ_USUARIO_ENC no se pudo descifrar. ¿Usaste la misma FERNET_KEY?") from e + + def descifrar_password(self) -> str: + """Descifra y retorna el password del PJ. Usar solo al momento de login.""" + try: + return self._fernet.decrypt(self.pj_password_enc.encode()).decode() + except InvalidToken as e: + raise ValueError("PJ_PASSWORD_ENC no se pudo descifrar. ¿Usaste la misma FERNET_KEY?") from e + + def asegurar_data_dir(self) -> None: + self.data_dir.mkdir(parents=True, exist_ok=True) + + +# Singleton — importar desde cualquier lado +_settings: Settings | None = None + + +def get_settings() -> Settings: + global _settings + if _settings is None: + _settings = Settings() + _settings.asegurar_data_dir() + return _settings diff --git a/csj_client.py b/csj_client.py new file mode 100644 index 0000000..0cc83d9 --- /dev/null +++ b/csj_client.py @@ -0,0 +1,691 @@ +""" +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", + "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"] + 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") + self.fecha: str | None = data.get("fecha") + self.juzgado: str | None = data.get("juzgado") + self.circunscripcion: str | None = 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(), + ) + + 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 diff --git a/csj_client_nuevos_metodos.py b/csj_client_nuevos_metodos.py new file mode 100644 index 0000000..99bd373 --- /dev/null +++ b/csj_client_nuevos_metodos.py @@ -0,0 +1,257 @@ +""" +NUEVOS MÉTODOS para agregar a csj_client.py +Basados en reconocimiento del 22/05/2026. + +Agregar: +1. La clase CasoJudicial después de NotificacionPendiente +2. La clase ActuacionCaso después de CasoJudicial +3. Los métodos buscar_expedientes() y listar_actuaciones() dentro de CSJClient +""" + +# ─── NUEVOS MODELOS (agregar después de NotificacionPendiente) ──────────────── + +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) -> 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" + + @property + def resumen(self) -> str: + """Texto compacto para mostrar en listados.""" + partes = [self.caratula] + if self.expediente_str != "sin número": + partes.append(f"Exp. {self.expediente_str}") + if self.circunscripcion: + partes.append(self.circunscripcion) + partes.append(self.estado) + return " | ".join(partes) + + +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) -> 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: + from datetime import datetime + 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}" + + +# ─── NUEVOS MÉTODOS para la clase CSJClient ────────────────────────────────── + + 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). + + Parámetros confirmados por reconocimiento 22/05/2026: + - caratula: búsqueda parcial por texto en carátula + - NroExpedienteNumero: número del expediente (sin año) + - NroExpedienteAnho: año del expediente (con 'h', no 'ñ') + - offset/cantidad: paginación + + Al menos uno de caratula, numero, o (numero+anio) debe proveerse. + """ + 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 = { + "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: + 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 = [] + 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)) + 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 (movimientos) de un expediente. + + URL confirmada por reconocimiento 22/05/2026: + GET /api/gestion-partes/CasoJudicial/{codCasoJudicial}/{origen}/0/actuaciones + + El parámetro {origen} es el campo 'origen' del CasoJudicial: + - origen=2: expediente del sistema viejo integrado + - origen=0: expediente del sistema nuevo + + El segundo posicional (/0/) parece 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" + + try: + r = await self._http.get( + url, + params={ + "offset": offset, + "cantidad": cantidad, + "incluirNotificaciones": str(incluir_notificaciones).lower(), + }, + headers=session.auth_headers(), + ) + except httpx.HTTPError as e: + raise CSJConnectionError(f"Error al listar actuaciones: {e}") from e + + if r.status_code == 401: + self._session = None + session = await self._asegurar_sesion() + r = await self._http.get( + url, + params={ + "offset": offset, + "cantidad": cantidad, + "incluirNotificaciones": str(incluir_notificaciones).lower(), + }, + headers=session.auth_headers(), + ) + + r.raise_for_status() + data = r.json() + + items = data.get("resultado") or [] + total = data.get("cantidadTotalRegistros") or 0 + + actuaciones = [] + 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), + ) + return actuaciones, total \ No newline at end of file diff --git a/database.py b/database.py new file mode 100644 index 0000000..4484106 --- /dev/null +++ b/database.py @@ -0,0 +1,140 @@ +""" +database.py — Persistencia de tenants y preferencias. + +SQLite vía aiosqlite. En v1 esto se reemplaza por Postgres con el mismo interface público. + +Schema: +- tenants: un registro por abogado registrado. + Las credenciales van cifradas con vault.py (Fernet derivada por chat_id). +""" +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path + +import aiosqlite +import structlog + +logger = structlog.get_logger(__name__) + +_conn: aiosqlite.Connection | None = None + +_CREATE_AUDIT_LOG = """ +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + timestamp_local TEXT, + chat_id INTEGER, + evento TEXT NOT NULL, + detalle TEXT, + resultado TEXT, + duracion_ms INTEGER +); +CREATE INDEX IF NOT EXISTS idx_audit_chat_id ON audit_log(chat_id); +CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp); +CREATE INDEX IF NOT EXISTS idx_audit_evento ON audit_log(evento); +""" + +_CREATE_TENANTS = """ +CREATE TABLE IF NOT EXISTS tenants ( + chat_id INTEGER PRIMARY KEY, + nombre_preferido TEXT, + tono TEXT DEFAULT 'cotidiano', + timezone TEXT DEFAULT 'America/Asuncion', + credentials_enc TEXT, + estado TEXT DEFAULT 'pendiente', + consentimiento_aceptado INTEGER DEFAULT 0, + created_at TEXT, + last_active TEXT +); +""" + + +def get_conn() -> aiosqlite.Connection: + """Retorna la conexión activa. Llamar solo después de init_db().""" + return _assert_conn() + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +async def init_db(db_path: Path) -> None: + """Crear tablas si no existen. Llamar una sola vez al arrancar.""" + global _conn + db_path.parent.mkdir(parents=True, exist_ok=True) + _conn = await aiosqlite.connect(str(db_path)) + _conn.row_factory = aiosqlite.Row + await _conn.execute(_CREATE_TENANTS) + # CREATE INDEX no soporta executescript — ejecutar sentencias separadas + for stmt in _CREATE_AUDIT_LOG.strip().split(";"): + stmt = stmt.strip() + if stmt: + await _conn.execute(stmt) + await _conn.commit() + logger.info("database_iniciada", path=str(db_path)) + + +def _assert_conn() -> aiosqlite.Connection: + if _conn is None: + raise RuntimeError("DB no inicializada — llamar a init_db() antes de usar database.") + return _conn + + +async def get_tenant(chat_id: int) -> dict | None: + """Retorna el tenant o None si no existe.""" + conn = _assert_conn() + async with conn.execute( + "SELECT * FROM tenants WHERE chat_id = ?", (chat_id,) + ) as cur: + row = await cur.fetchone() + return dict(row) if row else None + + +async def save_tenant(chat_id: int, **fields: object) -> None: + """ + Crear o actualizar un tenant. Los kwargs son los campos de la tabla. + Hace upsert: inserta si no existe, actualiza si ya existe. + """ + conn = _assert_conn() + fields["last_active"] = _now_iso() + existing = await get_tenant(chat_id) + + if existing is None: + fields.setdefault("created_at", _now_iso()) + columns = ["chat_id", *fields.keys()] + placeholders = ", ".join("?" * len(columns)) + values = [chat_id, *fields.values()] + await conn.execute( + f"INSERT INTO tenants ({', '.join(columns)}) VALUES ({placeholders})", + values, + ) + logger.info("tenant_creado", chat_id=chat_id) + else: + set_clause = ", ".join(f"{k} = ?" for k in fields) + values_upd = [*fields.values(), chat_id] + await conn.execute( + f"UPDATE tenants SET {set_clause} WHERE chat_id = ?", + values_upd, + ) + logger.info("tenant_actualizado", chat_id=chat_id, campos=list(fields.keys())) + + await conn.commit() + + +async def list_active_tenants() -> list[dict]: + """Retorna todos los tenants con estado='activo'.""" + conn = _assert_conn() + async with conn.execute( + "SELECT * FROM tenants WHERE estado = 'activo'" + ) as cur: + rows = await cur.fetchall() + return [dict(r) for r in rows] + + +async def delete_tenant(chat_id: int) -> None: + """Elimina el tenant y sus datos de la DB.""" + conn = _assert_conn() + await conn.execute("DELETE FROM tenants WHERE chat_id = ?", (chat_id,)) + await conn.commit() + logger.info("tenant_eliminado", chat_id=chat_id) diff --git a/main.py b/main.py new file mode 100644 index 0000000..d6ea4d7 --- /dev/null +++ b/main.py @@ -0,0 +1,146 @@ +""" +main.py — Entrypoint de Pedrito Hechakuaa. + +Arranca el bot de Telegram y el poller en paralelo. +Al arrancar: inicializa la DB y migra el tenant principal del .env si no existe. +""" +from __future__ import annotations + +import asyncio +import signal +import sys + +import structlog +from telegram.ext import Application + +import vault as vault_mod +from config import get_settings +from database import get_tenant, init_db, save_tenant +from onboarding import OnboardingFlow +from poller import Poller +from telegram_bot import PedritoBot + +# Configurar structlog +structlog.configure( + processors=[ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="iso"), + structlog.dev.ConsoleRenderer() if sys.stderr.isatty() else structlog.processors.JSONRenderer(), + ] +) + +logger = structlog.get_logger(__name__) + + +async def _migrar_tenant_principal(settings) -> None: + """ + Migra el tenant principal del .env a la DB si todavía no existe. + Permite que el dueño del bot siga funcionando sin re-registrarse. + """ + chat_id = settings.telegram_chat_id + existente = await get_tenant(chat_id) + if existente: + logger.info("tenant_principal_ya_existe", chat_id=chat_id) + return + + logger.info("migrando_tenant_principal_del_env", chat_id=chat_id) + usuario = settings.descifrar_usuario() + password = settings.descifrar_password() + try: + credentials_enc = vault_mod.cifrar_credenciales( + chat_id=chat_id, + usuario=usuario, + password=password, + fernet_key=settings.fernet_key, + ) + finally: + del usuario, password + + await save_tenant( + chat_id, + nombre_preferido="Admin", + tono="cotidiano", + timezone="America/Asuncion", + credentials_enc=credentials_enc, + estado="activo", + consentimiento_aceptado=1, + ) + logger.info("tenant_principal_migrado", chat_id=chat_id) + + +async def main() -> None: + settings = get_settings() + + logger.info( + "pedrito_hechakuaa_iniciando", + poll_interval_minutes=settings.poll_interval_minutes, + horario=f"{settings.poll_hora_inicio}:00-{settings.poll_hora_fin}:00", + chat_id=settings.telegram_chat_id, + ) + + # Base de datos + await init_db(settings.db_path) + await _migrar_tenant_principal(settings) + + # Bot de Telegram + onboarding = OnboardingFlow(settings) + pedrito_bot = PedritoBot( + token=settings.telegram_bot_token, + settings=settings, + onboarding=onboarding, + ) + app: Application = pedrito_bot.build() + + # Poller (sin snapshot global — maneja uno por tenant internamente) + poller = Poller( + settings=settings, + bot=pedrito_bot, + ) + + # El callback de /notif pasa el chat_id al poller + pedrito_bot.set_notif_callback( + lambda chat_id: poller.revisar_ahora(chat_id=chat_id, es_manual=True) + ) + + # Manejo de señales para shutdown limpio + loop = asyncio.get_running_loop() + + def handle_shutdown(sig: int) -> None: + logger.info("shutdown_recibido", signal=sig) + poller.detener() + loop.stop() + + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, handle_shutdown, sig) + except NotImplementedError: + pass # Windows no soporta add_signal_handler + + # Arrancar bot y poller en paralelo + async with app: + await app.initialize() + await app.start() + + logger.info("bot_telegram_iniciado") + + telegram_task = asyncio.create_task( + app.updater.start_polling(drop_pending_updates=True) + ) + poller_task = asyncio.create_task(poller.run_forever()) + + logger.info("pedrito_hechakuaa_corriendo") + + try: + await asyncio.gather(telegram_task, poller_task) + except asyncio.CancelledError: + pass + finally: + await app.updater.stop() + await app.stop() + + logger.info("pedrito_hechakuaa_detenido") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/onboarding.py b/onboarding.py new file mode 100644 index 0000000..c49b50b --- /dev/null +++ b/onboarding.py @@ -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"}) diff --git a/poller.py b/poller.py new file mode 100644 index 0000000..dcaedb0 --- /dev/null +++ b/poller.py @@ -0,0 +1,216 @@ +""" +poller.py — Loop de polling de notificaciones (multi-tenant). + +Responsabilidades: +- Revisar el PJ de cada tenant activo cada N minutos en horario hábil +- Calcular diff vs snapshot → solo notificar las nuevas +- Manejar errores de conexión sin crashear ni detener otros tenants +""" +from __future__ import annotations + +import asyncio +from datetime import datetime +from pathlib import Path +from zoneinfo import ZoneInfo + +import structlog + +import audit +import vault +from config import Settings +from csj_client import ( + CSJAuthError, + CSJClient, + CSJConnectionError, + CSJPasswordTemporalError, +) +from database import delete_tenant, get_tenant, list_active_tenants, save_tenant +from storage import Snapshot + +logger = structlog.get_logger(__name__) + +PY_TZ = ZoneInfo("America/Asuncion") +_MAX_ERRORES_CONSECUTIVOS = 5 + + +class Poller: + """ + Revisa el PJ periódicamente para todos los tenants activos. + """ + + def __init__( + self, + settings: Settings, + bot: object, # PedritoBot — evitamos import circular + ) -> None: + self._settings = settings + self._bot = bot + self._running = False + self._snapshots: dict[int, Snapshot] = {} + self._errores_consecutivos: dict[int, int] = {} + # Cola de revisiones manuales (chat_id) disparadas desde /notif + self._manual_queue: asyncio.Queue[int] = asyncio.Queue() + + def _en_horario_habitual(self) -> bool: + ahora = datetime.now(PY_TZ) + if ahora.weekday() >= 5: # sábado o domingo + return False + return self._settings.poll_hora_inicio <= ahora.hour < self._settings.poll_hora_fin + + def _get_snapshot(self, chat_id: int) -> Snapshot: + if chat_id not in self._snapshots: + path: Path = self._settings.data_dir / f"snapshot_{chat_id}.json" + self._snapshots[chat_id] = Snapshot(path) + return self._snapshots[chat_id] + + # ────────────────────────────────────────────────────────────────────────── + # Lógica de revisión por tenant + # ────────────────────────────────────────────────────────────────────────── + + async def _revisar_tenant(self, tenant: dict, es_manual: bool = False) -> None: + """Revisa notificaciones para UN tenant específico.""" + chat_id: int = tenant["chat_id"] + snapshot = self._get_snapshot(chat_id) + credentials_enc = tenant.get("credentials_enc") + + if not credentials_enc: + logger.warning("tenant_sin_credenciales_skip", chat_id=chat_id) + return + + try: + usuario, password = vault.descifrar_credenciales( + chat_id, credentials_enc, self._settings.fernet_key + ) + except ValueError as e: + logger.error("error_descifrar_credenciales_tenant", chat_id=chat_id, error=str(e)) + return + + logger.info("iniciando_revision_tenant", chat_id=chat_id, es_manual=es_manual) + await audit.log("polling_inicio", chat_id=chat_id, detalle={"es_manual": es_manual}, tenant=tenant) + + try: + async with CSJClient(self._settings, usuario=usuario, password=password, chat_id=chat_id) as client: + try: + cantidad = await client.contar_notificaciones() + logger.info("cantidad_notificaciones_tenant", chat_id=chat_id, cantidad=cantidad) + + if cantidad == 0: + snapshot.registrar_check() + if es_manual: + await self._bot.enviar_sin_novedades(tenant, snapshot) # type: ignore[attr-defined] + self._errores_consecutivos[chat_id] = 0 + await audit.log("polling_tenant_ok", chat_id=chat_id, detalle={"nuevas": 0, "total": 0}, tenant=tenant) + return + + notificaciones = await client.listar_notificaciones() + nuevas = [n for n in notificaciones if not snapshot.ya_vista(n.cod_notificacion_origen)] + + logger.info( + "diff_calculado_tenant", + chat_id=chat_id, + total=len(notificaciones), + nuevas=len(nuevas), + ) + + if nuevas: + for notif in nuevas: + await self._bot.enviar_notificacion(notif, tenant) # type: ignore[attr-defined] + snapshot.marcar_vista(notif.cod_notificacion_origen) + elif es_manual: + await self._bot.enviar_sin_novedades(tenant, snapshot) # type: ignore[attr-defined] + + snapshot.registrar_check() + self._errores_consecutivos[chat_id] = 0 + await audit.log("polling_tenant_ok", chat_id=chat_id, detalle={"nuevas": len(nuevas), "total": len(notificaciones)}, tenant=tenant) + + except CSJPasswordTemporalError: + logger.warning("password_temporal_tenant", chat_id=chat_id) + await audit.log("polling_tenant_error", chat_id=chat_id, resultado="error", detalle={"tipo": "password_temporal"}, tenant=tenant) + await self._bot.enviar_password_temporal(tenant) # type: ignore[attr-defined] + await save_tenant(chat_id, estado="suspendido") + self._snapshots.pop(chat_id, None) + + except CSJAuthError as e: + logger.error("error_auth_tenant", chat_id=chat_id, error=str(e)) + await audit.log("polling_tenant_error", chat_id=chat_id, resultado="error", detalle={"tipo": "auth"}, tenant=tenant) + await self._bot.enviar_error_auth(tenant) # type: ignore[attr-defined] + await save_tenant(chat_id, estado="suspendido") + self._snapshots.pop(chat_id, None) + + except CSJConnectionError as e: + n = self._errores_consecutivos.get(chat_id, 0) + 1 + self._errores_consecutivos[chat_id] = n + logger.warning( + "error_conexion_transitorio_tenant", + chat_id=chat_id, + error=str(e), + consecutivos=n, + ) + await audit.log("pj_error_conexion", chat_id=chat_id, resultado="error", detalle={"consecutivos": n}, tenant=tenant) + if n >= _MAX_ERRORES_CONSECUTIVOS: + await self._bot.enviar_error_conexion(str(e), tenant) # type: ignore[attr-defined] + self._errores_consecutivos[chat_id] = 0 + + except Exception as e: + logger.exception("error_inesperado_tenant", chat_id=chat_id, error=str(e)) + await audit.log("polling_tenant_error", chat_id=chat_id, resultado="error", detalle={"tipo": "inesperado"}, tenant=tenant) + self._errores_consecutivos[chat_id] = self._errores_consecutivos.get(chat_id, 0) + 1 + + finally: + del usuario, password + + # ────────────────────────────────────────────────────────────────────────── + # API pública + # ────────────────────────────────────────────────────────────────────────── + + async def revisar_ahora(self, chat_id: int | None = None, es_manual: bool = False) -> None: + """ + Si chat_id: revisar solo ese tenant (para /notif manual). + Si None: revisar todos los tenants activos. + """ + if chat_id is not None: + tenant = await get_tenant(chat_id) + if tenant and tenant.get("estado") == "activo": + await self._revisar_tenant(tenant, es_manual=es_manual) + elif es_manual and tenant: + logger.warning("revisar_ahora_tenant_no_activo", chat_id=chat_id, estado=tenant.get("estado")) + else: + tenants = await list_active_tenants() + if not tenants: + logger.info("sin_tenants_activos_skip") + return + await asyncio.gather(*(self._revisar_tenant(t) for t in tenants)) + + def disparar_revision_manual(self, chat_id: int) -> None: + """Llamado desde el handler de /notif para disparar revisión inmediata.""" + self._manual_queue.put_nowait(chat_id) + + def detener(self) -> None: + self._running = False + + async def run_forever(self) -> None: + """Loop principal. Revisa todos los tenants cada poll_interval_minutes en horario hábil.""" + self._running = True + logger.info( + "poller_iniciado", + interval_minutes=self._settings.poll_interval_minutes, + hora_inicio=self._settings.poll_hora_inicio, + hora_fin=self._settings.poll_hora_fin, + ) + + logger.info("revision_inicial_al_arrancar") + await self.revisar_ahora() + + while self._running: + try: + chat_id = await asyncio.wait_for( + self._manual_queue.get(), + timeout=self._settings.poll_interval_minutes * 60, + ) + await self.revisar_ahora(chat_id=chat_id, es_manual=True) + + except asyncio.TimeoutError: + if self._en_horario_habitual(): + await self.revisar_ahora() + else: + logger.debug("fuera_de_horario_skip") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..03c9740 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[project] +name = "pedrito-hechakuaa" +version = "0.1.0" +description = "Pedrito Hechakuaa — monitor de notificaciones judiciales via Telegram" +requires-python = ">=3.12" + +dependencies = [ + "httpx>=0.28", + "python-telegram-bot[ext]>=21.9", + "apscheduler>=3.10", + "cryptography>=44.0", + "pydantic>=2.9", + "pydantic-settings>=2.6", + "structlog>=24.4", + "python-dotenv>=1.0", + "aiosqlite>=0.20", +] + +[dependency-groups] +dev = [ + "pytest>=8.3", + "pytest-asyncio>=0.25", + "pytest-httpx>=0.34", + "ruff>=0.8", + "mypy>=1.13", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["."] + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "ASYNC"] +ignore = ["E501"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/scripts/cifrar_credenciales.py b/scripts/cifrar_credenciales.py new file mode 100644 index 0000000..0a822bb --- /dev/null +++ b/scripts/cifrar_credenciales.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +""" +scripts/cifrar_credenciales.py + +Cifra las credenciales del PJ con la Fernet key y las escribe en .env. +Correr UNA VEZ al configurar el sistema. + +Uso: + python scripts/cifrar_credenciales.py +""" +import getpass +import os +import re +import sys +from pathlib import Path + +# Agregar el directorio raíz al path +sys.path.insert(0, str(Path(__file__).parent.parent)) + + +def main() -> None: + env_path = Path(".env") + + if not env_path.exists(): + print("ERROR: No encontré .env. Copiá .env.example a .env primero.") + sys.exit(1) + + # Leer FERNET_KEY del .env + env_content = env_path.read_text(encoding="utf-8") + + fernet_key_match = re.search(r"^FERNET_KEY=(.+)$", env_content, re.MULTILINE) + if not fernet_key_match or not fernet_key_match.group(1).strip(): + print("ERROR: FERNET_KEY no encontrada en .env o está vacía.") + print("Generá una con:") + print(" python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"") + sys.exit(1) + + fernet_key = fernet_key_match.group(1).strip() + + try: + from cryptography.fernet import Fernet + f = Fernet(fernet_key.encode()) + except Exception as e: + print(f"ERROR: FERNET_KEY inválida: {e}") + sys.exit(1) + + print("=== Cifrado de credenciales del PJ ===\n") + print("Estas credenciales son tu usuario y contraseña del sistema del Poder Judicial.") + print("Se van a cifrar con tu Fernet key y guardar en .env.\n") + + usuario = input("Cédula de identidad (usuario del PJ): ").strip() + if not usuario: + print("ERROR: La cédula no puede estar vacía.") + sys.exit(1) + + password = getpass.getpass("Contraseña del PJ (no se muestra): ") + if not password: + print("ERROR: La contraseña no puede estar vacía.") + sys.exit(1) + + # Cifrar + usuario_enc = f.encrypt(usuario.encode()).decode() + password_enc = f.encrypt(password.encode()).decode() + + # Limpiar memoria + del password + del usuario + + # Actualizar .env + env_content = re.sub( + r"^PJ_USUARIO_ENC=.*$", + f"PJ_USUARIO_ENC={usuario_enc}", + env_content, + flags=re.MULTILINE, + ) + env_content = re.sub( + r"^PJ_PASSWORD_ENC=.*$", + f"PJ_PASSWORD_ENC={password_enc}", + env_content, + flags=re.MULTILINE, + ) + + env_path.write_text(env_content, encoding="utf-8") + + print("\n✅ Credenciales cifradas y guardadas en .env") + print(" PJ_USUARIO_ENC y PJ_PASSWORD_ENC actualizadas.") + print("\nPodés arrancar el bot con: python main.py") + + +if __name__ == "__main__": + main() diff --git a/storage.py b/storage.py new file mode 100644 index 0000000..dc98427 --- /dev/null +++ b/storage.py @@ -0,0 +1,76 @@ +""" +storage.py — Persistencia del snapshot de notificaciones. + +Guarda en JSON local en data/snapshot.json. +Simple a propósito: en v1 esto se reemplaza por Postgres con el mismo interface. +""" +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + +import structlog + +logger = structlog.get_logger(__name__) + + +class Snapshot: + """ + Snapshot del estado de notificaciones. + Guarda los IDs de las notificaciones ya notificadas al abogado. + """ + + def __init__(self, path: Path) -> None: + self._path = path + self._notificaciones_vistas: set[int] = set() + self._last_check: datetime | None = None + self._load() + + def _load(self) -> None: + if not self._path.exists(): + logger.info("snapshot_no_existe_iniciando_vacio", path=str(self._path)) + return + + try: + data = json.loads(self._path.read_text(encoding="utf-8")) + self._notificaciones_vistas = set(data.get("notificaciones_vistas", [])) + last_check_str = data.get("last_check") + if last_check_str: + self._last_check = datetime.fromisoformat(last_check_str) + logger.info( + "snapshot_cargado", + notificaciones_vistas=len(self._notificaciones_vistas), + last_check=last_check_str, + ) + except (json.JSONDecodeError, ValueError) as e: + logger.warning("snapshot_corrupto_iniciando_vacio", error=str(e)) + + def _save(self) -> None: + data = { + "last_check": self._last_check.isoformat() if self._last_check else None, + "notificaciones_vistas": sorted(self._notificaciones_vistas), + } + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + def ya_vista(self, cod_notificacion: int) -> bool: + return cod_notificacion in self._notificaciones_vistas + + def marcar_vista(self, cod_notificacion: int) -> None: + self._notificaciones_vistas.add(cod_notificacion) + + def marcar_varias_vistas(self, codigos: list[int]) -> None: + self._notificaciones_vistas.update(codigos) + + def registrar_check(self) -> None: + self._last_check = datetime.now(timezone.utc) + self._save() + + @property + def last_check(self) -> datetime | None: + return self._last_check + + @property + def total_vistas(self) -> int: + return len(self._notificaciones_vistas) diff --git a/telegram_bot.py b/telegram_bot.py new file mode 100644 index 0000000..b326152 --- /dev/null +++ b/telegram_bot.py @@ -0,0 +1,825 @@ +""" +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 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 + + 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 + + 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] + 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] + 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] + 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] + 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] + 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() + + 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] + 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 , /exp /, o /exp \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] + 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 / o /pdf / \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] + 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 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] + 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] + 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 diff --git a/utils/sanitize.py b/utils/sanitize.py new file mode 100644 index 0000000..9b5ebe5 --- /dev/null +++ b/utils/sanitize.py @@ -0,0 +1,66 @@ +""" +utils/sanitize.py — Sanitización de datos antes de loggear. + +REGLA: todo log que incluya datos externos (responses del PJ, inputs del usuario) +debe pasar por credential_scrubber() antes de escribirse. + +Uso: + from utils.sanitize import scrub + + logger.info("respuesta_pj", data=scrub(response_text)) + logger.error("error_en_request", body=scrub(request_body)) +""" +from __future__ import annotations + +import re + +# Patrones que NUNCA deben aparecer en logs +_PATTERNS = [ + # Bearer tokens (opacos base64url, ~128 chars) + (re.compile(r"Bearer\s+[A-Za-z0-9\-_]{20,}"), "Bearer [REDACTED]"), + # Authorization header completo + (re.compile(r'"[Aa]uthorization"\s*:\s*"[^"]+"'), '"Authorization": "[REDACTED]"'), + # bearerToken en JSON + (re.compile(r'"bearerToken"\s*:\s*"[^"]+"'), '"bearerToken": "[REDACTED]"'), + # refreshToken en JSON + (re.compile(r'"refreshToken"\s*:\s*"[^"]+"'), '"refreshToken": "[REDACTED]"'), + # permisoFirmado JWT (eyJ...) + (re.compile(r'"permisoFirmado"\s*:\s*"eyJ[A-Za-z0-9\.\-_=+/]+"'), '"permisoFirmado": "[REDACTED]"'), + # clave/password en JSON (campo "clave" del body de login) + (re.compile(r'"clave"\s*:\s*"[^"]+"'), '"clave": "[REDACTED]"'), + (re.compile(r'"password"\s*:\s*"[^"]+"'), '"password": "[REDACTED]"'), + # cookiesession (hexadecimal 32 chars) + (re.compile(r"cookiesession1=[A-Fa-f0-9]{32}"), "cookiesession1=[REDACTED]"), + # Cualquier JWT genérico (eyJ...) + (re.compile(r"\beyJ[A-Za-z0-9\.\-_=+/]{50,}"), "[JWT_REDACTED]"), +] + + +def scrub(value: object) -> str: + """ + Convierte value a string y aplica todos los patrones de sanitización. + Usar siempre antes de pasar datos externos a structlog. + + Ejemplo: + logger.debug("raw_response", body=scrub(response.text)) + """ + text = str(value) + for pattern, replacement in _PATTERNS: + text = pattern.sub(replacement, text) + return text + + +def scrub_dict(d: dict) -> dict: + """ + Aplica scrub() recursivamente a todos los valores string de un dict. + Útil para sanitizar dicts de headers o payloads antes de loggear. + """ + result = {} + for key, value in d.items(): + if isinstance(value, str): + result[key] = scrub(value) + elif isinstance(value, dict): + result[key] = scrub_dict(value) + else: + result[key] = value + return result diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..7bfe45d --- /dev/null +++ b/uv.lock @@ -0,0 +1,766 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", + "python_full_version < '3.14'", +] + +[[package]] +name = "aiolimiter" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/23/b52debf471f7a1e42e362d959a3982bdcb4fe13a5d46e63d28868807a79c/aiolimiter-1.2.1.tar.gz", hash = "sha256:e02a37ea1a855d9e832252a105420ad4d15011505512a1a1d814647451b5cca9", size = 7185, upload-time = "2024-12-08T15:31:51.496Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/ba/df6e8e1045aebc4778d19b8a3a9bc1808adb1619ba94ca354d9ba17d86c3/aiolimiter-1.2.1-py3-none-any.whl", hash = "sha256:d3f249e9059a20badcb56b61601a83556133655c11d1eb3dd3e04ff069e5f3c7", size = 6711, upload-time = "2024-12-08T15:31:49.874Z" }, +] + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "apscheduler" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, +] + +[[package]] +name = "cachetools" +version = "7.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/c1/67cfb86aa21144796ff51068326d467fbef8ee42f8d08a3a8a926106cf0c/cachetools-7.1.3.tar.gz", hash = "sha256:135cfe944bc3c1e805505f65dae0bef375a2f96261171ab66c79ef77d0bda39d", size = 45780, upload-time = "2026-05-18T18:21:03.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/52/8ff5c1a3b2e821ced9b2998fba3ee29aa4525c0bf51e5ee55dd6f61a4ed5/cachetools-7.1.3-py3-none-any.whl", hash = "sha256:9876787e2346e20584d5cca236cb5d49d04e7193de91646f230725b2e1e8b804", size = 16763, upload-time = "2026-05-18T18:21:02.386Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, +] + +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pedrito-hechakuaa" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiosqlite" }, + { name = "apscheduler" }, + { name = "cryptography" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "python-telegram-bot", extra = ["ext"] }, + { name = "structlog" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-httpx" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiosqlite", specifier = ">=0.20" }, + { name = "apscheduler", specifier = ">=3.10" }, + { name = "cryptography", specifier = ">=44.0" }, + { name = "httpx", specifier = ">=0.28" }, + { name = "pydantic", specifier = ">=2.9" }, + { name = "pydantic-settings", specifier = ">=2.6" }, + { name = "python-dotenv", specifier = ">=1.0" }, + { name = "python-telegram-bot", extras = ["ext"], specifier = ">=21.9" }, + { name = "structlog", specifier = ">=24.4" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.13" }, + { name = "pytest", specifier = ">=8.3" }, + { name = "pytest-asyncio", specifier = ">=0.25" }, + { name = "pytest-httpx", specifier = ">=0.34" }, + { name = "ruff", specifier = ">=0.8" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-httpx" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/42/f53c58570e80d503ade9dd42ce57f2915d14bcbe25f6308138143950d1d6/pytest_httpx-0.36.2.tar.gz", hash = "sha256:05a56527484f7f4e8c856419ea379b8dc359c36801c4992fdb330f294c690356", size = 57683, upload-time = "2026-04-09T13:57:19.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/55/1fa65f8e4fceb19dd6daa867c162ad845d547f6058cd92b4b02384a44777/pytest_httpx-0.36.2-py3-none-any.whl", hash = "sha256:d42ebd5679442dc7bfb0c48e0767b6562e9bc4534d805127b0084171886a5e22", size = 20315, upload-time = "2026-04-09T13:57:18.587Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-telegram-bot" +version = "22.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpcore", marker = "python_full_version >= '3.14'" }, + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/25/2258161b1069e66d6c39c0a602dbe57461d4767dc0012539970ea40bc9d6/python_telegram_bot-22.7.tar.gz", hash = "sha256:784b59ea3852fe4616ad63b4a0264c755637f5d725e87755ecdee28300febf61", size = 1516454, upload-time = "2026-03-16T09:36:03.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/f7/0e2f89dd62f45d46d4ea0d8aec5893ce5b37389638db010c117f46f11450/python_telegram_bot-22.7-py3-none-any.whl", hash = "sha256:d72eed532cf763758cd9331b57a6d790aff0bb4d37d8f4e92149436fe21c6475", size = 745365, upload-time = "2026-03-16T09:36:01.498Z" }, +] + +[package.optional-dependencies] +ext = [ + { name = "aiolimiter" }, + { name = "apscheduler" }, + { name = "cachetools" }, + { name = "tornado" }, +] + +[[package]] +name = "ruff" +version = "0.15.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, +] + +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" }, + { url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" }, + { url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" }, + { url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] diff --git a/vault.py b/vault.py new file mode 100644 index 0000000..25482ea --- /dev/null +++ b/vault.py @@ -0,0 +1,55 @@ +""" +vault.py — Cifrado de credenciales por tenant. + +Cada tenant tiene sus credenciales cifradas con una clave derivada de: + FERNET_KEY (global, del .env) + str(chat_id) (único por tenant) + +Esto asegura que las credenciales de un tenant no se pueden descifrar +con solo la FERNET_KEY — hace falta también el chat_id. +""" +from __future__ import annotations + +import base64 +import hashlib +import json + +import structlog +from cryptography.fernet import Fernet, InvalidToken + +from utils.sanitize import scrub + +logger = structlog.get_logger(__name__) + + +def _derive_key(fernet_key: str, chat_id: int) -> bytes: + """Deriva una clave Fernet única por tenant usando SHA-256(fernet_key + chat_id).""" + raw = (fernet_key + str(chat_id)).encode() + digest = hashlib.sha256(raw).digest() + return base64.urlsafe_b64encode(digest) + + +def cifrar_credenciales(chat_id: int, usuario: str, password: str, fernet_key: str) -> str: + """Retorna JSON cifrado con Fernet listo para guardar en DB.""" + key = _derive_key(fernet_key, chat_id) + f = Fernet(key) + payload = json.dumps({"usuario": usuario, "password": password}).encode() + encrypted = f.encrypt(payload).decode() + logger.info("credenciales_cifradas", chat_id=chat_id) + return encrypted + + +def descifrar_credenciales(chat_id: int, credentials_enc: str, fernet_key: str) -> tuple[str, str]: + """ + Retorna (usuario, password). + Levanta ValueError si no se puede descifrar (clave incorrecta o datos corruptos). + """ + key = _derive_key(fernet_key, chat_id) + f = Fernet(key) + try: + payload = json.loads(f.decrypt(credentials_enc.encode()).decode()) + return payload["usuario"], payload["password"] + except (InvalidToken, KeyError, ValueError) as e: + logger.error("error_descifrar_credenciales", chat_id=chat_id, error=scrub(str(e))) + raise ValueError( + f"No se pudieron descifrar credenciales para chat_id={chat_id}" + ) from e