diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3ca9987 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.github +.env +.venv +venv +__pycache__ +.pytest_cache +.ruff_cache +data +*.db +*.db-shm +*.db-wal +*.log +tests +README.md +docs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9e6c0b3 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Discord Developer Portal -> Bot -> Token +DISCORD_TOKEN=replace-me + +# Opcional: guild de desarrollo para sincronizar slash commands inmediatamente. +# Si queda vacío, los comandos se sincronizan globalmente. +DEV_GUILD_ID= + +# Persistencia local de configuración y recordatorios. +DATABASE_PATH=data/pybot.db + +# INFO, DEBUG, WARNING, ERROR +LOG_LEVEL=INFO + +# true para sincronizar comandos al iniciar. +SYNC_COMMANDS=true + +# No es necesario para los slash commands actuales. +# Activarlo exige habilitar Message Content Intent en Discord Developer Portal. +ENABLE_MESSAGE_CONTENT_INTENT=false + +BOT_STATUS=Usá /ayuda +CURRENCY_API_BASE_URL=https://dolarapi.com/v1 +HTTP_TIMEOUT_SECONDS=8 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fc0e73a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +jobs: + quality: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: | + requirements.txt + requirements-dev.txt + + - name: Install dependencies + run: python -m pip install --upgrade pip && pip install -r requirements-dev.txt + + - name: Ruff + run: ruff check . + + - name: Compile + run: python -m compileall -q main.py pybot + + - name: Tests + run: pytest -q diff --git a/.gitignore b/.gitignore index 752d0a1..de0c0a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,41 @@ -nuevaenv/* -myenv/* -/nuevaenv/* -/myenv/* +# Environment and secrets .env -docker-compose.yml -/docker-compose.yml -Dockerfile \ No newline at end of file +.env.* +!.env.example + +# Virtual environments +.venv/ +venv/ +env/ +myenv/ +nuevaenv/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +htmlcov/ + +# Local data / persistence +data/ +*.db +*.db-shm +*.db-wal + +# IDE / OS +.vscode/ +.idea/ +.DS_Store +Thumbs.db + +# Logs +*.log + +# Build artifacts +build/ +dist/ +*.egg-info/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d9b254d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +Todos los cambios relevantes de PyBot se documentan en este archivo. + +El versionado sigue [Semantic Versioning](https://semver.org/). + +## [2.0.0] — 2026-08-23 + +### Added + +- arquitectura modular basada en Cogs; +- slash commands como interfaz principal; +- configuración tipada por variables de entorno; +- persistencia SQLite con `aiosqlite`; +- configuración por servidor para bienvenida, despedida, mod-log y autorol; +- self roles con allowlist administrativa; +- recordatorios persistentes; +- moderación con timeout, untimeout, kick, ban, purge y slowmode; +- validaciones de permisos y jerarquías; +- auditoría de acciones de moderación; +- consulta asíncrona de cotizaciones mediante DolarAPI; +- encuestas, elección aleatoria, dados, moneda y Google Maps; +- error handler global para application commands; +- una única `aiohttp.ClientSession` administrada por lifecycle; +- Dockerfile; +- CI para Python 3.11 y 3.12; +- Ruff, compile checks y pytest; +- tests de configuración, parsing de duración y persistencia; +- documentación de arquitectura, comandos, despliegue y seguridad; +- `CONTRIBUTING.md` y `.env.example`. + +### Changed + +- `discord.py` actualizado de 2.3.2 a 2.7.1; +- entrypoint movido a `main.py`; +- proceso Heroku/Procfile cambiado de `web` a `worker`; +- el bot deja de depender del parsing de mensajes libres para sus funciones centrales; +- `Message Content Intent` pasa a ser opcional y deshabilitado por defecto; +- la consulta de dólar deja de usar `requests` síncrono; +- roles por reacción reemplazados por self roles explícitos y seguros; +- README reescrito como guía completa de producto e ingeniería. + +### Removed + +- implementación monolítica antigua de `bot.py`; +- `events.py` legacy; +- `wsgi.py` obsoleto; +- bytecode `__pycache__` versionado; +- dependencias antiguas/no utilizadas como `requests`, `youtube-dl` y paquetes transitivos fijados manualmente. + +### Security + +- token exclusivamente desde entorno; +- `.env` ignorado; +- permisos mínimos documentados; +- controles de jerarquía para moderación y roles; +- Message Content Intent no requerido en operación estándar. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4185c6a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,77 @@ +# Contribuir a PyBot + +## Flujo recomendado + +1. Crear una rama desde `main`. +2. Mantener cada cambio enfocado en un dominio. +3. Agregar o actualizar tests cuando corresponda. +4. Ejecutar lint y tests localmente. +5. Abrir PR explicando problema, solución y validación. + +## Setup + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements-dev.txt +cp .env.example .env +``` + +En Windows PowerShell: + +```powershell +.\.venv\Scripts\Activate.ps1 +Copy-Item .env.example .env +``` + +## Calidad + +Antes del PR: + +```bash +ruff check . +python -m compileall -q main.py pybot +pytest -q +``` + +## Arquitectura + +- Los comandos deben vivir en el Cog del dominio correspondiente. +- No crear una `ClientSession` por request/comando. +- No usar librerías HTTP síncronas dentro de coroutines. +- El acceso SQLite debe pasar por `Database`. +- No leer variables de entorno directamente desde Cogs; usar `Settings`. +- Las acciones destructivas deben validar permisos y jerarquía. +- Evitar habilitar intents privilegiados si una feature no los necesita. + +## Nuevos comandos + +Al agregar un comando: + +- usar slash commands; +- escribir `description` clara; +- usar tipos de Discord cuando corresponda (`Member`, `Role`, `TextChannel`); +- aplicar rangos con `app_commands.Range`; +- aplicar checks de permisos; +- responder `ephemeral=True` cuando la información sea administrativa o sensible; +- documentarlo en `docs/COMMANDS.md`. + +## Persistencia + +Los cambios de esquema deben ser compatibles con instalaciones existentes. `CREATE TABLE IF NOT EXISTS` es suficiente para tablas nuevas, pero cambios de columnas deben tratarse como migraciones explícitas en futuras versiones. + +## Commits + +Se recomienda Conventional Commits: + +```text +feat: add ticket system +fix: prevent role hierarchy escalation +docs: explain production deployment +test: cover reminder parser +refactor: isolate external API client +``` + +## Seguridad + +No incluir tokens reales, IDs privados innecesarios, `.env`, databases ni dumps de servidores en commits o fixtures. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..57b32bd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +COPY requirements.txt . +RUN python -m pip install --upgrade pip && pip install -r requirements.txt + +COPY . . +RUN mkdir -p /app/data + +CMD ["python", "main.py"] diff --git a/LICENCE b/LICENSE similarity index 99% rename from LICENCE rename to LICENSE index e34f770..51590f3 100644 --- a/LICENCE +++ b/LICENSE @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/Procfile b/Procfile index ffe297a..25fcdc8 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: python3.9 bot.py \ No newline at end of file +worker: python main.py diff --git a/README.md b/README.md index 75946b9..6277a41 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,506 @@ -# Bienvenidos al Bot de Discord de ArmoTuSitio +
-Este bot ha sido creado con un propósito muy claro: dar forma y enriquecer la experiencia en nuestro querido grupo, ArmoTuSitio. Sin embargo, al igual que una pitón siempre hambrienta de conocimiento, está ansioso por recibir retroalimentación y nuevas funcionalidades que lo ayuden a crecer y convertirse en una herramienta aún más poderosa. +# 🐍 PyBot — Professional Discord Community Bot -## ¿Por qué deberías considerar alimentar a nuestra pitón? +**Bot modular para Discord desarrollado en Python, orientado a comunidades, moderación y utilidades reales.** -1. **Adaptabilidad sin límites**: Nuestro bot es como una pitón, flexible y listo para adaptarse a tus necesidades. Si tienes ideas para mejorarlo o deseas nuevas funciones, estaremos encantados de escuchar tus sugerencias. +![Python](https://img.shields.io/badge/Python-3.11%2B-3776AB?logo=python&logoColor=white) +![discord.py](https://img.shields.io/badge/discord.py-2.7.1-5865F2?logo=discord&logoColor=white) +![SQLite](https://img.shields.io/badge/SQLite-persistence-003B57?logo=sqlite&logoColor=white) +![License](https://img.shields.io/badge/license-MIT-green) -2. **Siempre en evolución**: No te conformes con una solución estática. Nuestra pitón está en constante evolución, gracias a tu contribución y la de otros miembros de la comunidad. Juntos, creamos un entorno dinámico. +**Autor:** [Alejandro Daniel Di Stefano](https://github.com/Drako01) -3. **Comunidad colaborativa**: Pythonisos es más que un grupo; es una comunidad que se ayuda mutuamente. Contribuir a nuestro bot no solo lo enriquece, sino que también fortalece nuestra comunidad y tu papel en ella. +
-4. **Facilitador de experiencias**: Nuestro bot está aquí para hacer que tu experiencia en Pythonisos sea más fluida y enriquecedora. Cuantas más funciones le agreguemos, mejor será su capacidad para servirte. +--- -¿Qué estás esperando? Alimenta a la pitón, comparte tus ideas, sugerencias y necesidades, y juntos haremos de Pythonisos un lugar aún más increíble. +## ¿Qué es PyBot? -¡Únete a la comunidad colaborativa y haz crecer nuestro bot de Discord de ArmoTuSitio! +PyBot es un bot de Discord construido como un proyecto de software mantenible, no como una colección de handlers en un único archivo. - *(¡Una pitón bien alimentada es una pitón feliz!)* +La versión 2.0 reorganiza completamente el proyecto alrededor de: +- **slash commands** nativos de Discord; +- arquitectura modular mediante **Cogs**; +- persistencia local con **SQLite**; +- llamadas HTTP **asíncronas**; +- configuración por servidor; +- moderación con validaciones de permisos y jerarquía; +- recordatorios persistentes; +- roles autoasignables controlados; +- CI, tests y linting; +- configuración segura mediante variables de entorno; +- documentación de arquitectura, comandos y despliegue. ----- +La implementación está pensada para ser útil en un servidor real y, al mismo tiempo, funcionar como material educativo sobre desarrollo profesional de bots con Python y `discord.py`. +--- -[Canal de Discord](https://discord.gg/NdE3jYnf) +## Funcionalidades -## Para iniciar: +### 🧭 General + +- `/ayuda`: centro de ayuda del bot. +- `/ping`: latencia actual. +- `/about`: versión, framework y autor. +- `/serverinfo`: información del servidor. +- `/userinfo`: información de un miembro. +- `/avatar`: avatar en alta resolución. + +### 🛠️ Utilidades + +- `/dolar`: cotización de dólar oficial, blue, MEP, CCL, tarjeta, mayorista o cripto. +- `/encuesta`: encuesta con hasta cinco opciones. +- `/elegir`: selección aleatoria entre alternativas. +- `/dado`: dados configurables. +- `/moneda`: cara o cruz. +- `/mapa`: enlace seguro de búsqueda en Google Maps. + +La consulta del dólar se realiza de manera asíncrona contra [DolarAPI.com](https://dolarapi.com/), evitando bloquear el event loop de Discord. + +### ⏰ Recordatorios persistentes + +- `/recordar tiempo mensaje` +- `/recordatorios` +- `/borrar-recordatorio id` + +Ejemplos de duración: + +```text +30s +15m +2h +3d +1w +``` + +Los recordatorios se guardan en SQLite, por lo que sobreviven a reinicios del proceso. + +### 👋 Comunidad + +- bienvenida configurable; +- despedida configurable; +- autorol para nuevos miembros; +- roles autoasignables seguros; +- mod-log configurable. + +Administración: + +```text +/config-ver +/config-bienvenida +/config-despedida +/config-modlog +/config-autorol +/config-rol-agregar +/config-rol-quitar +``` + +Miembros: + +```text +/roles +/rol ``` + +A diferencia del comportamiento histórico basado en reaccionar a cualquier emoji cuyo nombre coincidiera con un rol, la versión actual usa una **allowlist persistente de roles autoasignables**. Esto evita que un usuario pueda obtener accidentalmente un rol sensible. + +### 🛡️ Moderación + +- `/limpiar` +- `/timeout` +- `/untimeout` +- `/expulsar` +- `/banear` +- `/slowmode` + +Los comandos verifican: + +- permisos del moderador; +- permisos del bot; +- jerarquía de roles; +- owner del servidor; +- imposibilidad de automoderarse; +- límites propios de Discord. + +Si existe un canal de `mod-log`, las acciones relevantes quedan auditadas allí. + +--- + +## Arquitectura + +```text +Discord Gateway / Interactions + │ + ▼ +┌──────────────────────────────┐ +│ PyBot Core │ +│ commands.Bot · CommandTree │ +│ intents · lifecycle · errors │ +└──────────────┬───────────────┘ + │ + ┌───────┴─────────────────────────┐ + ▼ ▼ +┌─────────────────────┐ ┌─────────────────────┐ +│ Cogs │ │ Services │ +│ core │ │ aiohttp session │ +│ community │ │ SQLite repository │ +│ moderation │ │ external APIs │ +│ utilities │ └──────────┬──────────┘ +│ reminders │ │ +└─────────────────────┘ ▼ + ┌───────────────┐ + │ SQLite │ + │ settings │ + │ reminders │ + │ self roles │ + └───────────────┘ +``` + +Más detalle en [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). + +--- + +## Estructura del repositorio + +```text +Pybot/ +├── .github/ +│ └── workflows/ +│ └── ci.yml +├── docs/ +│ ├── ARCHITECTURE.md +│ ├── COMMANDS.md +│ └── DEPLOYMENT.md +├── pybot/ +│ ├── cogs/ +│ │ ├── core.py +│ │ ├── community.py +│ │ ├── moderation.py +│ │ ├── reminders.py +│ │ └── utilities.py +│ ├── utils/ +│ │ └── timeparse.py +│ ├── bot.py +│ ├── config.py +│ ├── database.py +│ └── logging_config.py +├── tests/ +├── .env.example +├── Dockerfile +├── main.py +├── pyproject.toml +├── requirements.txt +└── requirements-dev.txt +``` + +--- + +## Requisitos + +- Python **3.11 o superior**; +- una aplicación creada en Discord Developer Portal; +- token del bot; +- acceso para invitar el bot al servidor. + +La versión de referencia usa `discord.py 2.7.1`. + +--- + +## Crear el bot en Discord Developer Portal + +1. Ir a **Discord Developer Portal**. +2. Crear una **New Application**. +3. Entrar a **Bot** y crear/configurar el bot. +4. Regenerar/copiar el token y guardarlo únicamente en `.env`. +5. Habilitar **Server Members Intent**. +6. `Message Content Intent` puede permanecer deshabilitado con la configuración estándar de PyBot. +7. En **OAuth2 → URL Generator**, seleccionar: + - `bot` + - `applications.commands` +8. Seleccionar sólo los permisos que realmente utilizará el bot. + +### Permisos básicos recomendados + +- View Channels +- Send Messages +- Embed Links +- Add Reactions +- Read Message History + +### Para moderación + +Agregar, según las funciones deseadas: + +- Manage Messages +- Moderate Members +- Kick Members +- Ban Members +- Manage Channels +- Manage Roles + +> No es recomendable conceder `Administrator` sólo para simplificar la configuración. + +--- + +## Instalación local + +### 1. Clonar + +```bash +git clone https://github.com/Drako01/Pybot.git +cd Pybot +``` + +### 2. Crear virtualenv + +Linux/macOS: + +```bash +python -m venv .venv +source .venv/bin/activate +``` + +Windows PowerShell: + +```powershell +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +``` + +### 3. Instalar dependencias + +```bash +python -m pip install --upgrade pip pip install -r requirements.txt ``` + +### 4. Configurar entorno + +```bash +cp .env.example .env +``` + +En Windows podés copiarlo manualmente o usar: + +```powershell +Copy-Item .env.example .env +``` + +Editar `.env`: + +```dotenv +DISCORD_TOKEN=tu-token-real +DEV_GUILD_ID= +DATABASE_PATH=data/pybot.db +LOG_LEVEL=INFO +SYNC_COMMANDS=true +ENABLE_MESSAGE_CONTENT_INTENT=false +BOT_STATUS=Usá /ayuda +CURRENCY_API_BASE_URL=https://dolarapi.com/v1 +HTTP_TIMEOUT_SECONDS=8 +``` + +### 5. Ejecutar + +```bash +python main.py +``` + +--- + +## `DEV_GUILD_ID` y sincronización de comandos + +Los slash commands globales pueden tardar en propagarse por Discord. + +Durante desarrollo es conveniente establecer: + +```dotenv +DEV_GUILD_ID=123456789012345678 +``` + +En ese caso PyBot copia y sincroniza los comandos directamente al servidor de desarrollo, haciendo que los cambios aparezcan mucho más rápido. + +Para producción puede dejarse vacío y usar sincronización global. + +--- + +## Persistencia + +SQLite se inicializa automáticamente en: + +```text +data/pybot.db +``` + +Se almacenan: + +- configuración por guild; +- canales de bienvenida/despedida/mod-log; +- autorol; +- roles autoasignables; +- recordatorios pendientes. + +La carpeta `data/` está ignorada por Git. + +En Docker o un proveedor cloud se debe montar como volumen persistente si se quiere conservar la información entre recreaciones del contenedor. + +--- + +## Desarrollo + +Instalar tooling: + +```bash +pip install -r requirements-dev.txt +``` + +Lint: + +```bash +ruff check . +``` + +Compilación estática básica: + +```bash +python -m compileall -q main.py pybot +``` + +Tests: + +```bash +pytest -q +``` + +--- + +## CI + +GitHub Actions ejecuta para Python 3.11 y 3.12: + +```text +install dependencies + ↓ +ruff check + ↓ +compileall + ↓ +pytest +``` + +El objetivo es evitar que cambios en un Cog, configuración o persistencia lleguen a `main` sin validación automática. + +--- + +## Docker + +Construir: + +```bash +docker build -t pybot . +``` + +Ejecutar: + +```bash +docker run --rm \ + --env-file .env \ + -v "$(pwd)/data:/app/data" \ + pybot +``` + +En Windows PowerShell: + +```powershell +docker run --rm --env-file .env -v "${PWD}/data:/app/data" pybot +``` + +Más opciones en [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md). + +--- + +## Seguridad + +Nunca subir al repositorio: + +- `DISCORD_TOKEN`; +- archivos `.env` reales; +- secretos de APIs; +- bases SQLite de producción; +- logs con información sensible. + +Si un token de Discord se publica accidentalmente, **no alcanza con borrar el archivo o commit**: debe regenerarse inmediatamente desde Discord Developer Portal. + +Consultar [`SECURITY.md`](SECURITY.md). + +--- + +## Decisiones de ingeniería de v2 + +### Slash commands primero + +El bot ya no depende de inspeccionar todos los mensajes del servidor para ejecutar sus funciones principales. Esto mejora UX, descubribilidad y reduce la necesidad del intent privilegiado `MESSAGE_CONTENT`. + +### Nada de HTTP bloqueante + +La implementación anterior usaba `requests.get()` dentro de una coroutine. Ahora se comparte una única `aiohttp.ClientSession`, evitando bloquear el event loop. + +### Cogs independientes + +Cada dominio tiene responsabilidad acotada. Agregar una funcionalidad nueva no exige modificar un archivo monolítico. + +### Persistencia explícita + +SQLite conserva configuración y recordatorios sin requerir un servidor de base de datos externo. + +### Permisos mínimos + +Los comandos de moderación usan checks de permisos y validan jerarquía antes de ejecutar acciones destructivas. + +### Configuración segura + +El token se obtiene del entorno y `.env` permanece fuera de Git. + +--- + +## Documentación adicional + +- [Arquitectura](docs/ARCHITECTURE.md) +- [Catálogo de comandos](docs/COMMANDS.md) +- [Despliegue](docs/DEPLOYMENT.md) +- [Política de seguridad](SECURITY.md) +- [Cómo contribuir](CONTRIBUTING.md) +- [Changelog](CHANGELOG.md) + +--- + +## Roadmap razonable + +La arquitectura permite incorporar posteriormente, sin reescribir el core: + +- tickets de soporte; +- warnings persistentes; +- sistema de sugerencias; +- estadísticas y observabilidad externa; +- dashboard web; +- PostgreSQL para despliegues multi-instancia; +- métricas Prometheus; +- internacionalización; +- plugins/cogs opcionales por servidor. + +--- + +## Licencia + +MIT. Ver [`LICENSE`](LICENSE). + +--- + +## Autor + +**Alejandro Daniel Di Stefano** +GitHub: [@Drako01](https://github.com/Drako01) + +PyBot se mantiene como proyecto práctico de referencia sobre Python asíncrono, Discord, arquitectura modular, persistencia y buenas prácticas de ingeniería de software. diff --git a/RELEASE_NOTES_v2.0.0.md b/RELEASE_NOTES_v2.0.0.md new file mode 100644 index 0000000..5705b98 --- /dev/null +++ b/RELEASE_NOTES_v2.0.0.md @@ -0,0 +1,70 @@ +# PyBot v2.0.0 — Professional Discord Bot Edition + +PyBot 2.0 transforma el proyecto original en una plataforma modular de utilidades y administración para comunidades Discord. + +## Highlights + +- Slash commands nativos. +- Arquitectura por Cogs. +- SQLite para configuración y recordatorios. +- Sistema completo de bienvenida/despedida/autorol. +- Self roles seguros y administrables. +- Herramientas de moderación con controles de jerarquía. +- Mod-log. +- Recordatorios persistentes. +- Cotización del dólar mediante I/O asíncrono. +- Encuestas y utilidades comunitarias. +- Docker. +- CI Python 3.11/3.12. +- Tests y Ruff. +- Documentación completa. + +## Breaking architecture change + +La estructura monolítica anterior (`bot.py` + `events.py`) fue reemplazada por `main.py` y el paquete `pybot/`. + +El comportamiento basado en inspección automática de mensajes también fue reemplazado por slash commands explícitos. + +Para desplegar v2 es necesario revisar `.env.example` y usar `DISCORD_TOKEN`. + +## Discord Developer Portal + +La versión actual necesita **Server Members Intent** para funciones de miembros. + +`Message Content Intent` no es requerido por defecto. + +Al invitar el bot se deben incluir los scopes: + +- `bot` +- `applications.commands` + +## Persistencia + +Los datos se almacenan por defecto en: + +```text +data/pybot.db +``` + +En despliegues Docker/cloud se debe persistir la carpeta `data/`. + +## Upgrade recomendado + +1. Crear backup de cualquier configuración externa que se quiera conservar. +2. Actualizar código. +3. Crear `.env` desde `.env.example`. +4. Instalar nuevas dependencias. +5. Revisar intents y permisos del bot. +6. Ejecutar `python main.py`. +7. Configurar el servidor desde `/config-*`. + +## Autor + +**Alejandro Daniel Di Stefano** +GitHub: [@Drako01](https://github.com/Drako01) + +Tag sugerido: `v2.0.0` + +Título sugerido de Release: + +**PyBot v2.0.0 — Professional Discord Bot Edition** diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0236d46 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,49 @@ +# Security Policy + +## Secretos + +Nunca publicar: + +- `DISCORD_TOKEN`; +- `.env` real; +- credenciales de APIs privadas; +- bases SQLite con datos de servidores reales; +- logs que contengan secretos. + +Si un token de Discord se expone, debe regenerarse inmediatamente desde Discord Developer Portal. Borrar el archivo del último commit no elimina el secreto del historial Git. + +## Permisos del bot + +PyBot está diseñado para trabajar con permisos específicos. No se recomienda conceder `Administrator` sólo para evitar configurar permisos. + +Los módulos de moderación necesitan únicamente los permisos asociados a las acciones activadas. + +## Intents privilegiados + +`Server Members Intent` es necesario para welcome/farewell/autorole. + +`Message Content Intent` está desactivado por defecto y no es requerido para los slash commands de v2. + +## Roles + +Los self roles usan una allowlist persistente configurada por administradores. El bot también verifica que: + +- el rol no sea gestionado; +- no sea `@everyone`; +- esté por debajo del rol más alto del bot. + +## Moderación + +Los comandos verifican permisos y jerarquías antes de kick, ban o timeout. + +## Dependencias + +Las actualizaciones de dependencias deben pasar CI. No aplicar upgrades mayores a ciegas sin revisar changelog de `discord.py` y ejecutar tests. + +## Reporte responsable + +Si encontrás una vulnerabilidad, evitá publicar tokens, datos personales o un exploit operativo en un Issue público. Contactá al mantenedor por un canal privado cuando la información permita abuso directo. + +## Alcance + +PyBot es software comunitario/educativo. No implementa un sistema formal de compliance, SIEM ni gestión centralizada de secretos. Para despliegues críticos se recomienda integrar un secret manager, observabilidad externa y políticas del proveedor de infraestructura. diff --git a/__pycache__/events.cpython-311.pyc b/__pycache__/events.cpython-311.pyc deleted file mode 100644 index b104c9a..0000000 Binary files a/__pycache__/events.cpython-311.pyc and /dev/null differ diff --git a/__pycache__/messages.cpython-311.pyc b/__pycache__/messages.cpython-311.pyc deleted file mode 100644 index e0618a9..0000000 Binary files a/__pycache__/messages.cpython-311.pyc and /dev/null differ diff --git a/bot.py b/bot.py deleted file mode 100644 index 5e4003d..0000000 --- a/bot.py +++ /dev/null @@ -1,53 +0,0 @@ -# bot.py -import discord -from discord.ext import commands -from discord import * -from dotenv import load_dotenv -import os -import events - -print("Iniciando Bot...\n") - -# Carga las variables de entorno desde el archivo .env -load_dotenv() -TOKEN = os.getenv('DISCORD_KEY') -WELCOME_CHANNEL_ID = os.getenv('WELCOME_CHANNEL_ID') - - -# Crea una instancia de la clase Bot con los intentos especificados -intents = discord.Intents.default() -intents.members = True -intents.message_content = True - - -bot = commands.Bot(command_prefix='!', intents=intents) - -# Crear un diccionario para mantener un registro de las salas de voz en las que el bot se encuentra -voice_channels = {} - -# Registra los eventos utilizando el decorador @bot.event -@bot.event -async def on_ready(): - print(f'Bot conectado como {bot.user}') - -@bot.event -async def on_member_join(member): - # Obtén el canal de bienvenida usando el método bot.get_channel - welcome_channel = bot.get_channel(WELCOME_CHANNEL_ID) - if welcome_channel: - await welcome_channel.send(f'¡Bienvenido al servidor {member.mention}! Actualmente hay {member.guild.member_count} miembros.') - await events.on_member_join(member, bot) - -@bot.event -async def on_member_remove(member): - await events.on_member_remove(member, bot) - -@bot.event -async def on_message(message): - await events.on_message(message, bot) - -@bot.event -async def on_raw_reaction_add(payload): - await events.on_raw_reaction_add(payload, bot) - -bot.run(TOKEN) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..dc08e9a --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,165 @@ +# Arquitectura de PyBot + +## Objetivo + +PyBot v2 aplica una arquitectura modular para evitar que eventos, comandos, acceso a datos y llamadas HTTP queden mezclados en un único archivo. + +## Capas + +### `main.py` + +Es el composition root. Sólo: + +1. carga `Settings`; +2. configura logging; +3. construye `PyBot`; +4. inicia el cliente Discord. + +No contiene lógica de negocio. + +### `pybot/bot.py` + +Contiene el lifecycle del bot: + +- intents; +- carga de extensiones; +- creación de una única `aiohttp.ClientSession`; +- conexión SQLite; +- sincronización de slash commands; +- presence; +- cierre ordenado de recursos; +- error handler global de application commands. + +### Cogs + +Cada Cog representa un dominio. + +| Cog | Responsabilidad | +|---|---| +| `core.py` | ayuda, ping e información | +| `community.py` | joins/leaves, configuración, autorol y self roles | +| `moderation.py` | acciones de moderación y auditoría | +| `utilities.py` | dólar, encuestas y utilidades generales | +| `reminders.py` | creación y dispatch de recordatorios | + +Los Cogs no crean conexiones HTTP ni conexiones de base propias: reutilizan recursos administrados por `PyBot`. + +## Persistencia + +`Database` encapsula SQLite mediante `aiosqlite`. + +Tablas actuales: + +### `guild_settings` + +Configuración por servidor: + +- welcome channel; +- farewell channel; +- mod-log channel; +- autorole. + +### `self_roles` + +Allowlist de roles que los miembros pueden asignarse mediante `/rol`. + +### `reminders` + +Recordatorios persistentes, con usuario, canal, guild, deadline y mensaje. + +SQLite usa WAL para mejorar comportamiento ante lecturas/escrituras concurrentes del mismo proceso. + +## Flujo de un slash command + +```text +Usuario + │ + ▼ +Discord Interaction + │ + ▼ +CommandTree + │ + ▼ +Cog command + ├── permission checks + ├── validation + ├── DB / external API + └── Interaction response +``` + +## Intents + +Se habilita `members` porque PyBot implementa: + +- bienvenida/despedida; +- autorol; +- información de miembros. + +`message_content` está deshabilitado por defecto. Los slash commands no lo necesitan. + +Esto evita pedir un intent privilegiado que ya no forma parte de la funcionalidad principal. + +## HTTP + +PyBot no utiliza `requests` en handlers async. + +El core crea una sola `aiohttp.ClientSession` con timeout global y la comparte durante toda la vida del proceso. Esto evita: + +- bloquear el event loop; +- crear conexiones nuevas por cada comando; +- dejar sesiones sin cerrar. + +## Manejo de errores + +El `CommandTree` tiene un error handler global que diferencia: + +- cooldown; +- permisos faltantes del usuario; +- permisos faltantes del bot; +- check failures; +- errores inesperados. + +Los errores inesperados se registran y al usuario se le devuelve un mensaje neutro. + +## Moderación y jerarquía + +Los comandos destructivos no confían sólo en permisos de Discord. También verifican: + +- self-target; +- owner del guild; +- top role del moderador; +- top role del bot; +- roles gestionados. + +## Recordatorios + +Un `tasks.loop` consulta recordatorios vencidos cada 15 segundos. + +Sólo se elimina el recordatorio después de una entrega exitosa. Si Discord no permite entregar el mensaje temporalmente, el registro permanece para un próximo intento. + +## Sincronización de comandos + +En desarrollo puede definirse `DEV_GUILD_ID` para sincronización inmediata sobre un guild. + +En producción se omite para sincronización global. + +## Escalabilidad + +SQLite es adecuada para una única instancia del bot y comunidades pequeñas/medianas. + +Si PyBot evolucionara a múltiples réplicas, se recomienda reemplazar `Database` por PostgreSQL y agregar coordinación distribuida para jobs/recordatorios. + +La separación actual permite hacer ese cambio sin reescribir los Cogs. + +## Principios aplicados + +- responsabilidades separadas; +- configuración externa; +- recursos compartidos con lifecycle explícito; +- I/O no bloqueante; +- least privilege; +- persistencia detrás de una abstracción; +- commands as product API; +- testabilidad de lógica pura y persistencia; +- backward cleanup: sin mantener dos cores en paralelo. diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md new file mode 100644 index 0000000..3ab565a --- /dev/null +++ b/docs/COMMANDS.md @@ -0,0 +1,167 @@ +# Catálogo de comandos + +Todos los comandos principales de PyBot v2 son **slash commands**. + +## General + +### `/ayuda` +Muestra un resumen de módulos y comandos. + +### `/ping` +Devuelve la latencia estimada del bot contra Discord. + +### `/about` +Versión de PyBot, `discord.py`, cantidad de servidores y autor. + +### `/serverinfo` +Información del servidor actual. + +### `/userinfo [miembro]` +Información de un miembro. Sin parámetro usa el usuario que ejecuta el comando. + +### `/avatar [usuario]` +Muestra el avatar en alta resolución. + +## Comunidad + +### `/roles` +Lista roles autoasignables permitidos. + +### `/rol rol` +Alterna un rol permitido: si el usuario no lo tiene lo agrega; si ya lo tiene lo quita. + +### `/config-ver` +**Permiso:** Manage Server. + +Muestra canales, autorol y self roles configurados. + +### `/config-bienvenida [canal]` +**Permiso:** Manage Server. + +Define el canal de bienvenida. Sin canal, desactiva la función. + +### `/config-despedida [canal]` +**Permiso:** Manage Server. + +Define el canal de despedida. Sin canal, desactiva la función. + +### `/config-modlog [canal]` +**Permiso:** Manage Server. + +Define el canal donde se auditan acciones de moderación. + +### `/config-autorol [rol]` +**Permiso:** Manage Roles. + +Configura el rol que reciben nuevos miembros. PyBot valida jerarquía y roles gestionados. + +### `/config-rol-agregar rol` +**Permiso:** Manage Roles. + +Agrega un rol a la allowlist de autoasignación. + +### `/config-rol-quitar rol` +**Permiso:** Manage Roles. + +Elimina un rol de esa allowlist. + +## Moderación + +### `/limpiar cantidad` +**Permiso:** Manage Messages. + +Elimina entre 1 y 100 mensajes recientes. + +### `/timeout miembro minutos [motivo]` +**Permiso:** Moderate Members. + +Aplica timeout entre 1 minuto y 28 días. + +### `/untimeout miembro [motivo]` +**Permiso:** Moderate Members. + +Quita el timeout. + +### `/expulsar miembro [motivo]` +**Permiso:** Kick Members. + +Expulsa un miembro validando jerarquía. + +### `/banear miembro [motivo] [borrar_horas]` +**Permiso:** Ban Members. + +Banea un miembro. `borrar_horas` permite borrar hasta 168 horas de mensajes previos según soporte de Discord. + +### `/slowmode segundos` +**Permiso:** Manage Channels. + +Configura slowmode entre 0 y 21600 segundos. Cero lo desactiva. + +## Utilidades + +### `/dolar tipo` +Consulta DolarAPI de forma asíncrona. + +Tipos disponibles: + +- Oficial +- Blue +- MEP / Bolsa +- CCL +- Tarjeta +- Mayorista +- Cripto + +### `/encuesta pregunta opcion_1 opcion_2 [opcion_3] [opcion_4] [opcion_5]` +Crea un embed y agrega reacciones numéricas para votar. + +### `/elegir opciones` +Selecciona aleatoriamente una opción. + +Formato: + +```text +pizza | sushi | empanadas +``` + +### `/dado [caras] [cantidad]` +Tira entre 1 y 10 dados, con entre 2 y 1000 caras. + +### `/moneda` +Cara o cruz. + +### `/mapa lugar` +Genera un enlace a una búsqueda de Google Maps sin requerir API key. + +## Recordatorios + +### `/recordar tiempo mensaje` +Crea un recordatorio persistente. + +Unidades: + +- `s`: segundos +- `m`: minutos +- `h`: horas +- `d`: días +- `w`: semanas + +Ejemplo: + +```text +/recordar tiempo:2h mensaje:Revisar el deploy +``` + +Máximo: 365 días. + +### `/recordatorios` +Lista hasta los próximos 10 recordatorios del usuario. + +### `/borrar-recordatorio id` +Borra un recordatorio propio por ID. + +## Comandos legacy + +La versión 1 reaccionaba a texto libre en mensajes como `dolar blue compra`, menciones, saludos o `¿dónde queda ...?`. + +En v2 esas funcionalidades fueron reemplazadas por comandos explícitos (`/dolar`, `/mapa`, `/ayuda`, etc.). Esto reduce falsos positivos, hace las funciones descubribles y evita depender del Message Content Intent para la operación normal. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..98f9def --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,135 @@ +# Despliegue de PyBot + +## Principio general + +PyBot es un proceso de larga duración conectado al Discord Gateway. No es una aplicación HTTP tradicional. + +Por eso debe ejecutarse como **worker** y no como web dyno. + +El `Procfile` incluido usa: + +```text +worker: python main.py +``` + +## Variables obligatorias + +```dotenv +DISCORD_TOKEN=... +``` + +## Variables recomendadas + +```dotenv +DATABASE_PATH=data/pybot.db +LOG_LEVEL=INFO +SYNC_COMMANDS=true +ENABLE_MESSAGE_CONTENT_INTENT=false +BOT_STATUS=Usá /ayuda +CURRENCY_API_BASE_URL=https://dolarapi.com/v1 +HTTP_TIMEOUT_SECONDS=8 +``` + +`DEV_GUILD_ID` debe usarse sólo cuando se quiera una sincronización rápida sobre un servidor concreto. + +## Docker + +### Build + +```bash +docker build -t pybot . +``` + +### Run + +```bash +docker run --rm \ + --env-file .env \ + -v "$(pwd)/data:/app/data" \ + pybot +``` + +El volumen es importante porque SQLite vive en `/app/data`. + +## Plataformas cloud + +Cualquier plataforma que ejecute un proceso Python persistente sirve, siempre que: + +- permita configurar variables de entorno; +- no suspenda el proceso por falta de tráfico HTTP; +- permita almacenamiento persistente si se usa SQLite; +- permita conexiones salientes a Discord y DolarAPI. + +Ejemplos conceptuales: Railway, Render Background Worker, Fly.io, VPS, Docker Compose o una VM. + +## SQLite y múltiples réplicas + +No ejecutar varias réplicas escribiendo sobre el mismo archivo SQLite mediante un filesystem compartido sin diseñar esa topología explícitamente. + +Para alta disponibilidad o múltiples instancias, migrar la capa `Database` a PostgreSQL y usar un mecanismo de coordinación distribuida para el dispatcher de recordatorios. + +## Reinicios + +Los recordatorios sobreviven reinicios porque se guardan en SQLite. + +Al volver a iniciar, el dispatcher procesa cualquier reminder cuya fecha ya haya vencido. + +## Logs + +PyBot escribe logs a stdout. Esto permite que Docker, systemd o el proveedor cloud recopile los logs sin necesidad de archivos locales. + +Para producción: + +```dotenv +LOG_LEVEL=INFO +``` + +Para troubleshooting temporal: + +```dotenv +LOG_LEVEL=DEBUG +``` + +No dejar DEBUG de forma permanente si el volumen de logs es alto. + +## systemd (ejemplo) + +```ini +[Unit] +Description=PyBot Discord Bot +After=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/pybot +EnvironmentFile=/opt/pybot/.env +ExecStart=/opt/pybot/.venv/bin/python /opt/pybot/main.py +Restart=always +RestartSec=5 +User=pybot + +[Install] +WantedBy=multi-user.target +``` + +Luego: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now pybot +sudo systemctl status pybot +``` + +## Checklist previo a producción + +- [ ] Token rotado si alguna vez estuvo expuesto. +- [ ] `.env` fuera de Git. +- [ ] `Server Members Intent` habilitado. +- [ ] Message Content Intent deshabilitado salvo necesidad real. +- [ ] Scopes `bot` y `applications.commands` al invitar. +- [ ] Permisos mínimos necesarios. +- [ ] Rol del bot por encima de los roles que debe administrar. +- [ ] Volumen persistente para `data/`. +- [ ] `DEV_GUILD_ID` removido si se desea sync global. +- [ ] CI en verde. +- [ ] Canal de mod-log configurado si se usa moderación. diff --git a/events.py b/events.py deleted file mode 100644 index 89bae17..0000000 --- a/events.py +++ /dev/null @@ -1,131 +0,0 @@ -import discord -import urllib.parse -import requests -import os -from dotenv import load_dotenv - - -load_dotenv() -url = os.getenv('API_DOLAR') -google = os.getenv('API_GOOGLE') -join_channel = int(os.getenv('CHANNEL_JOIN')) # Asegúrate de que CHANNEL_JOIN sea un ID numérico válido - -async def on_raw_reaction_add(payload, bot): - if payload.member.bot: - return - - channel = bot.get_channel(payload.channel_id) - message = await channel.fetch_message(payload.message_id) - guild = message.guild - - member = payload.member - - try: - emoji = payload.emoji.name - role = discord.utils.get(guild.roles, name=emoji) - - if role is not None: - if role in member.roles: - await member.remove_roles(role) - else: - await member.add_roles(role) - - except Exception as e: - print(f'Error: {e}') - -# Evento que se ejecuta cuando un nuevo miembro se une al servidor -async def on_member_join(member, bot): - guild = member.guild - canal_de_bienvenida = discord.utils.get(guild.channels, name="bienvenida") - - if canal_de_bienvenida: - mensaje_de_bienvenida = f'Bienvenido al servidor, {member.mention}!' - await canal_de_bienvenida.send(mensaje_de_bienvenida) - - channel = bot.get_channel(join_channel) - message = await channel.send(f'Bienvenido {member.mention}! Reacciona a este mensaje para seleccionar tus roles:') - - for emoji in ['👨‍💻', '📂', '⌨️', '💻', '🖥️', '🎨', '👁️']: - await message.add_reaction(emoji) - -# Evento que se ejecuta cuando un miembro abandona el servidor -async def on_member_remove(member, bot): - guild = member.guild - canal_de_despedida = discord.utils.get(guild.channels, name="bienvenida") - - if canal_de_despedida: - mensaje_de_despedida = f'{member.mention} ha dejado el servidor. ¡Te echaremos de menos!' - await canal_de_despedida.send(mensaje_de_despedida) - -async def obtener_usuarios_offline(message): - usuarios_offline = [m for m in message.guild.members if m.status == discord.Status.offline] - - if usuarios_offline: - lista_usuarios = "\n".join([u.display_name for u in usuarios_offline]) - respuesta = f'Los usuarios que están offline son:\n{lista_usuarios}' - else: - respuesta = 'No hay usuarios offline en este servidor en este momento.' - - await message.channel.send(respuesta) - -# Evento que se ejecuta cuando se recibe un mensaje en el servidor -async def on_message(message, bot): - if message.author == bot.user: - return - - mensaje = message.content.lower() - - if any(keyword in mensaje for keyword in ["código", "programación", "code", "programming"]): - recomendacion = ( - f"Hola {message.author.mention}, veo que tienes una consulta sobre programación. " - "Te recomiendo que uses ChatGPT para obtener respuestas detalladas y específicas sobre tu código. " - "¡Es una excelente herramienta! Accedé desde: https://chatgpt.com/" - ) - await message.channel.send(recomendacion) - return - - if ("dolar" in mensaje or "dólar" in mensaje or "euro" in mensaje) and (("oficial" in mensaje or "blue" in mensaje) and ("compra" in mensaje or "venta" in mensaje)): - tipo = "oficial" if "oficial" in mensaje else "blue" if "blue" in mensaje else "oficial_euro" if "euro" in mensaje else "blue_euro" - accion = "compra" if "compra" in mensaje else "venta" - - response = requests.get(url) - - if response.status_code == 200: - data_dolar = response.json() - clave_valor = "value_buy" if accion == "compra" else "value_sell" - valor = data_dolar[tipo][clave_valor] - - if "euro" in mensaje: - moneda = "Euro" - else: - moneda = "Dólar" - - mensaje_respuesta = f"El Valor del {moneda} {tipo.replace('_', ' ').title()} es de ${valor} para la {accion.title()}" - await message.channel.send(mensaje_respuesta) - else: - await message.channel.send("No se pudo obtener la información de la moneda en este momento. Inténtalo de nuevo más tarde.") - - if mensaje.startswith('¿dónde queda '): - provincia = mensaje[len('¿dónde queda '):-1] - ubicacion_codificada = urllib.parse.quote(provincia) - google_maps_url = f'{google}{ubicacion_codificada}' - - await message.channel.send(f'{provincia} se encuentra en {google_maps_url}') - - if bot.user.mentioned_in(message): - apodo = message.author.mention - if any(saludo in mensaje for saludo in ["hola", "hello", "hi", "buen dia", "buenas tardes", "buenas noches"]): - respuesta = f'Hola {apodo}! ¿Cómo estás en el día de hoy?' - elif any(saludo in mensaje for saludo in ["adios", "chau", "bye"]): - respuesta = f'Hasta luego {apodo}!' - elif any(saludo in mensaje for saludo in ["ayudar", "ayuda", "help", "ayúdame"]): - respuesta = f'Obviamente, ¿en qué te puedo ayudar {apodo}?' - elif any(saludo in mensaje for saludo in ["sos", "robot", "extraterrestre"]): - respuesta = f'Para nada {apodo}, soy un Bot generado con Python por un Hacker!.' - elif "offline" in mensaje: - await obtener_usuarios_offline(message) - return - else: - respuesta = f'¡Quedo atento a lo que necesites, {apodo}!' - - await message.channel.send(respuesta) diff --git a/main.py b/main.py new file mode 100644 index 0000000..bcb20c0 --- /dev/null +++ b/main.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import asyncio +import logging + +from pybot.bot import PyBot +from pybot.config import Settings +from pybot.logging_config import configure_logging + + +async def main() -> None: + settings = Settings.from_env() + configure_logging(settings.log_level) + + bot = PyBot(settings) + async with bot: + await bot.start(settings.discord_token) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + logging.getLogger("pybot").info("Bot detenido por el usuario") diff --git a/pybot/__init__.py b/pybot/__init__.py new file mode 100644 index 0000000..53614cd --- /dev/null +++ b/pybot/__init__.py @@ -0,0 +1,5 @@ +"""PyBot - bot profesional y extensible para Discord.""" + +__all__ = ["__version__"] + +__version__ = "2.0.0" diff --git a/pybot/bot.py b/pybot/bot.py new file mode 100644 index 0000000..514a1b8 --- /dev/null +++ b/pybot/bot.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import logging +from typing import Final + +import aiohttp +import discord +from discord import app_commands +from discord.ext import commands + +from pybot.config import Settings +from pybot.database import Database + + +LOGGER = logging.getLogger("pybot") +EXTENSIONS: Final[tuple[str, ...]] = ( + "pybot.cogs.core", + "pybot.cogs.community", + "pybot.cogs.moderation", + "pybot.cogs.utilities", + "pybot.cogs.reminders", +) + + +class PyBot(commands.Bot): + def __init__(self, settings: Settings) -> None: + intents = discord.Intents.default() + intents.members = True + intents.message_content = settings.enable_message_content_intent + + super().__init__( + command_prefix=commands.when_mentioned_or("!"), + intents=intents, + help_command=None, + case_insensitive=True, + allowed_mentions=discord.AllowedMentions( + everyone=False, + roles=False, + replied_user=False, + ), + ) + + self.settings = settings + self.database = Database(settings.database_path) + self.http_session: aiohttp.ClientSession | None = None + self.tree.on_error = self._on_app_command_error + + async def setup_hook(self) -> None: + await self.database.connect() + timeout = aiohttp.ClientTimeout(total=self.settings.http_timeout_seconds) + self.http_session = aiohttp.ClientSession(timeout=timeout) + + for extension in EXTENSIONS: + await self.load_extension(extension) + LOGGER.info("Extensión cargada: %s", extension) + + if self.settings.sync_commands: + if self.settings.dev_guild_id: + guild = discord.Object(id=self.settings.dev_guild_id) + self.tree.copy_global_to(guild=guild) + synced = await self.tree.sync(guild=guild) + LOGGER.info( + "%s comandos sincronizados en guild de desarrollo %s", + len(synced), + self.settings.dev_guild_id, + ) + else: + synced = await self.tree.sync() + LOGGER.info("%s comandos globales sincronizados", len(synced)) + + async def on_ready(self) -> None: + if self.user is None: + return + + await self.change_presence( + activity=discord.CustomActivity(name=self.settings.default_status) + ) + LOGGER.info( + "PyBot conectado como %s (%s) en %s servidores", + self.user, + self.user.id, + len(self.guilds), + ) + + async def close(self) -> None: + if self.http_session is not None and not self.http_session.closed: + await self.http_session.close() + await self.database.close() + await super().close() + + async def _on_app_command_error( + self, + interaction: discord.Interaction, + error: app_commands.AppCommandError, + ) -> None: + original = getattr(error, "original", error) + + if isinstance(error, app_commands.CommandOnCooldown): + message = f"Ese comando está en cooldown. Probá de nuevo en {error.retry_after:.1f}s." + elif isinstance(error, app_commands.MissingPermissions): + message = "No tenés los permisos necesarios para usar este comando." + elif isinstance(error, app_commands.BotMissingPermissions): + message = "Me faltan permisos de Discord para completar esa acción." + elif isinstance(error, app_commands.CheckFailure): + message = "No podés ejecutar ese comando en este contexto." + else: + LOGGER.exception( + "Error no controlado en app command", + exc_info=(type(original), original, original.__traceback__), + ) + message = "Ocurrió un error inesperado. El incidente quedó registrado." + + if interaction.response.is_done(): + await interaction.followup.send(message, ephemeral=True) + else: + await interaction.response.send_message(message, ephemeral=True) diff --git a/pybot/cogs/__init__.py b/pybot/cogs/__init__.py new file mode 100644 index 0000000..6b4b3d0 --- /dev/null +++ b/pybot/cogs/__init__.py @@ -0,0 +1 @@ +"""Cogs de PyBot.""" diff --git a/pybot/cogs/community.py b/pybot/cogs/community.py new file mode 100644 index 0000000..f7b56d2 --- /dev/null +++ b/pybot/cogs/community.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import logging + +import discord +from discord import app_commands +from discord.ext import commands + + +LOGGER = logging.getLogger("pybot.community") + + +class CommunityCog(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member) -> None: + settings = await self.bot.database.get_guild_settings(member.guild.id) # type: ignore[attr-defined] + + if settings.autorole_id: + role = member.guild.get_role(settings.autorole_id) + if role is not None: + try: + await member.add_roles(role, reason="Autorol configurado en PyBot") + except discord.Forbidden: + LOGGER.warning("Sin permisos para asignar autorol en guild %s", member.guild.id) + + if settings.welcome_channel_id: + channel = member.guild.get_channel(settings.welcome_channel_id) + if isinstance(channel, discord.TextChannel): + embed = discord.Embed( + title="👋 ¡Bienvenido/a!", + description=( + f"Hola {member.mention}, te damos la bienvenida a **{member.guild.name}**.\n" + f"Ya somos **{member.guild.member_count or 0}** miembros." + ), + colour=discord.Colour.green(), + ) + embed.set_thumbnail(url=member.display_avatar.url) + await channel.send(embed=embed) + + @commands.Cog.listener() + async def on_member_remove(self, member: discord.Member) -> None: + settings = await self.bot.database.get_guild_settings(member.guild.id) # type: ignore[attr-defined] + if not settings.farewell_channel_id: + return + + channel = member.guild.get_channel(settings.farewell_channel_id) + if isinstance(channel, discord.TextChannel): + embed = discord.Embed( + title="👋 Un miembro dejó el servidor", + description=f"**{member.display_name}** dejó **{member.guild.name}**.", + colour=discord.Colour.orange(), + ) + embed.set_thumbnail(url=member.display_avatar.url) + await channel.send(embed=embed) + + @app_commands.command(name="roles", description="Muestra los roles que los miembros pueden autoasignarse.") + @app_commands.guild_only() + async def self_roles(self, interaction: discord.Interaction) -> None: + guild = interaction.guild + assert guild is not None + role_ids = await self.bot.database.list_self_roles(guild.id) # type: ignore[attr-defined] + roles = [guild.get_role(role_id) for role_id in role_ids] + valid_roles = [role for role in roles if role is not None] + + if not valid_roles: + await interaction.response.send_message( + "Este servidor todavía no configuró roles autoasignables.", ephemeral=True + ) + return + + embed = discord.Embed( + title="🎭 Roles autoasignables", + description="\n".join(f"• {role.mention}" for role in valid_roles), + colour=discord.Colour.blurple(), + ) + embed.set_footer(text="Usá /rol para agregar o quitar uno de estos roles") + await interaction.response.send_message(embed=embed, ephemeral=True) + + @app_commands.command(name="rol", description="Agrega o quita un rol autoasignable de tu perfil.") + @app_commands.guild_only() + @app_commands.checks.bot_has_permissions(manage_roles=True) + async def self_role(self, interaction: discord.Interaction, rol: discord.Role) -> None: + guild = interaction.guild + member = interaction.user + assert guild is not None + if not isinstance(member, discord.Member): + await interaction.response.send_message("No pude resolver tu membresía.", ephemeral=True) + return + + allowed_ids = await self.bot.database.list_self_roles(guild.id) # type: ignore[attr-defined] + if rol.id not in allowed_ids: + await interaction.response.send_message( + "❌ Ese rol no está habilitado para autoasignación. Usá `/roles` para ver los disponibles.", + ephemeral=True, + ) + return + + if guild.me is None or rol >= guild.me.top_role or rol.managed: + await interaction.response.send_message( + "❌ No puedo administrar ese rol por la jerarquía o porque es un rol gestionado.", + ephemeral=True, + ) + return + + if rol in member.roles: + await member.remove_roles(rol, reason="Autoasignación mediante PyBot") + message = f"➖ Se quitó {rol.mention}." + else: + await member.add_roles(rol, reason="Autoasignación mediante PyBot") + message = f"✅ Se agregó {rol.mention}." + await interaction.response.send_message(message, ephemeral=True) + + @app_commands.command(name="config-ver", description="Muestra la configuración de PyBot para este servidor.") + @app_commands.guild_only() + @app_commands.checks.has_permissions(manage_guild=True) + async def config_view(self, interaction: discord.Interaction) -> None: + guild = interaction.guild + assert guild is not None + settings = await self.bot.database.get_guild_settings(guild.id) # type: ignore[attr-defined] + self_role_ids = await self.bot.database.list_self_roles(guild.id) # type: ignore[attr-defined] + + def channel_name(channel_id: int | None) -> str: + if not channel_id: + return "No configurado" + channel = guild.get_channel(channel_id) + return channel.mention if channel else f"ID `{channel_id}` (no encontrado)" + + def role_name(role_id: int | None) -> str: + if not role_id: + return "No configurado" + role = guild.get_role(role_id) + return role.mention if role else f"ID `{role_id}` (no encontrado)" + + self_roles = [guild.get_role(role_id) for role_id in self_role_ids] + self_role_text = ", ".join(role.mention for role in self_roles if role is not None) or "Ninguno" + + embed = discord.Embed( + title="⚙️ Configuración de PyBot", + colour=discord.Colour.blurple(), + ) + embed.add_field(name="Bienvenida", value=channel_name(settings.welcome_channel_id), inline=False) + embed.add_field(name="Despedida", value=channel_name(settings.farewell_channel_id), inline=False) + embed.add_field(name="Mod log", value=channel_name(settings.modlog_channel_id), inline=False) + embed.add_field(name="Autorol", value=role_name(settings.autorole_id), inline=False) + embed.add_field(name="Roles autoasignables", value=self_role_text, inline=False) + await interaction.response.send_message(embed=embed, ephemeral=True) + + @app_commands.command(name="config-bienvenida", description="Configura o desactiva el canal de bienvenida.") + @app_commands.guild_only() + @app_commands.checks.has_permissions(manage_guild=True) + async def config_welcome( + self, + interaction: discord.Interaction, + canal: discord.TextChannel | None = None, + ) -> None: + assert interaction.guild_id is not None + await self.bot.database.update_guild_setting( # type: ignore[attr-defined] + interaction.guild_id, "welcome_channel_id", canal.id if canal else None + ) + message = f"Canal de bienvenida: {canal.mention}." if canal else "Bienvenidas desactivadas." + await interaction.response.send_message(f"✅ {message}", ephemeral=True) + + @app_commands.command(name="config-despedida", description="Configura o desactiva el canal de despedida.") + @app_commands.guild_only() + @app_commands.checks.has_permissions(manage_guild=True) + async def config_farewell( + self, + interaction: discord.Interaction, + canal: discord.TextChannel | None = None, + ) -> None: + assert interaction.guild_id is not None + await self.bot.database.update_guild_setting( # type: ignore[attr-defined] + interaction.guild_id, "farewell_channel_id", canal.id if canal else None + ) + message = f"Canal de despedida: {canal.mention}." if canal else "Despedidas desactivadas." + await interaction.response.send_message(f"✅ {message}", ephemeral=True) + + @app_commands.command(name="config-modlog", description="Configura o desactiva el canal de auditoría de moderación.") + @app_commands.guild_only() + @app_commands.checks.has_permissions(manage_guild=True) + async def config_modlog( + self, + interaction: discord.Interaction, + canal: discord.TextChannel | None = None, + ) -> None: + assert interaction.guild_id is not None + await self.bot.database.update_guild_setting( # type: ignore[attr-defined] + interaction.guild_id, "modlog_channel_id", canal.id if canal else None + ) + message = f"Canal de mod log: {canal.mention}." if canal else "Mod log desactivado." + await interaction.response.send_message(f"✅ {message}", ephemeral=True) + + @app_commands.command(name="config-autorol", description="Configura o desactiva el rol automático para nuevos miembros.") + @app_commands.guild_only() + @app_commands.checks.has_permissions(manage_roles=True) + async def config_autorole( + self, + interaction: discord.Interaction, + rol: discord.Role | None = None, + ) -> None: + assert interaction.guild_id is not None + guild = interaction.guild + assert guild is not None + + if rol is not None and (rol.managed or (guild.me is not None and rol >= guild.me.top_role)): + await interaction.response.send_message( + "❌ Ese rol no puede ser administrado por el bot o está por encima de mi rol más alto.", + ephemeral=True, + ) + return + + await self.bot.database.update_guild_setting( # type: ignore[attr-defined] + interaction.guild_id, "autorole_id", rol.id if rol else None + ) + message = f"Autorol configurado: {rol.mention}." if rol else "Autorol desactivado." + await interaction.response.send_message(f"✅ {message}", ephemeral=True) + + @app_commands.command(name="config-rol-agregar", description="Habilita un rol para autoasignación por los miembros.") + @app_commands.guild_only() + @app_commands.checks.has_permissions(manage_roles=True) + async def config_self_role_add(self, interaction: discord.Interaction, rol: discord.Role) -> None: + guild = interaction.guild + assert guild is not None + if rol.is_default() or rol.managed or (guild.me is not None and rol >= guild.me.top_role): + await interaction.response.send_message( + "❌ Ese rol no es apto para autoasignación o está fuera de mi jerarquía.", ephemeral=True + ) + return + await self.bot.database.add_self_role(guild.id, rol.id) # type: ignore[attr-defined] + await interaction.response.send_message( + f"✅ {rol.mention} quedó habilitado para `/rol`.", ephemeral=True + ) + + @app_commands.command(name="config-rol-quitar", description="Quita un rol de la lista de autoasignables.") + @app_commands.guild_only() + @app_commands.checks.has_permissions(manage_roles=True) + async def config_self_role_remove(self, interaction: discord.Interaction, rol: discord.Role) -> None: + guild = interaction.guild + assert guild is not None + removed = await self.bot.database.remove_self_role(guild.id, rol.id) # type: ignore[attr-defined] + message = f"✅ {rol.mention} ya no es autoasignable." if removed else "Ese rol no estaba configurado." + await interaction.response.send_message(message, ephemeral=True) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(CommunityCog(bot)) diff --git a/pybot/cogs/core.py b/pybot/cogs/core.py new file mode 100644 index 0000000..817e303 --- /dev/null +++ b/pybot/cogs/core.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +import discord +from discord import app_commands +from discord.ext import commands + +from pybot import __version__ + + +class CoreCog(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + + @app_commands.command(name="ayuda", description="Muestra los módulos y comandos principales de PyBot.") + async def help_command(self, interaction: discord.Interaction) -> None: + embed = discord.Embed( + title="🐍 PyBot · Centro de ayuda", + description=( + "Bot comunitario construido con Python y discord.py. " + "Los comandos están organizados por función y usan slash commands." + ), + colour=discord.Colour.blurple(), + timestamp=datetime.now(UTC), + ) + embed.add_field( + name="🧭 General", + value="`/ping` `/about` `/serverinfo` `/userinfo` `/avatar`", + inline=False, + ) + embed.add_field( + name="🛠️ Utilidades", + value="`/dolar` `/encuesta` `/elegir` `/dado` `/moneda` `/mapa`", + inline=False, + ) + embed.add_field( + name="⏰ Recordatorios", + value="`/recordar` `/recordatorios` `/borrar-recordatorio`", + inline=False, + ) + embed.add_field( + name="🎭 Comunidad", + value="`/roles` `/rol` · administración con `/config-*`", + inline=False, + ) + embed.add_field( + name="🛡️ Moderación", + value="`/limpiar` `/timeout` `/untimeout` `/expulsar` `/banear` `/slowmode`", + inline=False, + ) + embed.set_footer(text=f"PyBot v{__version__} · consultá README/docs para referencia completa") + await interaction.response.send_message(embed=embed, ephemeral=True) + + @app_commands.command(name="ping", description="Mide la latencia actual del bot.") + async def ping(self, interaction: discord.Interaction) -> None: + latency_ms = round(self.bot.latency * 1000) + await interaction.response.send_message( + f"🏓 Pong · **{latency_ms} ms**", + ephemeral=True, + ) + + @app_commands.command(name="about", description="Información técnica y versión de PyBot.") + async def about(self, interaction: discord.Interaction) -> None: + embed = discord.Embed( + title="🐍 PyBot", + description="Bot modular para comunidades de Discord, desarrollado en Python.", + colour=discord.Colour.blurple(), + ) + embed.add_field(name="Versión", value=__version__) + embed.add_field(name="Framework", value=f"discord.py {discord.__version__}") + embed.add_field(name="Servidores", value=str(len(self.bot.guilds))) + embed.add_field( + name="Autor", + value="[Alejandro Daniel Di Stefano](https://github.com/Drako01)", + inline=False, + ) + await interaction.response.send_message(embed=embed) + + @app_commands.command(name="serverinfo", description="Muestra información del servidor actual.") + @app_commands.guild_only() + async def server_info(self, interaction: discord.Interaction) -> None: + guild = interaction.guild + assert guild is not None + owner = guild.owner.mention if guild.owner else "No disponible" + embed = discord.Embed( + title=f"📊 {guild.name}", + colour=discord.Colour.blurple(), + timestamp=datetime.now(UTC), + ) + if guild.icon: + embed.set_thumbnail(url=guild.icon.url) + embed.add_field(name="Miembros", value=str(guild.member_count or 0)) + embed.add_field(name="Canales", value=str(len(guild.channels))) + embed.add_field(name="Roles", value=str(len(guild.roles))) + embed.add_field(name="Owner", value=owner, inline=False) + embed.add_field( + name="Creado", + value=discord.utils.format_dt(guild.created_at, style="D"), + inline=False, + ) + await interaction.response.send_message(embed=embed) + + @app_commands.command(name="userinfo", description="Muestra información de un miembro.") + @app_commands.guild_only() + async def user_info( + self, + interaction: discord.Interaction, + miembro: discord.Member | None = None, + ) -> None: + member = miembro or interaction.user + if not isinstance(member, discord.Member): + await interaction.response.send_message("No pude resolver ese miembro.", ephemeral=True) + return + + embed = discord.Embed( + title=f"👤 {member.display_name}", + colour=member.colour if member.colour.value else discord.Colour.blurple(), + ) + embed.set_thumbnail(url=member.display_avatar.url) + embed.add_field(name="Usuario", value=str(member)) + embed.add_field(name="ID", value=str(member.id)) + embed.add_field( + name="Cuenta creada", + value=discord.utils.format_dt(member.created_at, style="R"), + inline=False, + ) + if member.joined_at: + embed.add_field( + name="Ingresó al servidor", + value=discord.utils.format_dt(member.joined_at, style="R"), + inline=False, + ) + embed.add_field(name="Roles", value=str(max(0, len(member.roles) - 1))) + await interaction.response.send_message(embed=embed) + + @app_commands.command(name="avatar", description="Muestra el avatar de un usuario en alta resolución.") + async def avatar( + self, + interaction: discord.Interaction, + usuario: discord.User | None = None, + ) -> None: + target = usuario or interaction.user + embed = discord.Embed( + title=f"Avatar de {target.display_name}", + colour=discord.Colour.blurple(), + ) + embed.set_image(url=target.display_avatar.url) + await interaction.response.send_message(embed=embed) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(CoreCog(bot)) diff --git a/pybot/cogs/moderation.py b/pybot/cogs/moderation.py new file mode 100644 index 0000000..de36a32 --- /dev/null +++ b/pybot/cogs/moderation.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import discord +from discord import app_commands +from discord.ext import commands + + +class ModerationCog(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + + async def _send_modlog( + self, + guild: discord.Guild, + *, + action: str, + moderator: discord.abc.User, + target: str, + reason: str, + ) -> None: + settings = await self.bot.database.get_guild_settings(guild.id) # type: ignore[attr-defined] + if not settings.modlog_channel_id: + return + channel = guild.get_channel(settings.modlog_channel_id) + if not isinstance(channel, discord.TextChannel): + return + + embed = discord.Embed( + title=f"🛡️ {action}", + colour=discord.Colour.orange(), + timestamp=datetime.now(UTC), + ) + embed.add_field(name="Objetivo", value=target, inline=False) + embed.add_field(name="Moderador", value=f"{moderator} (`{moderator.id}`)", inline=False) + embed.add_field(name="Motivo", value=reason or "Sin motivo especificado", inline=False) + await channel.send(embed=embed) + + @staticmethod + def _can_moderate(interaction: discord.Interaction, member: discord.Member) -> tuple[bool, str]: + guild = interaction.guild + actor = interaction.user + if guild is None or not isinstance(actor, discord.Member): + return False, "Este comando sólo puede usarse dentro de un servidor." + if member.id == actor.id: + return False, "No podés moderarte a vos mismo." + if member.id == guild.owner_id: + return False, "No se puede moderar al owner del servidor." + if actor.id != guild.owner_id and member.top_role >= actor.top_role: + return False, "Ese miembro tiene un rol igual o superior al tuyo." + if guild.me is not None and member.top_role >= guild.me.top_role: + return False, "Mi rol debe estar por encima del miembro que querés moderar." + return True, "" + + @app_commands.command(name="limpiar", description="Elimina una cantidad de mensajes recientes del canal.") + @app_commands.guild_only() + @app_commands.checks.has_permissions(manage_messages=True) + @app_commands.checks.bot_has_permissions(manage_messages=True, read_message_history=True) + async def clear(self, interaction: discord.Interaction, cantidad: app_commands.Range[int, 1, 100]) -> None: + channel = interaction.channel + if not isinstance(channel, discord.TextChannel): + await interaction.response.send_message("Este comando requiere un canal de texto.", ephemeral=True) + return + + await interaction.response.defer(ephemeral=True, thinking=True) + deleted = await channel.purge(limit=int(cantidad)) + await interaction.followup.send(f"✅ Se eliminaron **{len(deleted)}** mensajes.", ephemeral=True) + + @app_commands.command(name="timeout", description="Aísla temporalmente a un miembro.") + @app_commands.guild_only() + @app_commands.checks.has_permissions(moderate_members=True) + @app_commands.checks.bot_has_permissions(moderate_members=True) + async def timeout( + self, + interaction: discord.Interaction, + miembro: discord.Member, + minutos: app_commands.Range[int, 1, 40320], + motivo: str = "Sin motivo especificado", + ) -> None: + allowed, error = self._can_moderate(interaction, miembro) + if not allowed: + await interaction.response.send_message(f"❌ {error}", ephemeral=True) + return + + until = datetime.now(UTC) + timedelta(minutes=int(minutos)) + await miembro.timeout(until, reason=f"{motivo} · por {interaction.user}") + await interaction.response.send_message( + f"✅ {miembro.mention} recibió timeout por **{minutos} min**.", + ephemeral=True, + ) + assert interaction.guild is not None + await self._send_modlog( + interaction.guild, + action="Timeout", + moderator=interaction.user, + target=f"{miembro} (`{miembro.id}`)", + reason=f"{motivo} · {minutos} min", + ) + + @app_commands.command(name="untimeout", description="Quita el timeout a un miembro.") + @app_commands.guild_only() + @app_commands.checks.has_permissions(moderate_members=True) + @app_commands.checks.bot_has_permissions(moderate_members=True) + async def untimeout( + self, + interaction: discord.Interaction, + miembro: discord.Member, + motivo: str = "Timeout removido por moderación", + ) -> None: + allowed, error = self._can_moderate(interaction, miembro) + if not allowed: + await interaction.response.send_message(f"❌ {error}", ephemeral=True) + return + await miembro.timeout(None, reason=f"{motivo} · por {interaction.user}") + await interaction.response.send_message(f"✅ Timeout removido a {miembro.mention}.", ephemeral=True) + + @app_commands.command(name="expulsar", description="Expulsa un miembro del servidor.") + @app_commands.guild_only() + @app_commands.checks.has_permissions(kick_members=True) + @app_commands.checks.bot_has_permissions(kick_members=True) + async def kick( + self, + interaction: discord.Interaction, + miembro: discord.Member, + motivo: str = "Sin motivo especificado", + ) -> None: + allowed, error = self._can_moderate(interaction, miembro) + if not allowed: + await interaction.response.send_message(f"❌ {error}", ephemeral=True) + return + + target = f"{miembro} (`{miembro.id}`)" + await miembro.kick(reason=f"{motivo} · por {interaction.user}") + await interaction.response.send_message(f"✅ **{miembro}** fue expulsado.", ephemeral=True) + assert interaction.guild is not None + await self._send_modlog( + interaction.guild, + action="Expulsión", + moderator=interaction.user, + target=target, + reason=motivo, + ) + + @app_commands.command(name="banear", description="Banea un miembro del servidor.") + @app_commands.guild_only() + @app_commands.checks.has_permissions(ban_members=True) + @app_commands.checks.bot_has_permissions(ban_members=True) + async def ban( + self, + interaction: discord.Interaction, + miembro: discord.Member, + motivo: str = "Sin motivo especificado", + borrar_horas: app_commands.Range[int, 0, 168] = 0, + ) -> None: + allowed, error = self._can_moderate(interaction, miembro) + if not allowed: + await interaction.response.send_message(f"❌ {error}", ephemeral=True) + return + + target = f"{miembro} (`{miembro.id}`)" + await miembro.ban( + reason=f"{motivo} · por {interaction.user}", + delete_message_seconds=int(borrar_horas) * 3600, + ) + await interaction.response.send_message(f"✅ **{miembro}** fue baneado.", ephemeral=True) + assert interaction.guild is not None + await self._send_modlog( + interaction.guild, + action="Ban", + moderator=interaction.user, + target=target, + reason=motivo, + ) + + @app_commands.command(name="slowmode", description="Configura el slowmode del canal en segundos.") + @app_commands.guild_only() + @app_commands.checks.has_permissions(manage_channels=True) + @app_commands.checks.bot_has_permissions(manage_channels=True) + async def slowmode( + self, + interaction: discord.Interaction, + segundos: app_commands.Range[int, 0, 21600], + ) -> None: + channel = interaction.channel + if not isinstance(channel, discord.TextChannel): + await interaction.response.send_message("Este comando requiere un canal de texto.", ephemeral=True) + return + await channel.edit(slowmode_delay=int(segundos), reason=f"Configurado por {interaction.user}") + text = "desactivado" if segundos == 0 else f"configurado en **{segundos}s**" + await interaction.response.send_message(f"✅ Slowmode {text}.", ephemeral=True) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(ModerationCog(bot)) diff --git a/pybot/cogs/reminders.py b/pybot/cogs/reminders.py new file mode 100644 index 0000000..0d52655 --- /dev/null +++ b/pybot/cogs/reminders.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import logging +from datetime import UTC, datetime + +import discord +from discord import app_commands +from discord.ext import commands, tasks + +from pybot.utils.timeparse import parse_duration + + +LOGGER = logging.getLogger("pybot.reminders") + + +class RemindersCog(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self.dispatch_due_reminders.start() + + def cog_unload(self) -> None: + self.dispatch_due_reminders.cancel() + + @app_commands.command(name="recordar", description="Crea un recordatorio persistente.") + async def remind( + self, + interaction: discord.Interaction, + tiempo: str, + mensaje: str, + ) -> None: + try: + delta = parse_duration(tiempo) + except ValueError as exc: + await interaction.response.send_message(f"❌ {exc}", ephemeral=True) + return + + if len(mensaje.strip()) < 2 or len(mensaje) > 500: + await interaction.response.send_message( + "El mensaje debe tener entre 2 y 500 caracteres.", ephemeral=True + ) + return + + remind_at = datetime.now(UTC) + delta + reminder_id = await self.bot.database.add_reminder( # type: ignore[attr-defined] + user_id=interaction.user.id, + channel_id=interaction.channel_id, + guild_id=interaction.guild_id, + remind_at=remind_at, + message=mensaje.strip(), + ) + await interaction.response.send_message( + f"⏰ Recordatorio **#{reminder_id}** creado para {discord.utils.format_dt(remind_at, style='R')}.", + ephemeral=True, + ) + + @app_commands.command(name="recordatorios", description="Lista tus próximos recordatorios.") + async def list_reminders(self, interaction: discord.Interaction) -> None: + reminders = await self.bot.database.list_user_reminders(interaction.user.id) # type: ignore[attr-defined] + if not reminders: + await interaction.response.send_message("No tenés recordatorios pendientes.", ephemeral=True) + return + + lines = [ + f"**#{reminder.id}** · {discord.utils.format_dt(reminder.remind_at, style='R')} · {discord.utils.escape_markdown(reminder.message[:120])}" + for reminder in reminders + ] + embed = discord.Embed( + title="⏰ Tus recordatorios", + description="\n".join(lines), + colour=discord.Colour.blurple(), + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + + @app_commands.command(name="borrar-recordatorio", description="Elimina uno de tus recordatorios.") + async def delete_reminder(self, interaction: discord.Interaction, id: int) -> None: + deleted = await self.bot.database.delete_reminder(id, interaction.user.id) # type: ignore[attr-defined] + message = "✅ Recordatorio eliminado." if deleted else "No encontré ese recordatorio entre los tuyos." + await interaction.response.send_message(message, ephemeral=True) + + @tasks.loop(seconds=15) + async def dispatch_due_reminders(self) -> None: + reminders = await self.bot.database.get_due_reminders(datetime.now(UTC)) # type: ignore[attr-defined] + for reminder in reminders: + delivered = False + channel = self.bot.get_channel(reminder.channel_id) + if channel is None: + try: + channel = await self.bot.fetch_channel(reminder.channel_id) + except (discord.NotFound, discord.Forbidden, discord.HTTPException): + channel = None + + if isinstance(channel, (discord.TextChannel, discord.Thread, discord.DMChannel)): + try: + await channel.send( + f"⏰ <@{reminder.user_id}> recordatorio: **{discord.utils.escape_markdown(reminder.message)}**", + allowed_mentions=discord.AllowedMentions(users=True), + ) + delivered = True + except discord.HTTPException: + LOGGER.exception("No se pudo entregar reminder %s", reminder.id) + + if delivered: + await self.bot.database.delete_reminder(reminder.id) # type: ignore[attr-defined] + + @dispatch_due_reminders.before_loop + async def before_dispatch(self) -> None: + await self.bot.wait_until_ready() + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(RemindersCog(bot)) diff --git a/pybot/cogs/utilities.py b/pybot/cogs/utilities.py new file mode 100644 index 0000000..893bb74 --- /dev/null +++ b/pybot/cogs/utilities.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import random +import urllib.parse +from datetime import UTC, datetime + +import discord +from discord import app_commands +from discord.ext import commands + + +DOLLAR_CHOICES = [ + app_commands.Choice(name="Oficial", value="oficial"), + app_commands.Choice(name="Blue", value="blue"), + app_commands.Choice(name="MEP / Bolsa", value="bolsa"), + app_commands.Choice(name="CCL", value="contadoconliqui"), + app_commands.Choice(name="Tarjeta", value="tarjeta"), + app_commands.Choice(name="Mayorista", value="mayorista"), + app_commands.Choice(name="Cripto", value="cripto"), +] + + +class UtilitiesCog(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + + @app_commands.command(name="dolar", description="Consulta una cotización actual del dólar en Argentina.") + @app_commands.choices(tipo=DOLLAR_CHOICES) + @app_commands.checks.cooldown(1, 5.0, key=lambda i: i.user.id) + async def dollar( + self, + interaction: discord.Interaction, + tipo: app_commands.Choice[str], + ) -> None: + await interaction.response.defer(thinking=True) + session = self.bot.http_session # type: ignore[attr-defined] + if session is None: + await interaction.followup.send("El cliente HTTP todavía no está disponible.", ephemeral=True) + return + + base_url = self.bot.settings.currency_api_base_url # type: ignore[attr-defined] + url = f"{base_url}/dolares/{tipo.value}" + try: + async with session.get(url) as response: + if response.status != 200: + raise RuntimeError(f"HTTP {response.status}") + data = await response.json() + except Exception: + await interaction.followup.send( + "No pude obtener la cotización en este momento. Probá nuevamente más tarde.", + ephemeral=True, + ) + return + + embed = discord.Embed( + title=f"💵 {data.get('nombre', tipo.name)}", + colour=discord.Colour.green(), + timestamp=datetime.now(UTC), + ) + embed.add_field(name="Compra", value=f"$ {data.get('compra', 'N/D')}") + embed.add_field(name="Venta", value=f"$ {data.get('venta', 'N/D')}") + updated = data.get("fechaActualizacion") + if updated: + embed.add_field(name="Actualización", value=str(updated), inline=False) + embed.set_footer(text="Fuente: DolarAPI.com") + await interaction.followup.send(embed=embed) + + @app_commands.command(name="encuesta", description="Crea una encuesta rápida con hasta cinco opciones.") + @app_commands.guild_only() + async def poll( + self, + interaction: discord.Interaction, + pregunta: str, + opcion_1: str, + opcion_2: str, + opcion_3: str | None = None, + opcion_4: str | None = None, + opcion_5: str | None = None, + ) -> None: + options = [option for option in [opcion_1, opcion_2, opcion_3, opcion_4, opcion_5] if option] + emojis = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"] + description = "\n".join(f"{emojis[index]} {option}" for index, option in enumerate(options)) + embed = discord.Embed( + title=f"📊 {pregunta[:240]}", + description=description, + colour=discord.Colour.blurple(), + ) + embed.set_footer(text=f"Encuesta creada por {interaction.user.display_name}") + await interaction.response.send_message(embed=embed) + message = await interaction.original_response() + for emoji in emojis[: len(options)]: + await message.add_reaction(emoji) + + @app_commands.command(name="elegir", description="Elige aleatoriamente entre opciones separadas por |.") + async def choose(self, interaction: discord.Interaction, opciones: str) -> None: + values = [item.strip() for item in opciones.split("|") if item.strip()] + if len(values) < 2: + await interaction.response.send_message( + "Ingresá al menos dos opciones separadas por `|`. Ejemplo: `pizza | sushi | empanadas`.", + ephemeral=True, + ) + return + await interaction.response.send_message(f"🎯 Elijo: **{random.choice(values)}**") + + @app_commands.command(name="dado", description="Tira uno o más dados.") + async def dice( + self, + interaction: discord.Interaction, + caras: app_commands.Range[int, 2, 1000] = 6, + cantidad: app_commands.Range[int, 1, 10] = 1, + ) -> None: + rolls = [random.randint(1, int(caras)) for _ in range(int(cantidad))] + await interaction.response.send_message( + f"🎲 Resultado: **{', '.join(map(str, rolls))}** · total **{sum(rolls)}**" + ) + + @app_commands.command(name="moneda", description="Lanza una moneda virtual.") + async def coin(self, interaction: discord.Interaction) -> None: + await interaction.response.send_message(f"🪙 **{random.choice(['Cara', 'Cruz'])}**") + + @app_commands.command(name="mapa", description="Genera un enlace de búsqueda en Google Maps.") + async def map_link(self, interaction: discord.Interaction, lugar: str) -> None: + encoded = urllib.parse.quote_plus(lugar.strip()) + url = f"https://www.google.com/maps/search/?api=1&query={encoded}" + embed = discord.Embed( + title="🗺️ Buscar ubicación", + description=f"[{discord.utils.escape_markdown(lugar)}]({url})", + colour=discord.Colour.blurple(), + ) + await interaction.response.send_message(embed=embed) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(UtilitiesCog(bot)) diff --git a/pybot/config.py b/pybot/config.py new file mode 100644 index 0000000..2d256fe --- /dev/null +++ b/pybot/config.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +from dotenv import load_dotenv + + +def _optional_int(value: str | None) -> int | None: + if value is None or not value.strip(): + return None + try: + return int(value) + except ValueError as exc: + raise ValueError(f"Se esperaba un entero y se recibió: {value!r}") from exc + + +def _as_bool(value: str | None, default: bool = False) -> bool: + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on", "si", "sí"} + + +@dataclass(frozen=True, slots=True) +class Settings: + discord_token: str + dev_guild_id: int | None = None + database_path: Path = Path("data/pybot.db") + log_level: str = "INFO" + sync_commands: bool = True + enable_message_content_intent: bool = False + default_status: str = "Usá /ayuda" + currency_api_base_url: str = "https://dolarapi.com/v1" + http_timeout_seconds: float = 8.0 + + @classmethod + def from_env(cls) -> Settings: + load_dotenv() + + token = (os.getenv("DISCORD_TOKEN") or os.getenv("DISCORD_KEY") or "").strip() + if not token: + raise RuntimeError( + "Falta DISCORD_TOKEN. Copiá .env.example a .env y configurá el token del bot." + ) + + database_path = Path(os.getenv("DATABASE_PATH", "data/pybot.db")).expanduser() + timeout_raw = os.getenv("HTTP_TIMEOUT_SECONDS", "8") + try: + timeout = float(timeout_raw) + except ValueError as exc: + raise ValueError("HTTP_TIMEOUT_SECONDS debe ser numérico") from exc + + return cls( + discord_token=token, + dev_guild_id=_optional_int(os.getenv("DEV_GUILD_ID")), + database_path=database_path, + log_level=os.getenv("LOG_LEVEL", "INFO").upper(), + sync_commands=_as_bool(os.getenv("SYNC_COMMANDS"), True), + enable_message_content_intent=_as_bool( + os.getenv("ENABLE_MESSAGE_CONTENT_INTENT"), False + ), + default_status=os.getenv("BOT_STATUS", "Usá /ayuda").strip() or "Usá /ayuda", + currency_api_base_url=os.getenv( + "CURRENCY_API_BASE_URL", "https://dolarapi.com/v1" + ).rstrip("/"), + http_timeout_seconds=max(1.0, timeout), + ) diff --git a/pybot/database.py b/pybot/database.py new file mode 100644 index 0000000..497deec --- /dev/null +++ b/pybot/database.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path + +import aiosqlite + + +@dataclass(slots=True) +class GuildSettings: + guild_id: int + welcome_channel_id: int | None = None + farewell_channel_id: int | None = None + modlog_channel_id: int | None = None + autorole_id: int | None = None + + +@dataclass(slots=True) +class Reminder: + id: int + user_id: int + channel_id: int + guild_id: int | None + remind_at: datetime + message: str + + +class Database: + def __init__(self, path: Path) -> None: + self.path = path + self._connection: aiosqlite.Connection | None = None + + async def connect(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + self._connection = await aiosqlite.connect(self.path) + self._connection.row_factory = aiosqlite.Row + await self._connection.execute("PRAGMA journal_mode=WAL") + await self._connection.execute("PRAGMA foreign_keys=ON") + await self._create_schema() + + async def close(self) -> None: + if self._connection is not None: + await self._connection.close() + self._connection = None + + @property + def connection(self) -> aiosqlite.Connection: + if self._connection is None: + raise RuntimeError("La base de datos todavía no fue inicializada") + return self._connection + + async def _create_schema(self) -> None: + await self.connection.executescript( + """ + CREATE TABLE IF NOT EXISTS guild_settings ( + guild_id INTEGER PRIMARY KEY, + welcome_channel_id INTEGER, + farewell_channel_id INTEGER, + modlog_channel_id INTEGER, + autorole_id INTEGER, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS reminders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + channel_id INTEGER NOT NULL, + guild_id INTEGER, + remind_at TEXT NOT NULL, + message TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS self_roles ( + guild_id INTEGER NOT NULL, + role_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (guild_id, role_id) + ); + + CREATE INDEX IF NOT EXISTS idx_reminders_due ON reminders(remind_at); + CREATE INDEX IF NOT EXISTS idx_reminders_user ON reminders(user_id); + CREATE INDEX IF NOT EXISTS idx_self_roles_guild ON self_roles(guild_id); + """ + ) + await self.connection.commit() + + async def get_guild_settings(self, guild_id: int) -> GuildSettings: + cursor = await self.connection.execute( + "SELECT * FROM guild_settings WHERE guild_id = ?", (guild_id,) + ) + row = await cursor.fetchone() + await cursor.close() + + if row is None: + return GuildSettings(guild_id=guild_id) + + return GuildSettings( + guild_id=row["guild_id"], + welcome_channel_id=row["welcome_channel_id"], + farewell_channel_id=row["farewell_channel_id"], + modlog_channel_id=row["modlog_channel_id"], + autorole_id=row["autorole_id"], + ) + + async def update_guild_setting(self, guild_id: int, field: str, value: int | None) -> None: + allowed_fields = { + "welcome_channel_id", + "farewell_channel_id", + "modlog_channel_id", + "autorole_id", + } + if field not in allowed_fields: + raise ValueError(f"Campo de configuración no permitido: {field}") + + await self.connection.execute( + "INSERT INTO guild_settings (guild_id) VALUES (?) " + "ON CONFLICT(guild_id) DO NOTHING", + (guild_id,), + ) + await self.connection.execute( + f"UPDATE guild_settings SET {field} = ?, updated_at = CURRENT_TIMESTAMP " + "WHERE guild_id = ?", + (value, guild_id), + ) + await self.connection.commit() + + async def add_self_role(self, guild_id: int, role_id: int) -> None: + await self.connection.execute( + "INSERT OR IGNORE INTO self_roles (guild_id, role_id) VALUES (?, ?)", + (guild_id, role_id), + ) + await self.connection.commit() + + async def remove_self_role(self, guild_id: int, role_id: int) -> bool: + cursor = await self.connection.execute( + "DELETE FROM self_roles WHERE guild_id = ? AND role_id = ?", + (guild_id, role_id), + ) + await self.connection.commit() + deleted = cursor.rowcount > 0 + await cursor.close() + return deleted + + async def list_self_roles(self, guild_id: int) -> list[int]: + cursor = await self.connection.execute( + "SELECT role_id FROM self_roles WHERE guild_id = ? ORDER BY role_id", + (guild_id,), + ) + rows = await cursor.fetchall() + await cursor.close() + return [int(row["role_id"]) for row in rows] + + async def add_reminder( + self, + *, + user_id: int, + channel_id: int, + guild_id: int | None, + remind_at: datetime, + message: str, + ) -> int: + cursor = await self.connection.execute( + """ + INSERT INTO reminders (user_id, channel_id, guild_id, remind_at, message) + VALUES (?, ?, ?, ?, ?) + """, + ( + user_id, + channel_id, + guild_id, + remind_at.astimezone(UTC).isoformat(), + message, + ), + ) + await self.connection.commit() + reminder_id = cursor.lastrowid + await cursor.close() + if reminder_id is None: + raise RuntimeError("No fue posible crear el recordatorio") + return reminder_id + + async def get_due_reminders(self, now: datetime) -> list[Reminder]: + cursor = await self.connection.execute( + "SELECT * FROM reminders WHERE remind_at <= ? ORDER BY remind_at ASC LIMIT 100", + (now.astimezone(UTC).isoformat(),), + ) + rows = await cursor.fetchall() + await cursor.close() + return [self._row_to_reminder(row) for row in rows] + + async def list_user_reminders(self, user_id: int, limit: int = 10) -> list[Reminder]: + cursor = await self.connection.execute( + "SELECT * FROM reminders WHERE user_id = ? ORDER BY remind_at ASC LIMIT ?", + (user_id, limit), + ) + rows = await cursor.fetchall() + await cursor.close() + return [self._row_to_reminder(row) for row in rows] + + async def delete_reminder(self, reminder_id: int, user_id: int | None = None) -> bool: + if user_id is None: + cursor = await self.connection.execute( + "DELETE FROM reminders WHERE id = ?", (reminder_id,) + ) + else: + cursor = await self.connection.execute( + "DELETE FROM reminders WHERE id = ? AND user_id = ?", + (reminder_id, user_id), + ) + await self.connection.commit() + deleted = cursor.rowcount > 0 + await cursor.close() + return deleted + + @staticmethod + def _row_to_reminder(row: aiosqlite.Row) -> Reminder: + return Reminder( + id=row["id"], + user_id=row["user_id"], + channel_id=row["channel_id"], + guild_id=row["guild_id"], + remind_at=datetime.fromisoformat(row["remind_at"]).astimezone(UTC), + message=row["message"], + ) diff --git a/pybot/logging_config.py b/pybot/logging_config.py new file mode 100644 index 0000000..2f0a812 --- /dev/null +++ b/pybot/logging_config.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import logging +import sys + + +LOG_FORMAT = "%(asctime)s | %(levelname)s | %(name)s | %(message)s" + + +def configure_logging(level: str = "INFO") -> None: + numeric_level = getattr(logging, level.upper(), logging.INFO) + logging.basicConfig( + level=numeric_level, + format=LOG_FORMAT, + handlers=[logging.StreamHandler(sys.stdout)], + force=True, + ) + + logging.getLogger("discord").setLevel(max(numeric_level, logging.INFO)) + logging.getLogger("discord.http").setLevel(logging.WARNING) diff --git a/pybot/utils/__init__.py b/pybot/utils/__init__.py new file mode 100644 index 0000000..1598fc2 --- /dev/null +++ b/pybot/utils/__init__.py @@ -0,0 +1 @@ +"""Utilidades internas de PyBot.""" diff --git a/pybot/utils/timeparse.py b/pybot/utils/timeparse.py new file mode 100644 index 0000000..d3405bb --- /dev/null +++ b/pybot/utils/timeparse.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import re +from datetime import timedelta + + +_DURATION_PATTERN = re.compile(r"^\s*(\d+)\s*([smhdw])\s*$", re.IGNORECASE) +_MULTIPLIERS = { + "s": 1, + "m": 60, + "h": 3600, + "d": 86400, + "w": 604800, +} + + +def parse_duration(value: str) -> timedelta: + """Parsea duraciones simples como 30m, 2h, 3d o 1w.""" + match = _DURATION_PATTERN.match(value) + if not match: + raise ValueError("Usá un formato como 30m, 2h, 3d o 1w.") + + amount = int(match.group(1)) + unit = match.group(2).lower() + seconds = amount * _MULTIPLIERS[unit] + + if seconds <= 0: + raise ValueError("La duración debe ser mayor que cero.") + if seconds > 60 * 60 * 24 * 365: + raise ValueError("El máximo permitido es 365 días.") + + return timedelta(seconds=seconds) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3ff3abb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "pybot-discord" +version = "2.0.0" +description = "Bot profesional y extensible para comunidades de Discord" +requires-python = ">=3.11" +authors = [{ name = "Alejandro Daniel Di Stefano" }] +license = { text = "MIT" } + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +pythonpath = ["."] + +[tool.ruff] +target-version = "py311" +line-length = 100 +exclude = [".venv", "venv", "data"] + +[tool.ruff.lint] +select = ["E", "F", "UP", "B", "ASYNC"] +ignore = ["E501"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..45884cd --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest>=8.0.0,<10.0.0 +pytest-asyncio>=0.23.0,<2.0.0 +ruff>=0.11.0,<1.0.0 diff --git a/requirements.txt b/requirements.txt index 28f13ba..05d23c2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,4 @@ -aiohttp==3.7.4.post0 -async-timeout==3.0.1 -attrs==23.1.0 -certifi==2023.7.22 -cffi==1.15.1 -chardet==4.0.0 -charset-normalizer==2.0.12 -discord.py==2.3.2 -idna==3.4 -multidict==6.0.4 -pycparser==2.21 -PyNaCl==1.4.0 -python-dotenv==0.19.0 -requests==2.26.0 -six==1.16.0 -typing_extensions==4.8.0 -urllib3==1.26.16 -yarl==1.9.2 -youtube-dl==2021.12.17 +discord.py==2.7.1 +aiosqlite>=0.20.0,<1.0.0 +aiohttp>=3.9.0,<4.0.0 +python-dotenv>=1.0.0,<2.0.0 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..104f570 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,31 @@ +from pathlib import Path + +import pytest + +from pybot.config import Settings + + +def test_settings_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DISCORD_TOKEN", "test-token") + monkeypatch.setenv("DEV_GUILD_ID", "123456") + monkeypatch.setenv("DATABASE_PATH", "data/test.db") + monkeypatch.setenv("SYNC_COMMANDS", "false") + monkeypatch.setenv("ENABLE_MESSAGE_CONTENT_INTENT", "true") + monkeypatch.setenv("HTTP_TIMEOUT_SECONDS", "4.5") + + settings = Settings.from_env() + + assert settings.discord_token == "test-token" + assert settings.dev_guild_id == 123456 + assert settings.database_path == Path("data/test.db") + assert settings.sync_commands is False + assert settings.enable_message_content_intent is True + assert settings.http_timeout_seconds == 4.5 + + +def test_settings_requires_token(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("DISCORD_TOKEN", raising=False) + monkeypatch.delenv("DISCORD_KEY", raising=False) + + with pytest.raises(RuntimeError, match="DISCORD_TOKEN"): + Settings.from_env() diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..7586552 --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,96 @@ +from datetime import UTC, datetime, timedelta + +import pytest + +from pybot.database import Database + + +@pytest.mark.asyncio +async def test_guild_settings_roundtrip(tmp_path) -> None: + database = Database(tmp_path / "pybot.db") + await database.connect() + try: + initial = await database.get_guild_settings(100) + assert initial.guild_id == 100 + assert initial.welcome_channel_id is None + + await database.update_guild_setting(100, "welcome_channel_id", 200) + await database.update_guild_setting(100, "autorole_id", 300) + + updated = await database.get_guild_settings(100) + assert updated.welcome_channel_id == 200 + assert updated.autorole_id == 300 + finally: + await database.close() + + +@pytest.mark.asyncio +async def test_self_roles_are_allowlisted_per_guild(tmp_path) -> None: + database = Database(tmp_path / "pybot.db") + await database.connect() + try: + await database.add_self_role(100, 501) + await database.add_self_role(100, 502) + await database.add_self_role(100, 501) + await database.add_self_role(200, 999) + + assert await database.list_self_roles(100) == [501, 502] + assert await database.list_self_roles(200) == [999] + assert await database.remove_self_role(100, 501) is True + assert await database.remove_self_role(100, 501) is False + assert await database.list_self_roles(100) == [502] + finally: + await database.close() + + +@pytest.mark.asyncio +async def test_reminders_are_persistent_and_deletable(tmp_path) -> None: + database = Database(tmp_path / "pybot.db") + await database.connect() + try: + due_at = datetime.now(UTC) + timedelta(minutes=5) + reminder_id = await database.add_reminder( + user_id=1, + channel_id=2, + guild_id=3, + remind_at=due_at, + message="Revisar el deploy", + ) + + reminders = await database.list_user_reminders(1) + assert len(reminders) == 1 + assert reminders[0].id == reminder_id + assert reminders[0].message == "Revisar el deploy" + + assert await database.delete_reminder(reminder_id, user_id=999) is False + assert await database.delete_reminder(reminder_id, user_id=1) is True + assert await database.list_user_reminders(1) == [] + finally: + await database.close() + + +@pytest.mark.asyncio +async def test_due_reminders_only_returns_expired_items(tmp_path) -> None: + database = Database(tmp_path / "pybot.db") + await database.connect() + try: + now = datetime.now(UTC) + await database.add_reminder( + user_id=1, + channel_id=2, + guild_id=None, + remind_at=now - timedelta(seconds=1), + message="Vencido", + ) + await database.add_reminder( + user_id=1, + channel_id=2, + guild_id=None, + remind_at=now + timedelta(hours=1), + message="Futuro", + ) + + due = await database.get_due_reminders(now) + assert [item.message for item in due] == ["Vencido"] + finally: + await database.close() diff --git a/tests/test_timeparse.py b/tests/test_timeparse.py new file mode 100644 index 0000000..acc95cb --- /dev/null +++ b/tests/test_timeparse.py @@ -0,0 +1,26 @@ +from datetime import timedelta + +import pytest + +from pybot.utils.timeparse import parse_duration + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("30s", timedelta(seconds=30)), + ("15m", timedelta(minutes=15)), + ("2h", timedelta(hours=2)), + ("3d", timedelta(days=3)), + ("1w", timedelta(weeks=1)), + (" 10M ", timedelta(minutes=10)), + ], +) +def test_parse_duration_valid(raw: str, expected: timedelta) -> None: + assert parse_duration(raw) == expected + + +@pytest.mark.parametrize("raw", ["", "mañana", "10", "-2h", "1y", "999w"]) +def test_parse_duration_rejects_invalid_values(raw: str) -> None: + with pytest.raises(ValueError): + parse_duration(raw) diff --git a/wsgi.py b/wsgi.py deleted file mode 100644 index 47a6edc..0000000 --- a/wsgi.py +++ /dev/null @@ -1,7 +0,0 @@ -# import sys -# path = '/home/Drako01/Pybot/bot.py' -# if path not in sys.path: -# sys.path.append(path) - -# from bot import keep_alive -# keep_alive() \ No newline at end of file