From 63129dac950591eaf163c7236a856372665bab22 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:05:22 -0300 Subject: [PATCH 01/47] feat: add professional application entrypoint --- main.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 main.py 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") From 90955b5aa63b21459be2097a9ce7aaa472baaeb8 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:05:27 -0300 Subject: [PATCH 02/47] chore: initialize pybot package --- pybot/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 pybot/__init__.py 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" From 601da14db9e2a3f9db88c885f58da50863457a05 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:05:36 -0300 Subject: [PATCH 03/47] feat: add typed environment configuration --- pybot/config.py | 68 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 pybot/config.py diff --git a/pybot/config.py b/pybot/config.py new file mode 100644 index 0000000..93e219a --- /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), + ) From 6384e6c58620cc71466069191264702e3aec0461 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:05:42 -0300 Subject: [PATCH 04/47] feat: add structured logging configuration --- pybot/logging_config.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 pybot/logging_config.py 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) From 780e96485ecd75ccfedb282f0e873c92eaff3369 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:05:59 -0300 Subject: [PATCH 05/47] feat: add sqlite persistence layer --- pybot/database.py | 192 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 pybot/database.py diff --git a/pybot/database.py b/pybot/database.py new file mode 100644 index 0000000..cbd242b --- /dev/null +++ b/pybot/database.py @@ -0,0 +1,192 @@ +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 INDEX IF NOT EXISTS idx_reminders_due ON reminders(remind_at); + CREATE INDEX IF NOT EXISTS idx_reminders_user ON reminders(user_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_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"], + ) From e47a1a2381dd50d1bc7dcc2658cb6306b94cca05 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:06:10 -0300 Subject: [PATCH 06/47] feat: add extensible Discord bot core --- pybot/bot.py | 116 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 pybot/bot.py 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) From d1e9370f144aece65eff948b99638941d2f819bf Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:06:15 -0300 Subject: [PATCH 07/47] chore: initialize cogs package --- pybot/cogs/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 pybot/cogs/__init__.py 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.""" From baca310da080ee905ec253ddba3e7dbe5112d0e0 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:06:36 -0300 Subject: [PATCH 08/47] feat: add core slash commands --- pybot/cogs/core.py | 153 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 pybot/cogs/core.py diff --git a/pybot/cogs/core.py b/pybot/cogs/core.py new file mode 100644 index 0000000..c1f0442 --- /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="🛡️ Moderación", + value="`/limpiar` `/timeout` `/untimeout` `/expulsar` `/banear` `/slowmode`", + inline=False, + ) + embed.add_field( + name="⚙️ Comunidad", + value="`/config-ver` `/config-bienvenida` `/config-despedida` `/config-modlog` `/config-autorol`", + inline=False, + ) + embed.set_footer(text=f"PyBot v{__version__}") + 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)) From da401fb21614acbe26a5f2b51c73496882077a73 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:06:57 -0300 Subject: [PATCH 09/47] feat: add community events and guild configuration --- pybot/cogs/community.py | 161 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 pybot/cogs/community.py diff --git a/pybot/cogs/community.py b/pybot/cogs/community.py new file mode 100644 index 0000000..d9ef6cb --- /dev/null +++ b/pybot/cogs/community.py @@ -0,0 +1,161 @@ +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="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] + + 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)" + + 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) + 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 guild.me is not None and rol >= guild.me.top_role: + await interaction.response.send_message( + "❌ Ese rol está por encima (o al mismo nivel) que 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) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(CommunityCog(bot)) From ce015a5fda31865bd19dbb5bad4c0f29dae5ec01 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:07:19 -0300 Subject: [PATCH 10/47] feat: add moderation toolkit and audit log --- pybot/cogs/moderation.py | 195 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 pybot/cogs/moderation.py 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)) From b8a34dced0dbbd609b620204f4794b03e322cd84 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:07:27 -0300 Subject: [PATCH 11/47] chore: initialize utilities package --- pybot/utils/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 pybot/utils/__init__.py 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.""" From 18ac22df6114c840e957cbfcf5b9f10feb1e0f07 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:07:34 -0300 Subject: [PATCH 12/47] feat: add human duration parser --- pybot/utils/timeparse.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 pybot/utils/timeparse.py 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) From 941da08428db25e96a07735ac3d7da5afa3ade61 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:07:49 -0300 Subject: [PATCH 13/47] feat: add useful community utility commands --- pybot/cogs/utilities.py | 134 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 pybot/cogs/utilities.py 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)) From 69faf5d581a5fc307fba1037cb5de5ef38694b07 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:08:03 -0300 Subject: [PATCH 14/47] feat: add persistent reminder system --- pybot/cogs/reminders.py | 111 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 pybot/cogs/reminders.py 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)) From b3799d76fa9fcdb4e01a3ef76e12e08dc651f10c Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:08:32 -0300 Subject: [PATCH 15/47] chore: modernize production dependencies --- requirements.txt | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) 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 From db1c9257b0d018630647513e68f6ab2525b72d6f Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:08:39 -0300 Subject: [PATCH 16/47] chore: add development quality dependencies --- requirements-dev.txt | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 requirements-dev.txt 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 From d702f3bcf053733fa7b4660a449e3e49a6304163 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:08:48 -0300 Subject: [PATCH 17/47] chore: configure Python tooling --- pyproject.toml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..038a442 --- /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", "I", "UP", "B", "ASYNC"] +ignore = ["E501"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" From 5c64d747f27ee74f2ab841b541acef98535e72d9 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:08:54 -0300 Subject: [PATCH 18/47] docs: add environment template --- .env.example | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .env.example 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 From c9f20c6215eab651e101aac8ef4c93da03ee4c20 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:09:01 -0300 Subject: [PATCH 19/47] chore: harden Python gitignore --- .gitignore | 47 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 7 deletions(-) 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/ From f8b9076af54a536eb015f2a8438be394551863d3 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:09:08 -0300 Subject: [PATCH 20/47] chore: run Discord bot as worker process --- Procfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 05a5689a92c86f2ec7321336e12dadc3d3bbeb31 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:09:15 -0300 Subject: [PATCH 21/47] ci: add Python quality pipeline --- .github/workflows/ci.yml | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/ci.yml 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 From 331f44abd0c3ae6048983e1b5206e8e24c8f18cc Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:09:25 -0300 Subject: [PATCH 22/47] test: cover reminder duration parser --- tests/test_timeparse.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/test_timeparse.py 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) From 00ba26c2bd2704ef1a03ad9ef7feb7bb129fbf88 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:09:33 -0300 Subject: [PATCH 23/47] test: cover environment configuration --- tests/test_config.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 tests/test_config.py 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() From e57e4885bf55e6b2cff21ce1a4fe9c76bf096578 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:09:43 -0300 Subject: [PATCH 24/47] test: cover sqlite persistence --- tests/test_database.py | 77 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/test_database.py diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..f93a64a --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,77 @@ +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_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() From 2ee97e380924201379eb707b806dcb6ba56f247b Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:09:49 -0300 Subject: [PATCH 25/47] chore: add production container image --- Dockerfile | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 Dockerfile 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"] From 09220fdbc0e0ec1618147e2205d6de1b57298408 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:09:57 -0300 Subject: [PATCH 26/47] chore: add dockerignore --- .dockerignore | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .dockerignore 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 From dd2e00c1ce41130b9fc6a0e9d7d666317e7ee4c0 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:10:22 -0300 Subject: [PATCH 27/47] feat: add secure self-assignable roles persistence --- pybot/database.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/pybot/database.py b/pybot/database.py index cbd242b..497deec 100644 --- a/pybot/database.py +++ b/pybot/database.py @@ -72,8 +72,16 @@ async def _create_schema(self) -> None: 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() @@ -118,6 +126,32 @@ async def update_guild_setting(self, guild_id: int, field: str, value: int | Non ) 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, *, From c29d456aca51ec2a289f0a27853ebb45224f3655 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:10:51 -0300 Subject: [PATCH 28/47] feat: add safe self-assignable roles --- pybot/cogs/community.py | 92 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/pybot/cogs/community.py b/pybot/cogs/community.py index d9ef6cb..f7b56d2 100644 --- a/pybot/cogs/community.py +++ b/pybot/cogs/community.py @@ -56,6 +56,63 @@ async def on_member_remove(self, member: discord.Member) -> None: 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) @@ -63,6 +120,7 @@ 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: @@ -76,6 +134,9 @@ def role_name(role_id: int | None) -> str: 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(), @@ -84,6 +145,7 @@ def role_name(role_id: int | None) -> str: 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.") @@ -143,9 +205,9 @@ async def config_autorole( guild = interaction.guild assert guild is not None - if rol is not None and guild.me is not None and rol >= guild.me.top_role: + 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 está por encima (o al mismo nivel) que mi rol más alto.", + "❌ Ese rol no puede ser administrado por el bot o está por encima de mi rol más alto.", ephemeral=True, ) return @@ -156,6 +218,32 @@ async def config_autorole( 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)) From c452580e03d39ff50419305d6e7620d1ac9fded4 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:11:02 -0300 Subject: [PATCH 29/47] refactor: remove legacy monolithic bot entrypoint --- bot.py | 53 ----------------------------------------------------- 1 file changed, 53 deletions(-) delete mode 100644 bot.py 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) From 4f74f6f6fd80b75845c37d583207200c836f3bd2 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:11:07 -0300 Subject: [PATCH 30/47] refactor: remove legacy event handlers --- events.py | 131 ------------------------------------------------------ 1 file changed, 131 deletions(-) delete mode 100644 events.py 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) From 0acdaa7a21b4b5a0a243fe6d8e54c1431df7f445 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:11:12 -0300 Subject: [PATCH 31/47] refactor: remove obsolete web entrypoint --- wsgi.py | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 wsgi.py 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 From c059f376c24d5ad12515297c807fafc6638a3792 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:11:16 -0300 Subject: [PATCH 32/47] chore: remove tracked Python bytecode --- __pycache__/events.cpython-311.pyc | Bin 8130 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 __pycache__/events.cpython-311.pyc diff --git a/__pycache__/events.cpython-311.pyc b/__pycache__/events.cpython-311.pyc deleted file mode 100644 index b104c9af6c9b0daa32a159cc2e728574270e97ee..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8130 zcmc&(U2GdycAgWt-OO#;&Z;&`u9KI~ zMNVBBJbA@%qvDxMm(C0hL~W%>QAWW358?mGQ&`IdSmzxqg-xJ0<}*=Qp#%~SV<|ja zJ-XZtjVbF3t6h28ma=6Xm5x;f7=vavpV~1$1TFum{VI0H{uhq4J!PLD%l*=h>eZzQ z7n-@E)d^>%AF058mY)f8mr*V2DYsSUDaVAjTu+goK&XpzX$)g#K~7mq}vJ29oLJOaaZ!AjCjrDMH?v!$G$VE1gb ztH81E!`x}-drri|D$U?C*e(0_u^BvUA9{cr4uMVZNoqinMClk4-e!;qJ~6^ehJ8d- z49^&^M55!YAnI4`3nru;mu8#wm%KYA~D+hL?8hd-`(0<9hJ8MsFnO=dGW=I5)873(nu1J6&{mh}J@&@nfGJ z*qI?7`y1xdt9~Z$XR_Nr-LZT`^D{YrukP>7IMzIV&9kH6uV0w{IHmbJfeHfQav>?^Hx{YUI8M+hiy z4OgRE!d2R)RE{l%O;pO+3OK=hl_zDJr~ng(e{S1w>;ZVBZq8l}Q=ed*o#0M>S0VKy1mMBR8yuy0_sXsh%-Sa?VrOJFNgNoRC|Ml>vr8anp7FoRix4&F({Hvu><3}Gr zW7uWE!#3zAp_@Xghs1D}KtwhGMFbOW95OF(5}Mrbns`*ggs{`X76d*fFQY!n3T!;W z@sV|H8QV+3)iz5lXw(jrAZ(J+=9CdhLf~%DfI1fVMkd6VAcdi^3w8Z1Bqj9m*3A#j z4Lok?xcl;I%ies;-lc)hhaQErmc6-_A-!ctb2P3|Ek(>moG37Z4`2QD35^*nk+Zcx zcdXLgdAeKM-It?}>wrkkEvSTOT3b(!KBUu!Ogy_Y__IN+LtUEw{5{Al$_1zO;Iu|> zq`5O|+pZ&!*kdLcqCjt7rFZ7(o!KKfI;_)S&ABxpl&}Up0im&I#TJ1I+I`b*`5FqA z-NNCwpJBV4!ajpu>(*ErPvKG*XWQDA;q1T%-#BK&5w&rSlnvdoX}e(K2(VJBETCuD zJI^_)mR%4ghc*d`zRM@4A-RK%fq3tb=G%v{2Uyr) zkg~$U*^@Xq5 z2$UC5-Y0`b+rw7&&u~e+JgLI+SQ#BG1Vt7MH9c#qrQ^^K=}-PkNJ@ZY?r-+!r^ofq z*I=h}jS(H19KkiJv4}Z{%Xp!=^?viun@hUt8-zlx^P&!E^N`*=l%Wg3iw|#V!HYl{ zSH`s#yab7R+x&r5cU#`wwm6$}hjn*Yb3Btw_1D-8$lf!dLV%0uNB+%D4xFb7v{>h* zH#x51RrPi zf-)gc77r*p1)OuGZPqxblTfG9b_K~-de$8(rQ*F0bFt`O;2+N;>W2{K!G!0^k@{t-UfF|L8vW3(w63pf<9n{*&#U$z?XGqbCyOd7uKLVn5wLH1QX9ToX%%L#^qL5ki)o*-w_L`sjDHm8 znr=I}=I3AF62-Noe6ZFXV9(RSk2diL0gu)f@@O-8w3|HmO*|Oj(eXkaJ53&)CXfD2 zJaz$(t{3v?HhJted310unTXP;psTR-Y}97$S8Rp^!t1j90sBnXelc6{L%zeFA%oz= zI4hY}I=Au#6b%QjN+Pm1V$m2IHyk5zl{ZLaTS%-yATOLXyrs7hv#$$vF^k_Id(Uu9 zv#4Q2oi*H65!?pgGysgvnNFEAC4B>N<|-Q(C5GeUjDZ71l1{i*q0a0|x-6F?&NAws z09&|-W$;2&PGm~g9s{pp!l)=E*l29!3jqy-ri3W)95^qf3%YzCGv@?lKg-7 z?$)GqBH3!X9A*rs%8)C+=3$il5+vVRm%T=-K-sIA>BUBfMOfA9)Vzqpq~REc{Wpkl z*dWocjmMJCH$Y5=i?K-`DzPQ?$d#%fM#p(MNt}g&jJgx%>|nn7vVY}^gorF67=Sb% zXSkJrWRarB#o6!t|MewZf(cBGNtcm~)#{Okq2&dN8AF4r6%#nG1`IBu^R--f$eNKz~@vU!Zyn%y=PevkAY8nOk^Y^_#aQK*e0>AYk})kVxej^Sz>{6 z!Op4>CIInJ4%^SiCk@K1BzUzO*j^f$j)74wDEvOySJ=xbb>#sAfdDW;;p|!CA(HS% z+|+kBNk6OwhO4AU)oLnls$GUHg=i)UnELozaGDif0_$4glkUqXz9F!1>XQfef%OkI zzreg9DhxVf5(}b7MS;0ItBgZ737`e9&$7{Xd8uQ+bP!gQWWHx<2;46P6vY70AiqN3 zqr4nb0M^2dpnnw@0LrT1I6zob1U(B7VU3G3PQV?St_@}wvLKR3sM zr()hAh9??lAvz3*5AqRKMp|HOY%9SV1Z_*MjNzJ;#Oas-my8iqM`BUHaDX_FG6pvf zY=hyq78yyflX3)Uf?y{_2xIlZf}|^u$jEL2XQ=x5uH_lI*U?f!-oNq%$n1`a8z@)04GiuZ&NWZH?rF~+&U?BwPj{iY<9_SUTmP>8ZhPi@A$(x@ zwS4$>E&Tf8-o?F-LYWJNVB4ai2X|$Epa*+018e^6)-~d13eD{csnVYN?Tsf_}6Ij8#Ci;F|BhL=+OZy&(Uw{^qZP< z3tFSxw{NXdp*$5@yq2T7bgHX}1&ICV2MAgZbi)r29=iH=*A6I9&C#k3sYlg4Y7z6g zPT_^7b6G-bI%iSlT;|+kU!dj!>^TS3T;RM8sqeh*J72`y=D-74Rckt6QDtD&71MJK zuA5xox(=!Dy6%IZqSKo3(e}+VR-tpx6+r4-U|5ILH>~@Hix_DRd^`J=*7UYTXvTuC zVR5(S>i~LOw`b{SuI{j2cUbdoK?QGJ=JZ-q|HC6%Q@=%-bJe=<&CxIG^vjy_nN;im z1)Ey7E)Uv@$9XM0-EYFFGzG9Cp}W|y!No#aKkQ1#MgfTB(XYG`a*8AcCFs`_Gg55A zs|ib%VY3IlKyl@#&c1}msd4^dYy@G?5HYcXi{ufMfAVmNG From 22b9c01aff00f7333873a68d791a8de858947f91 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:11:20 -0300 Subject: [PATCH 33/47] chore: remove tracked Python bytecode --- __pycache__/messages.cpython-311.pyc | Bin 3181 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 __pycache__/messages.cpython-311.pyc diff --git a/__pycache__/messages.cpython-311.pyc b/__pycache__/messages.cpython-311.pyc deleted file mode 100644 index e0618a9a2d056cd36cc3b0224af00fc8be7fdbd0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3181 zcma)8U2NOd73Pmb>d#axDL-=Un2zlvsspD^k-1*HZfTnYse`y{QU`E5XteHCOphXE zlCm3*S`5@1^pFQX1c=)tsQQwn=pGCd7>d0#SYGl-2?Q(<2w-3su)Mj*`jVIJTyyx6=&-w1T=l+m??&=CrP-f>6m;>n_q|q$ChVcBaAl#u4g=mGEC7e;{95c(% zbsanF^ZNX=evfmr9HtidCQLcdl-l_NXnfOB%mxvQgJ~}oqGTw{EQFig$cKY}@jqa| z1KxU>jo`>aSF?qnI=xR2l_~ zOx+XO9JKi&YqpXNGvS&6qR<1##y72u6n$p2*#~y5g|&Qp?EIF0f#l9BDBQ@%J|*aq)}B0n1_$f+> z{gaQTRPogaXn*GlbRt-ExozEc$3+J^z4SX4Z4SJ=A07N-+Zq(Z=8;|7g;-NA$D2|+ zx5Axc6(cX%bBFfWby;0^DJyc9y3Z7aS7*Ws!}=sG0qogMq|ThQpWSkzJ<_IZQUA-I zoNn@_dM>+uq$!eKAm;`>{uV^nhYPyq@>*8T$dcmv=9B_ zVMTOlp!7;W#0Ip*p_QPRmvl)~B(d;YfW54V1{U)Lq>0)bTzyz4U9y_h^qiEDAAhS7 zH&Ep?6(dQ+Dj=B{nmGtx8x=3A=HstnB8o-~)dF79#N6Yr4AGQyghvZx%l-X(NQesE ziv@!5>_1T45SibF>FEoX&aHHlAh4P#044+ngQWJmoK!I9HQiH&}KBl<*s9hd&v#x7~l=la9Pl}VZ)Tnf|1T>2)n)oLsMN|$9YA{VApTTrh)-& zUQ$&IIW(|}+z@!^rm-xW^De7k)#VDhqR4ZuA2Mcu*J434vAZv?YZ+`1 zD5UEE30X0u1sKtk^BPPclaXO$7LZ7QMqX0^DTE|bN_*SI4QCW-38!I2VXFQ*a!Hb+}BMM)zf5Ed#?Qm2ey7q_r@q34={D935 z)B-|@18wxcEp1B}tO|n_Vb~FdS1*7vxNm*j4)g$R%s)J4A07kRocV6qK7GD&`ur2w z{&>#5>`_*+^UHR2xsqMpp{Sem1qRp@^C4k3>2@~5{DvqcW}dJaU_aE^cJJ`RgLdy5 zK&2a{8;`gr$LwGI8fY`SnYCrjHdbueqifi{hQawPbBQVWYJJ47?+u{!L+gh&ua~$V zVLsirIH%uonRQ}jWjFrx9NB3_Ww4)+WE!n@$t#jMS zfpu=jM-9BgY;q76@Ou4S;``pe^*+ixd9yMx?MzH>O?*_H_^2{*&6&8?Zf8r`j^^U> z)?%@`Sgb7GbQW)RbVd4VvE=QOTd||n*irk~Tb0;^6PvIjKTX@awt#Z{eUj_=6!UMd z22Ix#iS*Sq*)>~yvdSmzo{Yg*D=(ez* zY{DZ|;YeAw2d@AODl|$K+$-9mcOI{g(vc7f`(jOy)FHQ1ILOMQD;gG~*6bJ1kApHA-0Zo*E_E zom8XZc9UvUbk%!mRB+XMc0yD*cI)I;XrLMzsDuWc(BLY+9XqhfuSK`H*y{Y6QXYJ` lY;)rkZrtI Date: Sun, 23 Aug 2026 23:12:01 -0300 Subject: [PATCH 34/47] docs: rewrite README as professional PyBot guide --- README.md | 502 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 489 insertions(+), 13 deletions(-) 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. From 018e01467deb81390af3cab87117434dd2dc9876 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:12:21 -0300 Subject: [PATCH 35/47] docs: document PyBot architecture --- docs/ARCHITECTURE.md | 165 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 docs/ARCHITECTURE.md 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. From 91dceb1872bfa69639b8bc428639a9a6b8918e60 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:12:39 -0300 Subject: [PATCH 36/47] docs: add complete command reference --- docs/COMMANDS.md | 167 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/COMMANDS.md 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. From 7376cba91520f76ddca88770c2a787e77dfebde1 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:12:54 -0300 Subject: [PATCH 37/47] docs: add deployment guide --- docs/DEPLOYMENT.md | 135 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/DEPLOYMENT.md 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. From c766cf530d2e5e766640a5ef92c06af19bbb2d88 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:13:06 -0300 Subject: [PATCH 38/47] docs: add security policy --- SECURITY.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 SECURITY.md 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. From bf3c1825f37c1450f6907020e7b3813aeb8770a3 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:13:17 -0300 Subject: [PATCH 39/47] docs: add contribution guide --- CONTRIBUTING.md | 77 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 CONTRIBUTING.md 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. From c16a913a728f7795be8b62eccb231d33ef0a3eec Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:13:28 -0300 Subject: [PATCH 40/47] docs: add v2 changelog --- CHANGELOG.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 CHANGELOG.md 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. From 56d45f52f8fdbba3ffb3a5397dabf34f5b098ef1 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:13:39 -0300 Subject: [PATCH 41/47] docs: prepare PyBot v2 release notes --- RELEASE_NOTES_v2.0.0.md | 70 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 RELEASE_NOTES_v2.0.0.md 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** From 1fc144940ab0041688ec37ac56db7bbde8a16b96 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:13:49 -0300 Subject: [PATCH 42/47] docs: normalize MIT license filename --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..51590f3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Alejandro Daniel Di Stefano + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +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. From 1a00ebdb3a0b0ec7456e0f3dd3c1b9e3efbadccb Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:13:56 -0300 Subject: [PATCH 43/47] docs: remove noncanonical license filename --- LICENCE | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 LICENCE diff --git a/LICENCE b/LICENCE deleted file mode 100644 index e34f770..0000000 --- a/LICENCE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 Alejandro Daniel Di Stefano - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -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 From 242fb9baae2130a037aee3681c1f60e168e53a92 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:14:10 -0300 Subject: [PATCH 44/47] test: cover self-role allowlist persistence --- tests/test_database.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_database.py b/tests/test_database.py index f93a64a..7586552 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -24,6 +24,25 @@ async def test_guild_settings_roundtrip(tmp_path) -> None: 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") From 689c6259ef914f9183f04e8e925b604dc04f375b Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:14:29 -0300 Subject: [PATCH 45/47] docs: expose role commands in in-app help --- pybot/cogs/core.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pybot/cogs/core.py b/pybot/cogs/core.py index c1f0442..817e303 100644 --- a/pybot/cogs/core.py +++ b/pybot/cogs/core.py @@ -40,16 +40,16 @@ async def help_command(self, interaction: discord.Interaction) -> None: inline=False, ) embed.add_field( - name="🛡️ Moderación", - value="`/limpiar` `/timeout` `/untimeout` `/expulsar` `/banear` `/slowmode`", + name="🎭 Comunidad", + value="`/roles` `/rol` · administración con `/config-*`", inline=False, ) embed.add_field( - name="⚙️ Comunidad", - value="`/config-ver` `/config-bienvenida` `/config-despedida` `/config-modlog` `/config-autorol`", + name="🛡️ Moderación", + value="`/limpiar` `/timeout` `/untimeout` `/expulsar` `/banear` `/slowmode`", inline=False, ) - embed.set_footer(text=f"PyBot v{__version__}") + 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.") From d9219b57386f947bcc317014616da667c98f4d50 Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:16:07 -0300 Subject: [PATCH 46/47] style: modernize Settings return annotation --- pybot/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pybot/config.py b/pybot/config.py index 93e219a..2d256fe 100644 --- a/pybot/config.py +++ b/pybot/config.py @@ -35,7 +35,7 @@ class Settings: http_timeout_seconds: float = 8.0 @classmethod - def from_env(cls) -> "Settings": + def from_env(cls) -> Settings: load_dotenv() token = (os.getenv("DISCORD_TOKEN") or os.getenv("DISCORD_KEY") or "").strip() From 043f641c1ab5343a9fa1d694aec9914008557f1a Mon Sep 17 00:00:00 2001 From: Alejandro Daniel Di Stefano Date: Sun, 23 Aug 2026 23:16:17 -0300 Subject: [PATCH 47/47] ci: focus Ruff on correctness rules --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 038a442..3ff3abb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ line-length = 100 exclude = [".venv", "venv", "data"] [tool.ruff.lint] -select = ["E", "F", "I", "UP", "B", "ASYNC"] +select = ["E", "F", "UP", "B", "ASYNC"] ignore = ["E501"] [tool.ruff.format]