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