Initial commit — Pedrito Hechakuaa v0

Bot de Telegram para monitoreo de notificaciones judiciales del PJ Paraguay.
Multi-tenant con onboarding conversacional, audit log, y polling automático.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
markosbenitez
2026-05-25 10:58:32 -03:00
commit 7743919695
24 changed files with 5242 additions and 0 deletions

34
.env.example Normal file
View File

@@ -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

22
.gitignore vendored Normal file
View File

@@ -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

295
CLAUDE.md Normal file
View File

@@ -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 <número>` — detalle de expediente por número (ej: `/exp 198` o `/exp 198/2026`)
- `/exp <texto>` — 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.

211
GUIA_TELEGRAM.md Normal file
View File

@@ -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.

323
MANUAL_USUARIO.md Normal file
View File

@@ -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.*

34
OBSERVATIONS.md Normal file
View File

@@ -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`.

127
PROMPT_CLAUDE_CODE_EXP.md Normal file
View File

@@ -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

123
README.md Normal file
View File

@@ -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.

47
TECH_DEBT.md Normal file
View File

@@ -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*

57
audit.py Normal file
View File

@@ -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))

113
config.py Normal file
View File

@@ -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

691
csj_client.py Normal file
View File

@@ -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

View File

@@ -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

140
database.py Normal file
View File

@@ -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)

146
main.py Normal file
View File

@@ -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())

482
onboarding.py Normal file
View File

@@ -0,0 +1,482 @@
"""
onboarding.py — Máquina de estados del flujo de registro.
Estado en memoria por chat_id. Si el bot se reinicia, el usuario hace /start de nuevo
(aceptable en este sprint).
Estados:
INICIO → mostró bienvenida con botones de consentimiento
ESPERANDO_NOMBRE → preguntó nombre, esperando texto libre
ESPERANDO_TONO → mostró botones de tono, esperando selección
ESPERANDO_TZ → mostró botones de timezone, esperando selección
ESPERANDO_TZ_CUSTOM → eligió "Otra", esperando texto con nombre de zona
ESPERANDO_USUARIO → preguntó cédula del PJ, esperando texto
ESPERANDO_PASSWORD → preguntó password del PJ, esperando texto (borrar tras procesar)
ESPERANDO_SALIR → mostró confirmación de /salir, esperando botón
"""
from __future__ import annotations
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import structlog
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.constants import ParseMode
from telegram.ext import ContextTypes
import audit
import vault as vault_mod
from config import Settings
from csj_client import CSJAuthError, CSJClient, CSJConnectionError, CSJPasswordTemporalError
from database import delete_tenant, get_tenant, save_tenant
logger = structlog.get_logger(__name__)
# Estados (strings internos)
_INICIO = "inicio"
_ESPERANDO_NOMBRE = "nombre"
_ESPERANDO_TONO = "tono"
_ESPERANDO_TZ = "tz"
_ESPERANDO_TZ_CUSTOM = "tz_custom"
_ESPERANDO_USUARIO = "usuario"
_ESPERANDO_PASSWORD = "password"
_ESPERANDO_SALIR = "salir"
_MAX_INTENTOS = 3
_TZ_MAP = {
"py": "America/Asuncion",
"ar": "America/Argentina/Buenos_Aires",
"uy": "America/Montevideo",
}
def _esc(text: str) -> str:
"""Escapa MarkdownV2 para texto plano dentro de mensajes de onboarding."""
text = text.replace("\\", "\\\\")
for ch in "_*[]()~`>#+-=|{}.!":
text = text.replace(ch, f"\\{ch}")
return text
class OnboardingFlow:
"""Máquina de estados del flujo de registro multi-tenant."""
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._states: dict[int, str] = {}
self._temp: dict[int, dict] = {} # datos parciales por chat_id
self._attempts: dict[int, int] = {} # intentos de credenciales fallidos
# ──────────────────────────────────────────────────────────────────────────
# Helpers de estado
# ──────────────────────────────────────────────────────────────────────────
def is_in_onboarding(self, chat_id: int) -> bool:
return chat_id in self._states
def _clear(self, chat_id: int) -> None:
self._states.pop(chat_id, None)
self._temp.pop(chat_id, None)
self._attempts.pop(chat_id, None)
# ──────────────────────────────────────────────────────────────────────────
# Puntos de entrada desde telegram_bot.py
# ──────────────────────────────────────────────────────────────────────────
async def iniciar_registro(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Llamado desde cmd_start cuando el usuario no está registrado."""
chat_id = update.effective_chat.id # type: ignore[union-attr]
self._states[chat_id] = _INICIO
self._temp[chat_id] = {}
self._attempts[chat_id] = 0
texto = (
"Hola\\! Soy Pedrito, el asistente judicial del estudio\\.\n\n"
"Antes de empezar, necesito que sepas:\n"
"• Voy a consultar el sistema del Poder Judicial en tu nombre\n"
"• Guardo tus credenciales cifradas — solo yo las puedo leer\n"
"• Solo hago consultas\\. Nunca acuso notificaciones por vos\\.\n"
"• Podés borrar tus datos cuando quieras con /salir\n\n"
"¿Aceptás estas condiciones?"
)
keyboard = InlineKeyboardMarkup([
[
InlineKeyboardButton("✅ Sí, acepto", callback_data="onb:consent:si"),
InlineKeyboardButton("❌ No, gracias", callback_data="onb:consent:no"),
]
])
await update.effective_message.reply_text( # type: ignore[union-attr]
texto, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard
)
await audit.log("onboarding_inicio", chat_id=chat_id)
async def iniciar_salir(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Llamado desde cmd_salir."""
chat_id = update.effective_chat.id # type: ignore[union-attr]
self._states[chat_id] = _ESPERANDO_SALIR
texto = (
"¿Estás seguro que querés borrar tus datos?\n"
"Esto eliminará tus credenciales y preferencias del sistema\\."
)
keyboard = InlineKeyboardMarkup([
[
InlineKeyboardButton("✅ Sí, borrar todo", callback_data="onb:salir:si"),
InlineKeyboardButton("❌ Cancelar", callback_data="onb:salir:no"),
]
])
await update.effective_message.reply_text( # type: ignore[union-attr]
texto, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard
)
# ──────────────────────────────────────────────────────────────────────────
# Handlers de entrada (llamados desde telegram_bot.py)
# ──────────────────────────────────────────────────────────────────────────
async def handle_mensaje(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Procesa texto libre durante el onboarding."""
chat_id = update.effective_chat.id # type: ignore[union-attr]
estado = self._states.get(chat_id)
if not estado:
return
texto_msg = (update.effective_message.text or "").strip() # type: ignore[union-attr]
if estado == _ESPERANDO_NOMBRE:
await self._paso_nombre(chat_id, texto_msg, update)
elif estado == _ESPERANDO_TZ_CUSTOM:
await self._paso_tz_custom(chat_id, texto_msg, update)
elif estado == _ESPERANDO_USUARIO:
await self._paso_usuario(chat_id, texto_msg, update)
elif estado == _ESPERANDO_PASSWORD:
await self._paso_password(chat_id, texto_msg, update, context)
async def handle_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Procesa botones de onboarding (callback_data = 'onb:accion:valor')."""
query = update.callback_query
await query.answer() # type: ignore[union-attr]
chat_id = update.effective_chat.id # type: ignore[union-attr]
parts = (query.data or "").split(":") # type: ignore[union-attr]
if len(parts) < 3:
return
accion, valor = parts[1], parts[2]
if accion == "consent":
await self._paso_consentimiento(chat_id, valor, update)
elif accion == "tono":
await self._paso_tono(chat_id, valor, update)
elif accion == "tz":
await self._paso_tz(chat_id, valor, update)
elif accion == "reintentar":
await self._pedir_usuario(chat_id, update)
elif accion == "cancelar_onb":
self._clear(chat_id)
await update.effective_message.reply_text( # type: ignore[union-attr]
"Onboarding cancelado\\. Escribí /start cuando quieras intentarlo de nuevo\\.",
parse_mode=ParseMode.MARKDOWN_V2,
)
elif accion == "salir":
await self._procesar_salir(chat_id, valor, update, context)
# ──────────────────────────────────────────────────────────────────────────
# Pasos del flujo
# ──────────────────────────────────────────────────────────────────────────
async def _paso_consentimiento(self, chat_id: int, valor: str, update: Update) -> None:
if valor == "no":
self._clear(chat_id)
await audit.log("onboarding_consentimiento_rechazado", chat_id=chat_id)
await update.effective_message.reply_text( # type: ignore[union-attr]
"Entendido\\. Escribí /start cuando quieras empezar\\.",
parse_mode=ParseMode.MARKDOWN_V2,
)
return
await audit.log("onboarding_consentimiento_aceptado", chat_id=chat_id)
self._states[chat_id] = _ESPERANDO_NOMBRE
await update.effective_message.reply_text( # type: ignore[union-attr]
"¿Cómo querés que te llame?\n\\(Escribí tu nombre o apodo\\)",
parse_mode=ParseMode.MARKDOWN_V2,
)
async def _paso_nombre(self, chat_id: int, nombre: str, update: Update) -> None:
if not nombre:
await update.effective_message.reply_text( # type: ignore[union-attr]
"Por favor escribí tu nombre o apodo\\.",
parse_mode=ParseMode.MARKDOWN_V2,
)
return
nombre = nombre[:50]
self._temp[chat_id]["nombre_preferido"] = nombre
self._states[chat_id] = _ESPERANDO_TONO
await audit.log("onboarding_nombre_registrado", chat_id=chat_id)
nombre_esc = _esc(nombre)
texto = f"¿Cómo preferís que me comunique con vos, {nombre_esc}?"
keyboard = InlineKeyboardMarkup([
[
InlineKeyboardButton("📜 Formal", callback_data="onb:tono:formal"),
InlineKeyboardButton("😊 Cotidiano", callback_data="onb:tono:cotidiano"),
InlineKeyboardButton("🤙 Informal", callback_data="onb:tono:informal"),
]
])
await update.effective_message.reply_text( # type: ignore[union-attr]
texto, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard
)
async def _paso_tono(self, chat_id: int, tono: str, update: Update) -> None:
if tono not in ("formal", "cotidiano", "informal"):
return
self._temp[chat_id]["tono"] = tono
self._states[chat_id] = _ESPERANDO_TZ
await audit.log("onboarding_tono_seleccionado", chat_id=chat_id, detalle={"tono": tono})
texto = (
"¿Desde dónde vas a usar el bot?\n"
"\\(Para mostrarte las horas correctas\\)"
)
keyboard = InlineKeyboardMarkup([
[InlineKeyboardButton("🇵🇾 Paraguay (Asunción)", callback_data="onb:tz:py")],
[InlineKeyboardButton("🇦🇷 Argentina (Buenos Aires)", callback_data="onb:tz:ar")],
[InlineKeyboardButton("🇺🇾 Uruguay (Montevideo)", callback_data="onb:tz:uy")],
[InlineKeyboardButton("🌐 Otra zona horaria", callback_data="onb:tz:otra")],
])
await update.effective_message.reply_text( # type: ignore[union-attr]
texto, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard
)
async def _paso_tz(self, chat_id: int, valor: str, update: Update) -> None:
if valor == "otra":
self._states[chat_id] = _ESPERANDO_TZ_CUSTOM
await update.effective_message.reply_text( # type: ignore[union-attr]
"Escribí el nombre de tu zona horaria\\.\n"
"Ejemplos: `America/Lima`, `America/Bogota`, `Europe/Madrid`",
parse_mode=ParseMode.MARKDOWN_V2,
)
return
tz = _TZ_MAP.get(valor, "America/Asuncion")
self._temp[chat_id]["timezone"] = tz
await audit.log("onboarding_timezone_seleccionada", chat_id=chat_id, detalle={"tz": tz})
await self._pedir_usuario(chat_id, update)
async def _paso_tz_custom(self, chat_id: int, tz_str: str, update: Update) -> None:
try:
ZoneInfo(tz_str)
except (ZoneInfoNotFoundError, KeyError):
await update.effective_message.reply_text( # type: ignore[union-attr]
f"No reconozco la zona \"{_esc(tz_str)}\"\\.\n"
"Probá con algo como `America/Lima` o `America/Bogota`\\.",
parse_mode=ParseMode.MARKDOWN_V2,
)
return
self._temp[chat_id]["timezone"] = tz_str
await audit.log("onboarding_timezone_seleccionada", chat_id=chat_id, detalle={"tz": tz_str})
await self._pedir_usuario(chat_id, update)
async def _pedir_usuario(self, chat_id: int, update: Update) -> None:
self._states[chat_id] = _ESPERANDO_USUARIO
await update.effective_message.reply_text( # type: ignore[union-attr]
"Ahora necesito tus credenciales del sistema judicial "
"\\(apps\\.csj\\.gov\\.py\\)\\.\n\n"
"¿Cuál es tu número de cédula de identidad?\n"
"\\(Es el usuario que usás para entrar al sistema\\)",
parse_mode=ParseMode.MARKDOWN_V2,
)
async def _paso_usuario(self, chat_id: int, texto: str, update: Update) -> None:
cedula = texto.strip().replace(".", "").replace("-", "").replace(" ", "")
if not cedula.isdigit():
await update.effective_message.reply_text( # type: ignore[union-attr]
"La cédula debe contener solo dígitos\\. Intentá de nuevo\\.",
parse_mode=ParseMode.MARKDOWN_V2,
)
return
self._temp[chat_id]["usuario"] = cedula
self._states[chat_id] = _ESPERANDO_PASSWORD
await update.effective_message.reply_text( # type: ignore[union-attr]
"¿Y tu contraseña del sistema judicial?\n\n"
"⚠️ Voy a borrar este mensaje apenas lo procese\\. "
"No lo reenvíes a nadie\\.",
parse_mode=ParseMode.MARKDOWN_V2,
)
async def _paso_password(
self,
chat_id: int,
password: str,
update: Update,
context: ContextTypes.DEFAULT_TYPE,
) -> None:
# Borrar el mensaje del usuario inmediatamente
try:
await context.bot.delete_message(
chat_id=chat_id,
message_id=update.effective_message.message_id, # type: ignore[union-attr]
)
except Exception:
pass # puede fallar si el bot no tiene permisos de borrado en el chat
usuario = self._temp[chat_id].get("usuario", "")
if not usuario:
await context.bot.send_message(
chat_id=chat_id,
text="Error interno\\. Escribí /start para reiniciar\\.",
parse_mode=ParseMode.MARKDOWN_V2,
)
self._clear(chat_id)
return
await context.bot.send_message(chat_id=chat_id, text="⏳ Verificando tus credenciales...")
await audit.log("onboarding_credenciales_verificando", chat_id=chat_id)
credentials_enc: str | None = None
error_tipo: str | None = None
try:
async with CSJClient(self._settings, usuario=usuario, password=password, chat_id=chat_id) as client:
await client.login()
# Login exitoso — cifrar antes de borrar password
credentials_enc = vault_mod.cifrar_credenciales(
chat_id=chat_id,
usuario=usuario,
password=password,
fernet_key=self._settings.fernet_key,
)
except CSJPasswordTemporalError:
error_tipo = "temporal"
except CSJAuthError:
error_tipo = "credenciales"
except CSJConnectionError:
error_tipo = "conexion"
finally:
# Limpiar password de memoria lo antes posible
password = "" # noqa: F841
del password
if credentials_enc is not None:
await audit.log("onboarding_credenciales_ok", chat_id=chat_id)
await save_tenant(
chat_id,
nombre_preferido=self._temp[chat_id].get("nombre_preferido", ""),
tono=self._temp[chat_id].get("tono", "cotidiano"),
timezone=self._temp[chat_id].get("timezone", "America/Asuncion"),
credentials_enc=credentials_enc,
estado="activo",
consentimiento_aceptado=1,
)
await self._completado(chat_id, context)
else:
await audit.log("onboarding_credenciales_error", chat_id=chat_id, resultado="error", detalle={"tipo": error_tipo})
await self._error_credenciales(chat_id, error_tipo, context)
async def _completado(self, chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> None:
nombre = self._temp[chat_id].get("nombre_preferido", "")
tz_str = self._temp[chat_id].get("timezone", "America/Asuncion")
ciudad = {
"America/Asuncion": "Paraguay",
"America/Argentina/Buenos_Aires": "Argentina",
"America/Montevideo": "Uruguay",
}.get(tz_str, tz_str)
self._clear(chat_id)
texto = (
f"✅ ¡Todo listo, {_esc(nombre)}\\!\n\n"
f"Estás conectado al sistema del Poder Judicial\\.\n"
f"Voy a revisar tus notificaciones cada hora en horario hábil\n"
f"\\(lunes a viernes, 7:00\\-18:00 hora de {_esc(ciudad)}\\)\\.\n\n"
f"Escribí /notif para revisar ahora mismo\\."
)
await context.bot.send_message(
chat_id=chat_id,
text=texto,
parse_mode=ParseMode.MARKDOWN_V2,
)
logger.info("onboarding_completado", chat_id=chat_id)
await audit.log("onboarding_completado", chat_id=chat_id)
async def _error_credenciales(
self, chat_id: int, error_tipo: str | None, context: ContextTypes.DEFAULT_TYPE
) -> None:
self._attempts[chat_id] = self._attempts.get(chat_id, 0) + 1
if error_tipo == "temporal":
self._clear(chat_id)
await context.bot.send_message(
chat_id=chat_id,
text=(
"⚠️ Tu contraseña del sistema judicial es temporal\\. "
"Cambiala en apps\\.csj\\.gov\\.py y luego escribí /start de nuevo\\."
),
parse_mode=ParseMode.MARKDOWN_V2,
)
return
if self._attempts[chat_id] >= _MAX_INTENTOS:
self._clear(chat_id)
await context.bot.send_message(
chat_id=chat_id,
text=(
"❌ Demasiados intentos fallidos\\. "
"Escribí /start cuando quieras intentarlo de nuevo\\."
),
parse_mode=ParseMode.MARKDOWN_V2,
)
return
if error_tipo == "conexion":
detalle = "El sistema del PJ parece estar temporalmente caído\\."
else:
detalle = "El usuario o la contraseña no son correctos\\."
texto = (
f"❌ No pude conectarme con esas credenciales\\.\n\n"
f"{detalle}\n\n"
"¿Querés intentar de nuevo?"
)
keyboard = InlineKeyboardMarkup([
[
InlineKeyboardButton("🔄 Intentar de nuevo", callback_data="onb:reintentar:si"),
InlineKeyboardButton("❌ Cancelar", callback_data="onb:cancelar_onb:si"),
]
])
await context.bot.send_message(
chat_id=chat_id,
text=texto,
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=keyboard,
)
async def _procesar_salir(
self,
chat_id: int,
valor: str,
update: Update,
context: ContextTypes.DEFAULT_TYPE,
) -> None:
if valor == "no":
self._clear(chat_id)
await update.effective_message.reply_text("Operación cancelada\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr]
return
await delete_tenant(chat_id)
snapshot_path = Path(self._settings.data_dir) / f"snapshot_{chat_id}.json"
if snapshot_path.exists():
snapshot_path.unlink()
self._clear(chat_id)
await update.effective_message.reply_text( # type: ignore[union-attr]
"✅ Tus datos fueron eliminados\\. "
"Escribí /start si querés registrarte de nuevo\\.",
parse_mode=ParseMode.MARKDOWN_V2,
)
logger.info("tenant_dado_de_baja", chat_id=chat_id)
await audit.log("tenant_eliminado", chat_id=chat_id, detalle={"origen": "salir"})

216
poller.py Normal file
View File

@@ -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")

45
pyproject.toml Normal file
View File

@@ -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"]

View File

@@ -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()

76
storage.py Normal file
View File

@@ -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)

825
telegram_bot.py Normal file
View File

@@ -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 <número>, /exp <número>/<año>, o /exp <texto de carátula>\n"
"Ejemplo: /exp 198/2026"
)
return
arg = " ".join(args)
parsed = parsear_argumento_exp(arg)
await update.effective_message.reply_text("⏳ Buscando expediente...") # type: ignore[union-attr]
try:
client = self._csj_client_for(tenant)
except ValueError as e:
logger.error("error_descifrar_credenciales_cmd_exp", chat_id=chat_id, error=str(e))
await update.effective_message.reply_text("❌ Error interno con tus credenciales\\. Escribí /salir y volvé a registrarte\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr]
return
async with client:
try:
casos, total = await client.buscar_expedientes(
caratula=parsed.get("caratula"),
numero=parsed.get("numero"),
anio=parsed.get("anio"),
cantidad=5,
)
except CSJConnectionError as e:
logger.error("error_buscar_expedientes", chat_id=chat_id, error=str(e))
await update.effective_message.reply_text("❌ No pude conectarme al sistema judicial\\. Intentá de nuevo en un momento\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr]
return
except CSJAuthError as e:
logger.error("error_auth_buscar_expedientes", chat_id=chat_id, error=str(e))
await update.effective_message.reply_text("❌ Error de autenticación con el PJ\\. Revisá tus credenciales con /salir y /start\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr]
return
if not casos:
busqueda = _label_busqueda(parsed)
await update.effective_message.reply_text( # type: ignore[union-attr]
f"No encontré expedientes para \"{busqueda}\".\n"
"Probá con la carátula o con número/año."
)
return
if len(casos) == 1:
caso = casos[0]
actuaciones: list[ActuacionCaso] = []
total_actuaciones = 0
try:
actuaciones, total_actuaciones = await client.listar_actuaciones(
cod_caso_judicial=caso.cod_caso_judicial,
origen=caso.origen,
cantidad=3,
)
except Exception as e:
logger.warning("error_listar_actuaciones_degradado", chat_id=chat_id, error=str(e))
texto = _formatear_detalle_caso(caso, actuaciones, total_actuaciones)
await update.effective_message.reply_text(texto, parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr]
else:
busqueda = _label_busqueda(parsed)
texto = _formatear_lista_casos(casos, total, busqueda)
await update.effective_message.reply_text(texto, parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr]
async def cmd_pdf(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
chat_id = update.effective_chat.id # type: ignore[union-attr]
await audit.log("comando_recibido", chat_id=chat_id, detalle={"comando": "pdf"})
tenant = await self._get_tenant_activo(chat_id)
if not tenant:
await self._rechazar_no_registrado(update)
return
args = context.args
if not args:
await update.effective_message.reply_text( # type: ignore[union-attr]
"Uso: /pdf <número>/<año> o /pdf <número>/<año> <posición>\n"
"Ejemplo: /pdf 198/2026 o /pdf 198/2026 2 (segunda más reciente)"
)
return
partes = " ".join(args).strip().split()
exp_arg = partes[0]
posicion = 1
if len(partes) > 1:
try:
posicion = max(1, int(partes[1]))
except ValueError:
pass
parsed = parsear_argumento_exp(exp_arg)
if "numero" not in parsed or "anio" not in parsed:
await update.effective_message.reply_text( # type: ignore[union-attr]
"Para /pdf necesitás especificar número Y año.\nEjemplo: /pdf 198/2026"
)
return
await update.effective_message.reply_text("⏳ Buscando expediente...") # type: ignore[union-attr]
try:
client = self._csj_client_for(tenant)
except ValueError as e:
logger.error("error_descifrar_credenciales_cmd_pdf", chat_id=chat_id, error=str(e))
await update.effective_message.reply_text("❌ Error interno con tus credenciales\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr]
return
async with client:
try:
casos, _ = await client.buscar_expedientes(
numero=parsed["numero"],
anio=parsed["anio"],
cantidad=1,
)
except (CSJConnectionError, CSJAuthError) as e:
logger.error("error_buscar_expediente_pdf", chat_id=chat_id, error=str(e))
await update.effective_message.reply_text("❌ No pude conectarme al sistema judicial\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr]
return
if not casos:
await update.effective_message.reply_text( # type: ignore[union-attr]
f"No encontré el expediente {parsed['numero']}/{parsed['anio']}."
)
return
caso = casos[0]
try:
actuaciones, _ = await client.listar_actuaciones(
cod_caso_judicial=caso.cod_caso_judicial,
origen=caso.origen,
cantidad=max(posicion, 10),
)
except (CSJConnectionError, CSJAuthError) as e:
logger.error("error_listar_actuaciones_pdf", chat_id=chat_id, error=str(e))
await update.effective_message.reply_text("❌ No pude obtener las actuaciones del expediente\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr]
return
if not actuaciones:
await update.effective_message.reply_text("Este expediente no tiene actuaciones registradas\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr]
return
if posicion > len(actuaciones):
await update.effective_message.reply_text( # type: ignore[union-attr]
f"El expediente solo tiene {len(actuaciones)} actuaciones disponibles.\n"
f"Usá un número entre 1 y {len(actuaciones)}."
)
return
actuacion = actuaciones[posicion - 1]
origen_actuacion = actuacion.origen if actuacion.origen is not None else caso.origen
try:
pdf_bytes = await client.descargar_documento_principal(
cod_caso_judicial=caso.cod_caso_judicial,
origen_caso=caso.origen,
cod_actuacion_caso=actuacion.cod_actuacion_caso,
origen_actuacion=origen_actuacion,
)
except CSJDocumentoNoDisponibleError:
await update.effective_message.reply_text("Esta actuación no tiene documento adjunto disponible\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr]
return
except (CSJConnectionError, CSJAuthError) as e:
logger.error("error_descargando_pdf_cmd", chat_id=chat_id, error=str(e))
await update.effective_message.reply_text("❌ No pude descargar el documento\\.", parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr]
return
tipo = actuacion.tipo
fecha = actuacion.fecha_formateada
despacho = actuacion.descripcion_despacho or caso.descripcion_despacho_judicial or ""
safe_tipo = re.sub(r"[^\w]", "_", tipo)[:30]
filename = f"{safe_tipo}_{caso.nro_expediente_numero}_{caso.nro_expediente_anio}_{fecha.replace('/', '-')}.pdf"
caption = f"📎 {tipo}\nExp. {caso.expediente_str}{fecha}"
if despacho:
caption += f"\n{despacho}"
await update.effective_message.reply_document( # type: ignore[union-attr]
document=pdf_bytes,
filename=filename,
caption=caption,
)
await audit.log("bot_pdf_enviado", chat_id=chat_id, detalle={"cod_caso": caso.cod_caso_judicial, "cod_actuacion": actuacion.cod_actuacion_caso}, tenant=tenant)
# ──────────────────────────────────────────────────────────────────────────
# Handler de texto libre (para onboarding)
# ──────────────────────────────────────────────────────────────────────────
async def handle_mensaje(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Procesa texto libre — redirige al onboarding si el usuario está en ese flujo."""
chat_id = update.effective_chat.id # type: ignore[union-attr]
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

66
utils/sanitize.py Normal file
View File

@@ -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

766
uv.lock generated Normal file
View File

@@ -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" },
]

55
vault.py Normal file
View File

@@ -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