diff --git a/bot.py b/bot.py index ea9d4df..e9be85b 100644 --- a/bot.py +++ b/bot.py @@ -1,4 +1,5 @@ import asyncio +import csv import pandas as pd import discord import logging @@ -16,6 +17,11 @@ from comandos.archivar import Archivar from comandos.enviar import Enviar +# Every cog to register on startup. Add a class here (and to the imports +# above) to wire up a new command/listener group - nothing else needs to +# change. +COGS = (Ping, Ayuda, Limpia, Archivar, Moderacion, FloodSpam, Enviar) + # Global instance of the server guild = None @@ -28,36 +34,39 @@ handler = logging.FileHandler(filename="bot.log", encoding="utf-8", mode="w") discord.utils.setup_logging(level=logging.INFO, handler=handler) +logger = logging.getLogger(__name__) @bot.event async def on_message(message: discord.Message): - # Main log - with open(config.log_main_file, "a") as f: + # Main log. Uses csv.writer (not hand-built quoting) so a message + # containing a literal '"' or a newline - both common in real messages - + # doesn't silently corrupt the row. + with open(config.log_main_file, "a", newline="") as f: date_str = f"{datetime.now()}" - line = ( - f'"{date_str}";' - f'"{message.id}";' - f'"{message.channel}";' - f'"{message.author.id}";' - f'"{message.author}";' - f'"{message.content}"\n' - ) - f.write(line) + csv.writer(f, delimiter=";").writerow([ + date_str, + "", # command - this log covers every message, not just commands + message.id, + message.channel, + message.author.id, + message.author, + message.content, + ]) @bot.event async def on_ready(): - print("Syncing tree...") + logger.info("Syncing tree...") # await bot.tree.sync() @bot.event async def on_command_error(msg, error): if isinstance(error, (commands.MissingRole, commands.MissingAnyRole)): - print(f"MissingRole ERROR: {error}") + logger.warning("MissingRole ERROR: %s", error) else: - print(error) + logger.error("Unhandled command error", exc_info=error) async def main(): @@ -73,21 +82,15 @@ async def main(): # keeping the data in the bot instance bot.data_mod = data_mod[~data_mod["message_id"].isin(ready_ids)] # type: ignore[attr-defined] - await bot.add_cog(Ping(bot)) - await bot.add_cog(Ayuda(bot)) - - await bot.add_cog(Limpia(bot)) - await bot.add_cog(Archivar(bot)) - - await bot.add_cog(Moderacion(bot)) - await bot.add_cog(FloodSpam(bot)) - await bot.add_cog(Enviar(bot)) + for cog_cls in COGS: + await bot.add_cog(cog_cls(bot)) # Removing the help command # bot.remove_command("help") - print("Running...") + logger.info("Running...") await bot.start(config.TOKEN) -asyncio.run(main()) +if __name__ == "__main__": + asyncio.run(main()) diff --git a/colors.py b/colors.py new file mode 100644 index 0000000..617c5c1 --- /dev/null +++ b/colors.py @@ -0,0 +1,21 @@ +"""Shared Discord embed colors, used across all cogs. + +Centralizing these avoids the same hex value being redefined under +different names in different modules (``WARNING_COLOR`` in flood.py and +``EMBED_COLOR`` in moderacion.py were both ``0x2B597B``), and gives every +other embed color in the project a name instead of leaving it as an +unexplained magic number. +""" + +# The project's primary/brand color - used for most informational and +# warning embeds: moderation alerts, help text, moderation confirmations. +BRAND = 0x2B597B + +# archivar.py's "channel archived" confirmation embed. +ARCHIVE = 0xFF0000 + +# limpia.py's "messages purged" confirmation embed. +SUCCESS = 0x178D38 + +# enviar.py's broadcast-message embeds. +BROADCAST = 0xFDC130 diff --git a/comandos/archivar.py b/comandos/archivar.py index e36449a..028f609 100644 --- a/comandos/archivar.py +++ b/comandos/archivar.py @@ -1,12 +1,15 @@ +import logging from datetime import datetime from typing import List, Optional import discord from discord.ext import commands +import colors from configuration import Config config = Config() +logger = logging.getLogger(__name__) class Archivar(commands.Cog): @@ -28,15 +31,15 @@ async def archivar(self, ctx, *, channel: discord.TextChannel) -> Optional[disco filename = f"{timestamp}_canal_{channel.name}.csv" messages = [m async for m in channel.history(limit=None)] - status = self.archivar_canal(filename, messages) + status, archived_filename = self.archivar_canal(filename, messages) if status: e = discord.Embed( title="\N{PAGE FACING UP} Canal Archivado", description=f"El canal {channel.mention} tiene {len(messages)} mensajes", - colour=0xFF0000, + colour=colors.ARCHIVE, ) - await self.mod_channel.send(embed=e, file=discord.File(filename)) + await self.mod_channel.send(embed=e, file=discord.File(archived_filename)) else: await self.mod_channel.send(f"Error: Canal '{channel.name}' no fue archivado.") @@ -53,6 +56,14 @@ async def archivar_categoria(self, ctx, *, category: discord.CategoryChannel): await self.archivar(ctx, channel=channel) def archivar_canal(self, filename: str, messages: List[discord.Message]): + """Write ``messages`` to ``filename`` as CSV. + + Always returns a ``(success, filename)`` tuple - never just a bare + falsy value - so callers can safely do + ``status, archived_filename = self.archivar_canal(...)`` and check + ``status`` directly, instead of risking a non-empty-but-failed + tuple being treated as truthy. + """ try: with open(filename, "w") as f: f.write( @@ -63,7 +74,7 @@ def archivar_canal(self, filename: str, messages: List[discord.Message]): for msg in messages: if not isinstance(msg.channel, (discord.TextChannel, discord.Thread, discord.VoiceChannel)): - return + return False, None m_id = msg.id m_content = msg.content.strip().replace("\n", "\\n") @@ -78,9 +89,9 @@ def archivar_canal(self, filename: str, messages: List[discord.Message]): f"{m_id};{m_content};{m_channel_id};{m_channel_name};{m_channel_category};" f"{m_author_id};{m_author_name}#{m_author_discriminator};{m_author_bot}\n" ) - print(f"File written: {filename}") - except Exception as e: - print(f"{type(e).__name__}: {e}") + logger.info("File written: %s", filename) + except Exception: + logger.exception("Failed to archive channel into %s", filename) return False, None return True, filename diff --git a/comandos/ayuda.py b/comandos/ayuda.py index b319f2c..607ac6e 100644 --- a/comandos/ayuda.py +++ b/comandos/ayuda.py @@ -1,8 +1,8 @@ import discord from discord.ext import commands +import colors from configuration import Config -from utils import get_moderation_channel config = Config() @@ -17,21 +17,12 @@ async def mensaje_ayuda(self, ctx): if ctx.author.id == config.BOT_ID: return - # Check which channel combination we are using from the - # configuration information - channel_mod = get_moderation_channel(self.bot, ctx.channel.id) - - if channel_mod: - e = self.get_mod_help() - await channel_mod.send(embed=e) - else: - e = self.get_main_help() - await ctx.channel.send(embed=e) + await ctx.channel.send(embed=self.get_mod_help()) def get_mod_help(self): embed = discord.Embed( title="Comandos Disponibles", - colour=0x2B597B, + colour=colors.BRAND, ) embed.add_field( name="`%mod`", @@ -62,28 +53,3 @@ def get_mod_help(self): inline=False, ) return embed - - def get_main_help(self): - embed = discord.Embed( - title="Comandos Disponibles", - colour=0x2B597B, - ) - embed.add_field( - name='`%encuesta "pregunta"`', - value=( - "Para hacer preguntas de Sí y No.\n" 'Ejemplo:\n `%encuesta "¿Te gusta el té?"`' - ), - inline=False, - ) - embed.add_field( - name='`%encuesta "pregunta" "opción a" "opción b" ...`', - value=( - "Para hacer preguntas con varias opciones.\n" - 'Ejemplo:\n `%encuesta "¿Lenguaje favorito?" "Inglés" "Español" "Python"`' - ), - inline=False, - ) - embed.set_footer( - text='Importante: La pregunta y opciones deben ir entre comillas dobles "..."' - ) - return embed diff --git a/comandos/enviar.py b/comandos/enviar.py index ce69019..2aeeb67 100644 --- a/comandos/enviar.py +++ b/comandos/enviar.py @@ -1,10 +1,10 @@ from discord.ext import commands from discord import TextChannel, Embed, app_commands +import colors from configuration import Config config = Config() -COLOR_MSG = 0xfdc130 class Enviar(commands.Cog): @@ -22,7 +22,7 @@ async def enviar(self, ctx: commands.Context, channel: TextChannel, *, message: reply_embed = Embed( title=f"Mensaje enviado a {channel}", description=f"{channel.mention}:\n{message}", - colour=COLOR_MSG, + colour=colors.BROADCAST, ) try: @@ -33,7 +33,7 @@ async def enviar(self, ctx: commands.Context, channel: TextChannel, *, message: embed = Embed( title="Mensaje de Coordinación", description=message, - colour=COLOR_MSG, + colour=colors.BROADCAST, ) # Send the command to the channel passed to the command diff --git a/comandos/flood.py b/comandos/flood.py index 282a5c1..5cc5993 100644 --- a/comandos/flood.py +++ b/comandos/flood.py @@ -1,19 +1,22 @@ import hashlib +import logging import time +from dataclasses import dataclass from io import BytesIO import discord from discord.ext import commands, tasks from PIL import Image, UnidentifiedImageError +import colors from configuration import Config -from messages import Messages from utils import strip_message from typing import Optional config = Config() +logger = logging.getLogger(__name__) SPAM_WORDS = [ ("discord", "nitro", "free", "http"), @@ -31,7 +34,37 @@ ("gratis", "full", "youtube.com", "telegra.ph"), ] -WARNING_COLOR = 0x2B597B + +@dataclass(frozen=True) +class MessageContext: + """Per-message state, threaded explicitly through the ``*_check`` + methods and ``alert_moderation`` as a parameter. + + This used to live on shared ``FloodSpam`` instance attributes + (``self._msg_channel``/``_msg_content``/``_msg_author``/ + ``_msg_author_mention``), set once at the top of ``on_message`` and + read back by whichever check ran next. That made those methods hard + to call/test independently, and - since real handling has plenty of + ``await`` points in between - a second ``on_message`` call for a + *different* message running concurrently could overwrite that shared + state while the first call was still relying on it. + """ + + message: discord.Message + content: str # message.content, stripped via strip_message() + + @property + def channel(self): + return self.message.channel + + @property + def author(self): + return self.message.author + + @property + def author_mention(self): + return self.message.author.mention + # Modal view to 'ban' or 'remove role' from users that get reported # as spam. @@ -57,21 +90,17 @@ def __init__(self, bot): self.bot = bot self._main_mod_channel: Optional[discord.TextChannel] = None - self.messages = Messages() - self.messages.spam = config.get_spam_messages() - self.messages.normal = {} - self.messages.image_spam = config.get_spam_image_hashes() - self.messages.image_authors = {} + # Known spam/scam text and image hashes, plus the short-lived + # per-author tracking used to detect floods and image bursts. + self.spam = config.get_spam_messages() + self.normal = {} + self.image_spam = config.get_spam_image_hashes() + self.image_authors = {} self.guild = None self._coord_role: Optional[discord.Role] = None self._muted_role: Optional[discord.Role] = None - self._msg_channel: Optional[discord.TextChannel | discord.ForumChannel | discord.VoiceChannel] = None - self._msg_content: Optional[str] = None - self._msg_author: Optional[discord.Member] = None - self._msg_author_mention: Optional[str] = None - @property def muted_role(self) -> discord.Role: assert self._muted_role is not None, "Muted role not found - make sure it exists first" @@ -107,12 +136,12 @@ async def on_ready(self): # Remove messages every hour @tasks.loop(seconds=60 * 30) async def clear_messages(self): - self.messages.normal = {} - self.messages.image_authors = {} + self.normal = {} + self.image_authors = {} @commands.Cog.listener() async def on_message(self, message): - print("FloodSpam.on_message") + logger.debug("on_message: %s", message.id) await self.bot.process_commands(message) if message.author.bot or message.author.id == config.BOT_ID: @@ -122,148 +151,143 @@ async def on_message(self, message): # (e.g. an image-only spam message has no text at all). if len(message.content) < 5 and not message.attachments: return - self._msg_channel = self.bot.get_channel(message.channel.id) - self._msg_content = strip_message(message.content) - self._msg_author = message.author - self._msg_author_mention = self._msg_author.mention + + ctx = MessageContext(message=message, content=strip_message(message.content)) # skip coord role - if self.coord_role in self._msg_author.roles: + if self.coord_role in ctx.author.roles: return - print("FloodSpam.on_message: attachment_check") - if await self.attachment_check(message): + if await self.attachment_check(ctx): return - if await self.flood_check(message): + if await self.flood_check(ctx): return - if self._msg_content in self.messages.spam: + if ctx.content in self.spam: await self.alert_moderation( + ctx, "Alerta de SPAM (Mensaje conocido)", "known", ) # Set muted role - await self._msg_author.add_roles(self.muted_role) + await ctx.author.add_roles(self.muted_role) await discord.Message.delete(message) msg = ( - f"El mensaje del usuario {self._msg_author_mention} fue borrado por ser un " + f"El mensaje del usuario {ctx.author_mention} fue borrado por ser un " "mensaje detectado previamente como spam.\n" ) embed = discord.Embed( title="\N{NO ENTRY} Alerta de posible SPAM", description=msg, - colour=WARNING_COLOR, + colour=colors.BRAND, ) - await self._msg_channel.send(embed=embed, delete_after = 60) + await ctx.channel.send(embed=embed, delete_after = 60) # Check first more than 3 mentions - if await self.mention_check(message): - self.add_spam_message(self._msg_content) + if await self.mention_check(ctx): + self.add_spam_message(ctx.content) await discord.Message.delete(message) msg = ( - f"El mensaje del usuario {self._msg_author_mention} fue borrado por tener muchas " + f"El mensaje del usuario {ctx.author_mention} fue borrado por tener muchas " "menciones y podría ser un engaño.\nEvita `hacer click` en enlaces de " "**usuarios que no conozcas**." ) embed = discord.Embed( title="\N{NO ENTRY} Alerta de posible SPAM", description=msg, - colour=WARNING_COLOR, + colour=colors.BRAND, ) - await self._msg_channel.send(embed=embed, delete_after=300) + await ctx.channel.send(embed=embed, delete_after=300) - print("FloodSpam.on_message: spam_check") - if await self.spam_check(message): - self.add_spam_message(self._msg_content) + if await self.spam_check(ctx): + self.add_spam_message(ctx.content) await discord.Message.delete(message) msg = ( - f"El mensaje del usuario {self._msg_author_mention} fue borrado y podría ser " + f"El mensaje del usuario {ctx.author_mention} fue borrado y podría ser " "un engaño.\nEvita `hacer click` en enlaces de **usuarios que no conozcas**." ) embed = discord.Embed( title="\N{NO ENTRY} Alerta de posible SCAM", description=msg, - colour=WARNING_COLOR, + colour=colors.BRAND, ) - await self._msg_channel.send(embed=embed, delete_after = 300) + await ctx.channel.send(embed=embed, delete_after = 300) - async def spam_check(self, message: discord.Message): - author = message.author - - if not isinstance(author, discord.Member): + async def spam_check(self, ctx: MessageContext): + if not isinstance(ctx.author, discord.Member): return - if not any(all(i in message.content for i in sw) for sw in SPAM_WORDS): + if not any(all(i in ctx.message.content for i in sw) for sw in SPAM_WORDS): return False - await self.alert_moderation("Alerta de SCAM", "scam") + await self.alert_moderation(ctx, "Alerta de SCAM", "scam") # Set muted role - await author.add_roles(self.muted_role) + await ctx.author.add_roles(self.muted_role) _msg = ( - f"Usuario {author.mention} silenciado por compartir un mensaje que " + f"Usuario {ctx.author_mention} silenciado por compartir un mensaje que " "parece contener enlaces de engaño. El equipo de coordinación ha sido notificado." ) embed = discord.Embed( title="\N{NO ENTRY} Alerta de posible SCAM", description=_msg, - colour=WARNING_COLOR, + colour=colors.BRAND, ) # Send message notifying the user is muted - await message.channel.send(embed=embed, delete_after = 300) + await ctx.channel.send(embed=embed, delete_after = 300) return True - async def flood_check(self, message): - print(f"LOG: flood_check: {message}") + async def flood_check(self, ctx: MessageContext): + logger.debug("flood_check: %s", ctx.message.id) # Textless (image-only) messages are handled by attachment_check - if not self._msg_content: + if not ctx.content: return False - if self._msg_author not in self.messages.normal: - self.messages.normal[self._msg_author] = {self._msg_content: 1} + if ctx.author not in self.normal: + self.normal[ctx.author] = {ctx.content: 1} else: - if self._msg_content not in self.messages.normal[self._msg_author]: - self.messages.normal[self._msg_author][self._msg_content] = 1 + if ctx.content not in self.normal[ctx.author]: + self.normal[ctx.author][ctx.content] = 1 else: - self.messages.normal[self._msg_author][self._msg_content] += 1 - if self.messages.normal[self._msg_author][self._msg_content] >= config.FLOOD_LIMIT: - self.add_spam_message(self._msg_content) + self.normal[ctx.author][ctx.content] += 1 + if self.normal[ctx.author][ctx.content] >= config.FLOOD_LIMIT: + self.add_spam_message(ctx.content) await self.alert_moderation( + ctx, "Alerta de Flood", "flood", ) # Set muted role - await self._msg_author.add_roles(self.muted_role) + await ctx.author.add_roles(self.muted_role) # Reset author counters - self.messages.normal[self._msg_author] = {} + self.normal[ctx.author] = {} _msg = ( - f"Usuario {self._msg_author_mention} silenciado por enviar mensajes " + f"Usuario {ctx.author_mention} silenciado por enviar mensajes " "repetitivos. El equipo de coordinación ha sido notificado." ) embed = discord.Embed( - title="\N{NO ENTRY} Alerta de posible SCAM", + title="\N{NO ENTRY} Alerta de posible SPAM", description=_msg, - colour=WARNING_COLOR, + colour=colors.BRAND, ) # Send message notifying the user is muted - await self._msg_channel.send(embed=embed, delete_after = 120) + await ctx.channel.send(embed=embed, delete_after = 120) @staticmethod - async def _hash_attachment(attachment: discord.Attachment) -> str: - data = await attachment.read() + def _hash_bytes(data: bytes) -> str: return hashlib.sha256(data).hexdigest() @staticmethod - async def _sanitize_attachment(attachment: discord.Attachment) -> Optional[discord.File]: - """Decode and re-encode an attachment before it's shown to moderators. + async def _sanitize_bytes(data: bytes) -> Optional[discord.File]: + """Decode and re-encode image bytes before they're shown to moderators. Images from a compromised/malicious account are untrusted input: a crafted file could try to exploit a bug in whatever renders its @@ -271,10 +295,9 @@ async def _sanitize_attachment(attachment: discord.Attachment) -> Optional[disco via Pillow into a fresh PNG strips anything relying on a malformed file structure, and the result is still sent as a spoiler so viewing it requires an explicit click rather than an automatic preview. - Returns ``None`` if the attachment can't be safely decoded. + Returns ``None`` if the image can't be safely decoded. """ try: - data = await attachment.read() with Image.open(BytesIO(data)) as img: img.load() clean = img.convert("RGB") @@ -283,10 +306,10 @@ async def _sanitize_attachment(attachment: discord.Attachment) -> Optional[disco buf.seek(0) return discord.File(buf, filename="evidencia.png", spoiler=True) except (UnidentifiedImageError, OSError, ValueError): - print(f"LOG: _sanitize_attachment: could not decode {attachment.filename!r}, skipping") + logger.warning("_sanitize_bytes: could not decode image, skipping") return None - async def attachment_check(self, message: discord.Message) -> bool: + async def attachment_check(self, ctx: MessageContext) -> bool: """Detect image-based spam/scam bursts from (often compromised) accounts. Two mechanisms: @@ -298,39 +321,46 @@ async def attachment_check(self, message: discord.Message) -> bool: same images across the server. When this fires, the offending images are hashed and cached for the fast path above. """ - print("LOG: attachment_check") + logger.debug("attachment_check: %s", ctx.message.id) images = [ - a for a in message.attachments + a for a in ctx.message.attachments if (a.content_type or "").startswith("image/") ] if not images: return False + # Read each attachment's bytes once and reuse them below for hashing + # and (if needed) sanitizing, instead of re-downloading from + # Discord's CDN for each separate step. + image_bytes = [await a.read() for a in images] + # Fast path: any image already known to be spam/scam - for attachment in images: - digest = await self._hash_attachment(attachment) - if digest in self.messages.image_spam: + for data in image_bytes: + digest = self._hash_bytes(data) + if digest in self.image_spam: await self.alert_moderation( + ctx, "Alerta de SPAM (Imagen conocida)", "known_image", - attachments=images, + image_bytes=image_bytes, ) # Set muted role - await self._msg_author.add_roles(self.muted_role) + await ctx.author.add_roles(self.muted_role) - await discord.Message.delete(message) + await discord.Message.delete(ctx.message) msg = ( - f"El mensaje del usuario {self._msg_author_mention} fue borrado por " - "contener una imagen detectada previamente como spam." + f"El mensaje del usuario {ctx.author_mention} fue borrado por " + "contener una imagen detectada previamente como spam.\nEl equipo de " + "coordinación ha sido notificado." ) embed = discord.Embed( title="\N{NO ENTRY} Alerta de posible SPAM", description=msg, - colour=WARNING_COLOR, + colour=colors.BRAND, ) - await self._msg_channel.send(embed=embed, delete_after=60) + await ctx.channel.send(embed=embed, delete_after=60) return True if len(images) < config.IMAGE_ATTACHMENT_LIMIT: @@ -338,118 +368,120 @@ async def attachment_check(self, message: discord.Message) -> bool: # Burst path: same author, 2+ images, 2+ different channels, short window now = time.time() - channels = self.messages.image_authors.get(self._msg_author, {}) + channels = self.image_authors.get(ctx.author, {}) channels = { channel_id: ts for channel_id, ts in channels.items() if now - ts <= config.IMAGE_BURST_WINDOW } - channels[message.channel.id] = now - self.messages.image_authors[self._msg_author] = channels + channels[ctx.channel.id] = now + self.image_authors[ctx.author] = channels if len(channels) < 2: return False await self.alert_moderation( + ctx, "Alerta de SPAM (Imágenes en varios canales)", "image_burst", - attachments=images, + image_bytes=image_bytes, ) # Set muted role - await self._msg_author.add_roles(self.muted_role) + await ctx.author.add_roles(self.muted_role) # Cache the images involved so future occurrences hit the fast path - for attachment in images: - digest = await self._hash_attachment(attachment) - self.add_spam_image_hash(digest) + for data in image_bytes: + self.add_spam_image_hash(self._hash_bytes(data)) # Reset author's channel tracking now that we've acted on it - self.messages.image_authors[self._msg_author] = {} + self.image_authors[ctx.author] = {} - await discord.Message.delete(message) + await discord.Message.delete(ctx.message) msg = ( - f"El mensaje del usuario {self._msg_author_mention} fue borrado por compartir " + f"El mensaje del usuario {ctx.author_mention} fue borrado por compartir " "imágenes en varios canales en poco tiempo, lo cual podría indicar una cuenta " "comprometida.\nEvita **hacer click** en enlaces o seguir instrucciones de " - "imágenes de **usuarios que no conozcas**." + "imágenes de **usuarios que no conozcas**.\nEl equipo de coordinación ha sido " + "notificado." ) embed = discord.Embed( - title="\N{NO ENTRY} Alerta de posible SCAM", + title="\N{NO ENTRY} Alerta de posible SPAM", description=msg, - colour=WARNING_COLOR, + colour=colors.BRAND, ) - await self._msg_channel.send(embed=embed, delete_after=300) + await ctx.channel.send(embed=embed, delete_after=300) return True - async def mention_check(self, message): - print("LOG: mention_check") + async def mention_check(self, ctx: MessageContext): + logger.debug("mention_check: %s", ctx.message.id) # Skip if 2 mentions or less - if (len(message.mentions) + len(message.role_mentions)) < config.MENTIONS_LIMIT: + if (len(ctx.message.mentions) + len(ctx.message.role_mentions)) < config.MENTIONS_LIMIT: return False await self.alert_moderation( + ctx, "Alerta de Flood (Menciones)", "menciones", ) # Set muted role - await self._msg_author.add_roles(self.muted_role) + await ctx.author.add_roles(self.muted_role) _msg = ( - f"Usuario {self._msg_author_mention} silenciado por hacer muchas menciones. " + f"Usuario {ctx.author_mention} silenciado por hacer muchas menciones. " "El equipo de coordinación ha sido notificado." ) embed = discord.Embed( title="\N{NO ENTRY} Alerta de SPAM de menciones", description=_msg, - colour=WARNING_COLOR, + colour=colors.BRAND, ) # Send message notifying the user is muted - await self._msg_channel.send(embed=embed, delete_after = 300) + await ctx.channel.send(embed=embed, delete_after = 300) return True def add_spam_message(self, message): - print("LOG: add_spam_message") + logger.info("add_spam_message: %r", message) with open(config.log_spam_file, "a") as f: f.write(f"{message}\n") - self.messages.spam.add(message) + self.spam.add(message) def add_spam_image_hash(self, digest): - print("LOG: add_spam_image_hash") + logger.info("add_spam_image_hash: %s", digest) with open(config.log_image_spam_file, "a") as f: f.write(f"{digest}\n") - self.messages.image_spam.add(digest) + self.image_spam.add(digest) - async def alert_moderation(self, title, reason, attachments=None): - print("LOG: alert_moderation") + async def alert_moderation(self, ctx: MessageContext, title, reason, image_bytes=None): + logger.debug("alert_moderation: %s (%s)", title, reason) d_msg = { "menciones": ( f"{self.coord_role.mention} Se detectó un mensaje con muchas menciones " - f"de {self._msg_author_mention} y se ha muteado." + f"de {ctx.author_mention} y se ha muteado." ), "flood": ( f"{self.coord_role.mention} Se detectaron mensajes repetitivos de " - f"{self._msg_author_mention} y se ha muteado." + f"{ctx.author_mention} y se ha muteado." ), "scam": ( f"{self.coord_role.mention} Se detectó un mensaje de SCAM de " - f"{self._msg_author_mention} y se ha muteado." + f"{ctx.author_mention} y se ha muteado." ), "known": ( f"{self.coord_role.mention} Se detectó un mensaje previamente reconocido " - f"como spam de {self._msg_author_mention} y se ha muteado." + f"como spam de {ctx.author_mention} y se ha muteado." ), "known_image": ( f"{self.coord_role.mention} Se detectó una imagen previamente reconocida " - f"como spam/scam de {self._msg_author_mention} y se ha muteado." + f"como spam/scam de {ctx.author_mention} y se ha muteado." ), "image_burst": ( f"{self.coord_role.mention} Se detectaron imágenes enviadas por " - f"{self._msg_author_mention} en varios canales en poco tiempo " + f"{ctx.author_mention} en varios canales en poco tiempo " "(posible cuenta comprometida) y se ha muteado." ), } @@ -457,9 +489,13 @@ async def alert_moderation(self, title, reason, attachments=None): embed = discord.Embed( title=f"\N{NO ENTRY} {title}", description=msg, - colour=WARNING_COLOR, + colour=colors.BRAND, ) - embed.add_field(name="Mensaje", value=f"`{repr(self._msg_content)[1:-1]}`", inline=False) + # Escape backticks so message content can't break out of the inline + # code span (repr(...)[1:-1] used to do this by stripping repr's + # quote characters - fragile, and didn't actually escape backticks). + safe_content = ctx.content.replace("`", "'") if ctx.content else "(sin texto)" + embed.add_field(name="Mensaje", value=f"`{safe_content}`", inline=False) embed.add_field( name="En caso de ser spam", value=( @@ -483,9 +519,9 @@ async def alert_moderation(self, title, reason, attachments=None): # a malformed file to exploit an image parser) and sent as a spoiler # so viewing them requires an explicit click. files = [] - if attachments: - for attachment in attachments: - sanitized = await self._sanitize_attachment(attachment) + if image_bytes: + for data in image_bytes: + sanitized = await self._sanitize_bytes(data) if sanitized is not None: files.append(sanitized) embed.add_field( @@ -498,7 +534,7 @@ async def alert_moderation(self, title, reason, attachments=None): inline=False, ) - view = ModActionView(self._msg_author, self._muted_role) - thread = await self.main_mod_channel.create_thread(name=f"{title} - {self._msg_author_mention}", + view = ModActionView(ctx.author, self._muted_role) + thread = await self.main_mod_channel.create_thread(name=f"{title} - {ctx.author_mention}", auto_archive_duration=60, type=discord.ChannelType.public_thread) - await thread.send(embed=embed, view=view, files=files) \ No newline at end of file + await thread.send(embed=embed, view=view, files=files) diff --git a/comandos/limpia.py b/comandos/limpia.py index 3073f32..fd1d8bb 100644 --- a/comandos/limpia.py +++ b/comandos/limpia.py @@ -1,10 +1,14 @@ +import logging + import discord from discord.ext import commands from discord import app_commands +import colors from configuration import Config config = Config() +logger = logging.getLogger(__name__) class Limpia(commands.Cog): @@ -27,7 +31,6 @@ async def purge(self, ctx: commands.Context, limit: int = 1) -> None: if not isinstance(channel, (discord.TextChannel, discord.Thread, discord.VoiceChannel)): return - print(dir(ctx.message.reference)) if hasattr(ctx.message.reference, "message_id"): reply_id = ctx.message.reference.message_id msg = [] @@ -42,13 +45,13 @@ async def purge(self, ctx: commands.Context, limit: int = 1) -> None: try: await ctx.message.delete() except discord.NotFound: - print("Slash command, no need to remove command message") + logger.debug("Slash command, no need to remove command message") # await ctx.channel.typing() embed = discord.Embed( title=f"Borrados '{limit}' mensajes\n\n", description=f"Comando ejecuta por {ctx.author.mention}", - colour=0x178D38, + colour=colors.SUCCESS, ) await ctx.send(embed=embed, ephemeral=True) await channel.purge(limit=1) diff --git a/comandos/moderacion.py b/comandos/moderacion.py index dc26597..2f67be6 100644 --- a/comandos/moderacion.py +++ b/comandos/moderacion.py @@ -1,5 +1,8 @@ +import ast import asyncio import base64 +import csv +import logging from datetime import datetime from dataclasses import dataclass from typing import Optional @@ -8,12 +11,33 @@ import discord from discord.ext import commands +import colors from configuration import Config -from utils import get_moderation_channel, get_message_to_moderate, aceptar_emoji, rechazar_emoji +from utils import get_message_to_moderate, aceptar_emoji, rechazar_emoji config = Config() +logger = logging.getLogger(__name__) -EMBED_COLOR = 0x2B597B + +def _encode_message(content: str) -> str: + """Base64-encode a message's content for storage in data_mod/the log files.""" + return base64.b64encode(content.encode("utf-8")).decode("ascii") + + +def _decode_message(stored: str) -> str: + """Reverse ``_encode_message``. + + Also tolerates the legacy on-disk format: older code stored the + *repr* of the base64 `bytes` object (e.g. ``"b'aG9sYQ=='"``, via + ``f"{base64.b64encode(...)}"``) and reversed it with ``eval()``. Rows + written before this change still look like that, so a plain + ``b64decode`` fails validation and we fall back to safely parsing that + literal with ``ast.literal_eval`` instead - no ``eval()`` involved. + """ + try: + return base64.b64decode(stored, validate=True).decode("utf-8") + except ValueError: + return base64.b64decode(ast.literal_eval(stored)).decode("utf-8") @dataclass @@ -88,10 +112,6 @@ def _resolve_author(self, ctx) -> discord.User | discord.Member: def _is_bot(self, ctx) -> bool: return self._resolve_author(ctx).id == config.BOT_ID - def _is_valid_channel(self, ctx) -> bool: - channel_mod = get_moderation_channel(self.bot, ctx.channel.id) - return channel_mod.id == ctx.message.channel.id - def get_channels_main_mod_sub(self, channel_id): channel_main = self.bot.get_channel(self.channels[channel_id]["main"]) channel_mod = self.bot.get_channel(self.channels[channel_id]["mod"]) @@ -102,7 +122,7 @@ async def _parse_post_id( self, ctx, message_id: Optional[int], command_name: str ) -> Optional[str]: """Parse and validate the post_id from interaction or command message.""" - channel_mod = get_moderation_channel(self.bot, ctx.channel.id) + channel_mod = self.bot.get_channel(ctx.channel.id) if isinstance(ctx, discord.Interaction) and message_id is not None: return str(message_id) @@ -125,10 +145,10 @@ async def _get_validated_post( - Resolves channels and decodes the message Returns a ValidatedPost or None if any step fails. """ - if self._is_bot(ctx) or not self._is_valid_channel(ctx): + if self._is_bot(ctx): return None - channel_mod = get_moderation_channel(self.bot, ctx.channel.id) + channel_mod = self.bot.get_channel(ctx.channel.id) post_id = await self._parse_post_id(ctx, message_id, command_name) if post_id is None: @@ -146,7 +166,7 @@ async def _get_validated_post( ]["submission"] ch_main, ch_mod, ch_sub = self.get_channels_main_mod_sub(channel_id) - message_dec = base64.b64decode(eval(mod_row["message"].values[0])).decode("utf-8") + message_dec = _decode_message(mod_row["message"].values[0]) author = self.bot.get_user(int(mod_row["author_id"].values[0])) return ValidatedPost( @@ -164,26 +184,34 @@ def _log_action(self, action: str, row, post_id, moderator, reason: str = ""): """ Unified log writer for accept/reject actions. action: "aceptar" or "rechazar" + + Uses csv.writer (not hand-built quoting) so a channel/author name or + message containing a literal '"' or newline doesn't silently corrupt + the row - these files are re-parsed with pd.read_csv on every bot + startup, so a malformed row there can break loading the pending + queue. """ filename = ( config.log_accepted_file if action == "aceptar" else config.log_rejected_file ) date_str = f"{datetime.now()}" - line = ( - f'"{date_str}";' - f'"{post_id}";' - f'"{row["channel"].values[0]}";' - f'"{row["author_id"].values[0]}";' - f'"{row["author"].values[0]}";' - f'"{row["message"].values[0]}";' - f'"{moderator}"' - ) - if reason: - line += f';"{reason}"' - line += "\n" - - with open(str(filename), "a") as f: - f.write(line) + fields = [ + date_str, + post_id, + row["channel"].values[0], + row["author_id"].values[0], + row["author"].values[0], + row["message"].values[0], + moderator, + ] + # log_rejected_file's header always has a "reason" column - include + # it (even if empty) for every rechazar row, not just when reason is + # truthy, so the column count always matches the header. + if action != "aceptar": + fields.append(reason) + + with open(str(filename), "a", newline="") as f: + csv.writer(f, delimiter=";").writerow(fields) def log_on_message(self, channel_sub, author): date_str = f"{datetime.now()}" @@ -197,16 +225,10 @@ def log_on_message(self, channel_sub, author): } self.bot.data_mod = pd.concat([self.bot.data_mod, pd.DataFrame([new_data])]) - line = ( - f'"{date_str}";' - f'"{self._msg_id}";' - f'"{channel_sub}";' - f'"{author.id}";' - f'"{author}";' - f'"{self._msg_enc}"\n' - ) - with open(str(config.log_mod_file), "a") as f: - f.write(line) + with open(str(config.log_mod_file), "a", newline="") as f: + csv.writer(f, delimiter=";").writerow([ + date_str, self._msg_id, channel_sub, author.id, author, self._msg_enc, + ]) @commands.Cog.listener() async def on_ready(self): @@ -229,7 +251,7 @@ async def on_message(self, message): return self._msg_id = message.id - self._msg_enc = base64.b64encode(message.content.encode("utf-8")) + self._msg_enc = _encode_message(message.content) ch_main, ch_mod, ch_sub = self.get_channels_main_mod_sub(ch_id) self.log_on_message(ch_sub, message.author) @@ -237,7 +259,7 @@ async def on_message(self, message): embed = discord.Embed( title="Mensaje Enviado", description=f"Gracias {message.author.mention}, tu mensaje espera moderación.", - colour=EMBED_COLOR, + colour=colors.BRAND, ) reply_msg = await ch_sub.send(embed=embed) @@ -259,12 +281,16 @@ async def _aceptar_mensaje(self, ctx, message_id: Optional[int] = None): self._log_action("aceptar", vp.mod_row, vp.post_id, moderator) self.bot.data_mod = self.bot.data_mod[~vp.condition] - jump_url = f"https://discord.com/channels/{self.bot.guilds[0].id}/{vp.ch_main.id}/{self._msg_id}" + # Send to the destination channel first so the confirmation below can + # link to the message that was actually posted there, instead of + # guessing at a URL (the old code built the link from self._msg_id - + # the *original submission's* id in a different channel entirely - + # before the message below even existed). + sent_message = await vp.ch_main.send(f"> [Enviado por {vp.author.mention}]\n{vp.message_dec}") await vp.ch_mod.send( f"{aceptar_emoji} Mensaje `{vp.post_id}` aceptado, " - f"enviado al canal {vp.ch_main.mention}\nVer en {jump_url}" + f"enviado al canal {vp.ch_main.mention}\nVer en {sent_message.jump_url}" ) - await vp.ch_main.send(f"> [Enviado por {vp.author.mention}]\n{vp.message_dec}") @commands.command(name="aceptar", help="Comando para aceptar mensajes en moderación") @commands.has_role(config.MOD_ROLE) @@ -290,7 +316,7 @@ async def _rechazar_mensaje( embed = discord.Embed( title="Mensaje rechazado", description=f"{vp.author.mention} tu mensaje necesita atención.", - colour=EMBED_COLOR, + colour=colors.BRAND, ) embed.add_field( name="Razón rechazado", @@ -314,14 +340,14 @@ def get_mod_pending(self, data): messages = False embed = discord.Embed( title="Mensajes pendientes de moderación", - colour=EMBED_COLOR, + colour=colors.BRAND, ) for idx, mod_row in data.iterrows(): author = self.bot.get_user(int(mod_row["author_id"])) if not author: - print(f"El author '{mod_row['author_id']}' ya no existe en el server.") + logger.warning("El author '%s' ya no existe en el server.", mod_row["author_id"]) continue - m_message = base64.b64decode(eval(mod_row["message"])).decode("utf-8") + m_message = _decode_message(mod_row["message"]) embed.add_field( name=f"ID: `{mod_row['message_id']}`", value=f"{m_message[:30]}...\nFecha: `{mod_row['date']}`\nAutor: {author.mention}", @@ -336,10 +362,10 @@ def get_mod_pending(self, data): @commands.command(name="mod", help="Comando para listar los mensajes pendientes") @commands.has_role(config.MOD_ROLE) async def mostrar_mensajes(self, ctx): - if self._is_bot(ctx) or not self._is_valid_channel(ctx): + if self._is_bot(ctx): return - channel_mod = get_moderation_channel(self.bot, ctx.channel.id) + channel_mod = self.bot.get_channel(ctx.channel.id) _post = ctx.message.content.replace("%mod", "").strip().split() if not _post: @@ -357,7 +383,7 @@ async def mostrar_mensajes(self, ctx): condition = self.bot.data_mod["message_id"] == post_id mod_row = self.bot.data_mod[condition] author = self.bot.get_user(int(mod_row["author_id"].values[0])) - m_message = base64.b64decode(eval(mod_row["message"].values[0])).decode("utf-8") + m_message = _decode_message(mod_row["message"].values[0]) embed = discord.Embed( title="Mensaje pendiente de moderación", @@ -366,6 +392,6 @@ async def mostrar_mensajes(self, ctx): f"**ID:** {mod_row['message_id'].values[0]}\n" f"**Mensaje:**\n```\n{m_message}\n```\n" ), - colour=EMBED_COLOR, + colour=colors.BRAND, ) await channel_mod.send(embed=embed) diff --git a/configuration.py b/configuration.py index 1d3e999..b1f56e1 100644 --- a/configuration.py +++ b/configuration.py @@ -1,8 +1,11 @@ +import logging import sys import toml from pathlib import Path +logger = logging.getLogger(__name__) + class Singleton(type): _instances = {} @@ -16,13 +19,13 @@ def __call__(cls, *args, **kwargs): class Config(metaclass=Singleton): def __init__(self): # Configuration file - print("Config __init__") + logger.debug("Config __init__") config = None with open("config.toml") as f: config = toml.loads(f.read()) if not config: - print("Error: Failed to load the config") + logger.error("Failed to load the config") sys.exit(-1) try: @@ -46,8 +49,10 @@ def __init__(self): self.IMAGE_ATTACHMENT_LIMIT = 2 self.IMAGE_BURST_WINDOW = 60 * 5 except KeyError: - print("Error while reading the configuration file. " - "Make sure it contains all the required field") + logger.error( + "Error while reading the configuration file. " + "Make sure it contains all the required field" + ) sys.exit(-1) self.setup_log_files() @@ -94,9 +99,9 @@ def get_spam_messages(self): d = set() with open(self.log_spam_file) as f: for line in f.readlines(): - print(">>>", line.strip()) + logger.debug("Loaded known spam message: %r", line.strip()) d.add(line.strip()) - print("LOG: get_spam_messages", d) + logger.debug("get_spam_messages: %s", d) return d def get_spam_image_hashes(self): @@ -107,7 +112,7 @@ def get_spam_image_hashes(self): line = line.strip() if line: d.add(line) - print("LOG: get_spam_image_hashes", d) + logger.debug("get_spam_image_hashes: %s", d) return d def check_create_file(self, fname: Path, msg: str) -> None: diff --git a/messages.py b/messages.py deleted file mode 100644 index 915b7a6..0000000 --- a/messages.py +++ /dev/null @@ -1,10 +0,0 @@ -from dataclasses import dataclass, field -from typing import Set, Dict, Any - - -@dataclass -class Messages: - spam: Set = field(default_factory=set) - normal: Dict[Any, Any] = field(default_factory=dict) - image_spam: Set = field(default_factory=set) - image_authors: Dict[Any, Any] = field(default_factory=dict) diff --git a/tests/factories.py b/tests/factories.py index 4b08e0f..9260e94 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -152,9 +152,6 @@ def make_interaction(user=None, channel=None): interaction = MagicMock(spec=discord.Interaction) interaction.user = user if user is not None else make_member() interaction.channel = channel if channel is not None else make_text_channel() - # `_is_valid_channel` compares `channel_mod.id == ctx.message.channel.id` - # even for interactions, so this needs to line up with `.channel` too. - interaction.message = SimpleNamespace(channel=interaction.channel) interaction.response = MagicMock() interaction.response.send_message = AsyncMock() interaction.response.send_modal = AsyncMock() @@ -162,10 +159,17 @@ def make_interaction(user=None, channel=None): def encode_for_mod_row(text: str) -> str: - """Matches ``Moderacion.on_message``'s encoding of a message's content - into the ``data_mod``/log-file ``message`` column: a base64-encoded - ``bytes`` object rendered through an f-string (so later ``eval()``'d - back into a real ``bytes`` object by the accept/reject/list commands).""" + """Matches ``Moderacion``'s current encoding of a message's content into + the ``data_mod``/log-file ``message`` column: a plain base64 string.""" + import base64 + + return base64.b64encode(text.encode("utf-8")).decode("ascii") + + +def encode_for_mod_row_legacy(text: str) -> str: + """Matches the *old* (pre-fix) encoding: the repr of a base64 ``bytes`` + object, e.g. ``"b'aG9sYQ=='"``. Used to test that rows logged before + the eval()-removal fix still decode correctly.""" import base64 return f"{base64.b64encode(text.encode('utf-8'))}" @@ -194,11 +198,11 @@ def bind_commands(cog): return cog -def prime_cog(cog, message): - """Mirror the attribute setup ``FloodSpam.on_message`` does before - delegating to its individual ``*_check`` methods, so those methods can - be unit-tested directly without going through the full listener.""" - cog._msg_channel = message.channel - cog._msg_content = strip_message(message.content) - cog._msg_author = message.author - cog._msg_author_mention = message.author.mention +def make_context(message): + """Build the ``MessageContext`` ``FloodSpam.on_message`` would build + before delegating to its individual ``*_check`` methods, so those + methods can be unit-tested directly without going through the full + listener.""" + from comandos.flood import MessageContext + + return MessageContext(message=message, content=strip_message(message.content)) diff --git a/tests/test_archivar.py b/tests/test_archivar.py index bfaab1b..8c7f31e 100644 --- a/tests/test_archivar.py +++ b/tests/test_archivar.py @@ -33,12 +33,12 @@ def test_writes_header_and_rows(self, tmp_path): # Newlines inside content are escaped, not left as real line breaks. assert "hola\\ncon salto de linea" in lines[1] - def test_stops_and_returns_none_for_unsupported_channel_type(self, tmp_path): + def test_stops_and_returns_false_for_unsupported_channel_type(self, tmp_path): cog = Archivar(make_bot()) messages = [make_message(id=1, channel=make_dm_channel())] target = tmp_path / "archivo.csv" - assert cog.archivar_canal(str(target), messages) is None + assert cog.archivar_canal(str(target), messages) == (False, None) def test_write_failure_returns_false_none(self, tmp_path): cog = Archivar(make_bot()) @@ -48,17 +48,6 @@ def test_write_failure_returns_false_none(self, tmp_path): assert cog.archivar_canal(str(bad_target), messages) == (False, None) - def test_failure_tuple_is_still_truthy(self, tmp_path): - """Documents a real footgun in archivar(): ``status = archivar_canal(...)`` - followed by ``if status:`` treats a write failure as success, because - ``(False, None)`` is a non-empty tuple and therefore truthy in Python. - """ - cog = Archivar(make_bot()) - status = cog.archivar_canal(str(tmp_path), [make_message(id=1)]) - - assert status == (False, None) - assert bool(status) is True # the actual footgun - class TestArchivarCommand: async def test_sends_success_embed_with_the_file(self, tmp_path, monkeypatch): @@ -97,6 +86,32 @@ async def test_sends_error_embed_when_archiving_fails(self, tmp_path, monkeypatc (msg,), _ = mod_channel.send.call_args assert "Error" in msg + async def test_write_exception_reports_error_instead_of_crashing(self, tmp_path, monkeypatch): + """Regression test: archivar_canal() returning (False, None) on a + write failure used to be treated as success by ``if status:`` + (a non-empty tuple is always truthy), which would then try to + attach a file that was never written - crashing instead of just + reporting the error. + """ + monkeypatch.chdir(tmp_path) + mod_channel = make_text_channel(id=1, name="mod") + cog = bind_commands(Archivar(make_bot())) + cog.mod_channel = mod_channel + + # A channel name containing "/" makes the auto-generated filename + # point at a non-existent subdirectory, so open(filename, "w") fails. + channel = make_text_channel( + id=2, name="no-existe/canal", history_messages=[make_message(id=1)] + ) + ctx = make_ctx(channel=mod_channel) + + await cog.archivar(ctx, channel=channel) + + mod_channel.send.assert_awaited_once() + (msg,), kwargs = mod_channel.send.call_args + assert "Error" in msg + assert "file" not in kwargs + class TestArchivarCategoria: async def test_only_archives_text_channels(self, tmp_path, monkeypatch): diff --git a/tests/test_ayuda.py b/tests/test_ayuda.py index a81fedd..624bfd4 100644 --- a/tests/test_ayuda.py +++ b/tests/test_ayuda.py @@ -1,5 +1,5 @@ from comandos.ayuda import Ayuda -from tests.factories import bind_commands, make_bot, make_ctx, make_member, make_text_channel +from tests.factories import bind_commands, make_bot, make_ctx, make_member class TestGetModHelp: @@ -14,15 +14,6 @@ def test_lists_moderation_commands(self): assert "`%rechazar ID RAZON`" in names -class TestGetMainHelp: - def test_lists_encuesta_usage(self): - cog = Ayuda(make_bot()) - - embed = cog.get_main_help() - - assert any("encuesta" in f.name for f in embed.fields) - - class TestMensajeAyuda: async def test_ignores_the_bot_itself(self, config): cog = bind_commands(Ayuda(make_bot())) @@ -32,30 +23,14 @@ async def test_ignores_the_bot_itself(self, config): ctx.channel.send.assert_not_awaited() - async def test_sends_mod_help_inside_a_moderation_channel(self): - mod_channel = make_text_channel(id=1, name="mod") - bot = make_bot(channels={mod_channel.id: mod_channel}) - cog = bind_commands(Ayuda(bot)) - ctx = make_ctx(channel=mod_channel) + async def test_sends_mod_help(self): + cog = bind_commands(Ayuda(make_bot())) + ctx = make_ctx() await cog.mensaje_ayuda(ctx) - mod_channel.send.assert_awaited_once() - _, kwargs = mod_channel.send.call_args + ctx.channel.send.assert_awaited_once() + _, kwargs = ctx.channel.send.call_args assert kwargs["embed"].title == "Comandos Disponibles" names = [f.name for f in kwargs["embed"].fields] assert "`%mod`" in names - - async def test_sends_main_help_outside_a_moderation_channel(self): - # bot.get_channel(ctx.channel.id) returns None - not a known channel. - bot = make_bot(channels={}) - cog = bind_commands(Ayuda(bot)) - channel = make_text_channel(id=99, name="general") - ctx = make_ctx(channel=channel) - - await cog.mensaje_ayuda(ctx) - - channel.send.assert_awaited_once() - _, kwargs = channel.send.call_args - names = [f.name for f in kwargs["embed"].fields] - assert any("encuesta" in name for name in names) diff --git a/tests/test_flood.py b/tests/test_flood.py index e8ff73c..c4ea6fe 100644 --- a/tests/test_flood.py +++ b/tests/test_flood.py @@ -5,11 +5,11 @@ from tests.factories import ( make_attachment, + make_context, make_member, make_message, make_png_bytes, make_text_channel, - prime_cog, ) @@ -20,13 +20,14 @@ class TestSpamCheck: async def test_ignores_non_member_author(self, flood_cog): message = make_message(content="discord nitro free http://evil") message.author = object() # not a discord.Member + ctx = make_context(message) - assert await flood_cog.spam_check(message) is None + assert await flood_cog.spam_check(ctx) is None async def test_no_match_returns_false(self, flood_cog): - message = make_message(content="hola a todos, buen dia") + ctx = make_context(make_message(content="hola a todos, buen dia")) - assert await flood_cog.spam_check(message) is False + assert await flood_cog.spam_check(ctx) is False @pytest.mark.parametrize( "content", @@ -38,9 +39,9 @@ async def test_no_match_returns_false(self, flood_cog): async def test_match_mutes_and_notifies(self, flood_cog, content): member = make_member(name="victima") message = make_message(content=content, author=member) - prime_cog(flood_cog, message) + ctx = make_context(message) - result = await flood_cog.spam_check(message) + result = await flood_cog.spam_check(ctx) assert result is True member.add_roles.assert_awaited_once_with(flood_cog.muted_role) @@ -54,47 +55,50 @@ async def test_match_mutes_and_notifies(self, flood_cog, content): # --------------------------------------------------------------------------- class TestFloodCheck: async def test_empty_content_is_a_noop(self, flood_cog): - message = make_message(content="") - prime_cog(flood_cog, message) + ctx = make_context(make_message(content="")) - assert await flood_cog.flood_check(message) is False - assert flood_cog.messages.normal == {} + assert await flood_cog.flood_check(ctx) is False + assert flood_cog.normal == {} async def test_below_flood_limit_does_not_mute(self, flood_cog, config): member = make_member(name="repetidor") message = make_message(content="hola hola hola", author=member) - prime_cog(flood_cog, message) + ctx = make_context(message) for _ in range(config.FLOOD_LIMIT - 1): - await flood_cog.flood_check(message) + await flood_cog.flood_check(ctx) member.add_roles.assert_not_awaited() async def test_reaching_flood_limit_mutes_and_caches(self, flood_cog, config): member = make_member(name="repetidor") message = make_message(content="hola hola hola", author=member) - prime_cog(flood_cog, message) + ctx = make_context(message) for _ in range(config.FLOOD_LIMIT): - await flood_cog.flood_check(message) + await flood_cog.flood_check(ctx) member.add_roles.assert_awaited_once_with(flood_cog.muted_role) - assert "hola hola hola" in flood_cog.messages.spam + assert "hola hola hola" in flood_cog.spam # Counter resets after muting - assert flood_cog.messages.normal[member] == {} + assert flood_cog.normal[member] == {} + # Repeated messages are behavioral spam, not a scam-link detection - + # the public notice should say so consistently with the other + # behavioral checks (mentions, known text/images). + message.channel.send.assert_awaited_once() + _, kwargs = message.channel.send.call_args + assert kwargs["embed"].title.endswith("Alerta de posible SPAM") async def test_different_authors_counted_separately(self, flood_cog, config): alice = make_member(name="alice", id=1) bob = make_member(name="bob", id=2) for _ in range(config.FLOOD_LIMIT - 1): - msg = make_message(content="mismo mensaje", author=alice) - prime_cog(flood_cog, msg) - await flood_cog.flood_check(msg) + ctx = make_context(make_message(content="mismo mensaje", author=alice)) + await flood_cog.flood_check(ctx) - msg = make_message(content="mismo mensaje", author=bob) - prime_cog(flood_cog, msg) - await flood_cog.flood_check(msg) + ctx = make_context(make_message(content="mismo mensaje", author=bob)) + await flood_cog.flood_check(ctx) alice.add_roles.assert_not_awaited() bob.add_roles.assert_not_awaited() @@ -106,28 +110,27 @@ async def test_different_authors_counted_separately(self, flood_cog, config): class TestMentionCheck: async def test_below_limit_returns_false(self, flood_cog, config): mentions = [make_member(id=i) for i in range(config.MENTIONS_LIMIT - 1)] - message = make_message(mentions=mentions) - prime_cog(flood_cog, message) + ctx = make_context(make_message(mentions=mentions)) - assert await flood_cog.mention_check(message) is False + assert await flood_cog.mention_check(ctx) is False async def test_at_limit_mutes_and_alerts(self, flood_cog, config): member = make_member(name="mencionador") mentions = [make_member(id=i) for i in range(config.MENTIONS_LIMIT)] - message = make_message(author=member, mentions=mentions) - prime_cog(flood_cog, message) + ctx = make_context(make_message(author=member, mentions=mentions)) - assert await flood_cog.mention_check(message) is True + assert await flood_cog.mention_check(ctx) is True member.add_roles.assert_awaited_once_with(flood_cog.muted_role) async def test_mentions_and_role_mentions_add_up(self, flood_cog, config): member = make_member(name="mencionador") mentions = [make_member(id=1)] role_mentions = [object() for _ in range(config.MENTIONS_LIMIT - 1)] - message = make_message(author=member, mentions=mentions, role_mentions=role_mentions) - prime_cog(flood_cog, message) + ctx = make_context( + make_message(author=member, mentions=mentions, role_mentions=role_mentions) + ) - assert await flood_cog.mention_check(message) is True + assert await flood_cog.mention_check(ctx) is True # --------------------------------------------------------------------------- @@ -135,40 +138,66 @@ async def test_mentions_and_role_mentions_add_up(self, flood_cog, config): # --------------------------------------------------------------------------- class TestAttachmentCheckFastPath: async def test_no_images_returns_false(self, flood_cog): - message = make_message(attachments=[make_attachment(content_type="text/plain")]) - prime_cog(flood_cog, message) + ctx = make_context(make_message(attachments=[make_attachment(content_type="text/plain")])) - assert await flood_cog.attachment_check(message) is False + assert await flood_cog.attachment_check(ctx) is False async def test_known_hash_mutes_and_deletes_regardless_of_channel_count( self, flood_cog, patched_message_delete ): data = make_png_bytes() digest = __import__("hashlib").sha256(data).hexdigest() - flood_cog.messages.image_spam.add(digest) + flood_cog.image_spam.add(digest) member = make_member(name="reincidente") message = make_message( author=member, attachments=[make_attachment(data=data)], ) - prime_cog(flood_cog, message) + ctx = make_context(message) - assert await flood_cog.attachment_check(message) is True + assert await flood_cog.attachment_check(ctx) is True member.add_roles.assert_awaited_once_with(flood_cog.muted_role) patched_message_delete.assert_awaited_once_with(message) + message.channel.send.assert_awaited_once() + _, kwargs = message.channel.send.call_args + assert "equipo de coordinación ha sido notificado" in kwargs["embed"].description + class TestAttachmentCheckBurstPath: + async def test_each_attachment_is_only_downloaded_once(self, flood_cog): + """Regression test: within a single attachment_check() call, + attachment.read() used to be called once in the fast-path + hash-check loop, again to cache the hash on a burst trigger, and a + third time inside alert_moderation's sanitize step - up to 3 CDN + downloads per image for the one message that triggers the burst. + """ + member = make_member(name="comprometido") + first = make_message( + author=member, + channel=make_text_channel(id=1), + attachments=[make_attachment(filename="a1.png"), make_attachment(filename="a2.png")], + ) + await flood_cog.attachment_check(make_context(first)) + + b1 = make_attachment(filename="b1.png", data=make_png_bytes((255, 0, 0))) + b2 = make_attachment(filename="b2.png", data=make_png_bytes((0, 255, 0))) + second = make_message(author=member, channel=make_text_channel(id=2), attachments=[b1, b2]) + result = await flood_cog.attachment_check(make_context(second)) + + assert result is True # sanity check that the burst path actually ran + b1.read.assert_awaited_once() + b2.read.assert_awaited_once() + async def test_single_channel_two_images_does_not_trigger(self, flood_cog): member = make_member(name="autor") message = make_message( author=member, attachments=[make_attachment(filename="a.png"), make_attachment(filename="b.png")], ) - prime_cog(flood_cog, message) - assert await flood_cog.attachment_check(message) is False + assert await flood_cog.attachment_check(make_context(message)) is False member.add_roles.assert_not_awaited() async def test_single_image_across_channels_does_not_trigger(self, flood_cog): @@ -180,8 +209,7 @@ async def test_single_image_across_channels_does_not_trigger(self, flood_cog): message = make_message( author=member, channel=channel, attachments=[make_attachment()] ) - prime_cog(flood_cog, message) - assert await flood_cog.attachment_check(message) is False + assert await flood_cog.attachment_check(make_context(message)) is False member.add_roles.assert_not_awaited() @@ -197,16 +225,14 @@ async def test_two_images_two_channels_triggers_on_the_second_message( channel=channel_a, attachments=[make_attachment(filename="a1.png"), make_attachment(filename="a2.png")], ) - prime_cog(flood_cog, first) - first_result = await flood_cog.attachment_check(first) + first_result = await flood_cog.attachment_check(make_context(first)) second = make_message( author=member, channel=channel_b, attachments=[make_attachment(filename="b1.png"), make_attachment(filename="b2.png")], ) - prime_cog(flood_cog, second) - second_result = await flood_cog.attachment_check(second) + second_result = await flood_cog.attachment_check(make_context(second)) # The first channel's message is never retroactively touched - only # the message that crosses the 2-channel threshold gets acted on. @@ -215,6 +241,14 @@ async def test_two_images_two_channels_triggers_on_the_second_message( member.add_roles.assert_awaited_once_with(flood_cog.muted_role) patched_message_delete.assert_awaited_once_with(second) + # Consistent wording with the other behavioral (non scam-link) + # detections: "posible SPAM", and reassurance that the mod team + # was notified (alert_moderation posts to the mod thread). + second.channel.send.assert_awaited_once() + _, kwargs = second.channel.send.call_args + assert kwargs["embed"].title.endswith("Alerta de posible SPAM") + assert "equipo de coordinación ha sido notificado" in kwargs["embed"].description + async def test_images_get_cached_for_the_fast_path(self, flood_cog): member = make_member(name="comprometido") data_a, data_b = make_png_bytes((255, 0, 0)), make_png_bytes((0, 255, 0)) @@ -224,21 +258,19 @@ async def test_images_get_cached_for_the_fast_path(self, flood_cog): channel=make_text_channel(id=1), attachments=[make_attachment(data=data_a), make_attachment(data=data_b)], ) - prime_cog(flood_cog, first) - await flood_cog.attachment_check(first) + await flood_cog.attachment_check(make_context(first)) second = make_message( author=member, channel=make_text_channel(id=2), attachments=[make_attachment(data=data_a), make_attachment(data=data_b)], ) - prime_cog(flood_cog, second) - await flood_cog.attachment_check(second) + await flood_cog.attachment_check(make_context(second)) import hashlib - assert hashlib.sha256(data_a).hexdigest() in flood_cog.messages.image_spam - assert hashlib.sha256(data_b).hexdigest() in flood_cog.messages.image_spam + assert hashlib.sha256(data_a).hexdigest() in flood_cog.image_spam + assert hashlib.sha256(data_b).hexdigest() in flood_cog.image_spam async def test_outside_burst_window_does_not_trigger(self, flood_cog, config, monkeypatch): import comandos.flood as flood_module @@ -252,16 +284,14 @@ async def test_outside_burst_window_does_not_trigger(self, flood_cog, config, mo channel=make_text_channel(id=1), attachments=[make_attachment(filename="a1.png"), make_attachment(filename="a2.png")], ) - prime_cog(flood_cog, first) - await flood_cog.attachment_check(first) + await flood_cog.attachment_check(make_context(first)) second = make_message( author=member, channel=make_text_channel(id=2), attachments=[make_attachment(filename="b1.png"), make_attachment(filename="b2.png")], ) - prime_cog(flood_cog, second) - result = await flood_cog.attachment_check(second) + result = await flood_cog.attachment_check(make_context(second)) assert result is False member.add_roles.assert_not_awaited() @@ -276,21 +306,18 @@ async def test_same_channel_twice_is_not_two_distinct_channels(self, flood_cog): channel=channel, attachments=[make_attachment(filename="a.png"), make_attachment(filename="b.png")], ) - prime_cog(flood_cog, message) - result = await flood_cog.attachment_check(message) + result = await flood_cog.attachment_check(make_context(message)) assert result is False member.add_roles.assert_not_awaited() # --------------------------------------------------------------------------- -# _sanitize_attachment / _hash_attachment +# _sanitize_bytes / _hash_bytes # --------------------------------------------------------------------------- -class TestSanitizeAttachment: +class TestSanitizeBytes: async def test_valid_image_round_trips_as_spoiler_file(self, flood_cog): - attachment = make_attachment(data=make_png_bytes()) - - result = await flood_cog._sanitize_attachment(attachment) + result = await flood_cog._sanitize_bytes(make_png_bytes()) assert result is not None assert isinstance(result, discord.File) @@ -298,24 +325,19 @@ async def test_valid_image_round_trips_as_spoiler_file(self, flood_cog): assert result.filename.endswith("evidencia.png") or "SPOILER" in result.filename async def test_garbage_bytes_returns_none(self, flood_cog): - attachment = make_attachment(data=b"not an image, just garbage" * 10) - - assert await flood_cog._sanitize_attachment(attachment) is None + assert await flood_cog._sanitize_bytes(b"not an image, just garbage" * 10) is None async def test_truncated_image_returns_none(self, flood_cog): - attachment = make_attachment(data=make_png_bytes()[:15]) - - assert await flood_cog._sanitize_attachment(attachment) is None + assert await flood_cog._sanitize_bytes(make_png_bytes()[:15]) is None -class TestHashAttachment: - async def test_matches_sha256_of_bytes(self, flood_cog): +class TestHashBytes: + def test_matches_sha256_of_bytes(self, flood_cog): import hashlib data = b"some bytes" - attachment = make_attachment(data=data) - assert await flood_cog._hash_attachment(attachment) == hashlib.sha256(data).hexdigest() + assert flood_cog._hash_bytes(data) == hashlib.sha256(data).hexdigest() # --------------------------------------------------------------------------- @@ -325,13 +347,13 @@ class TestAddSpamHelpers: def test_add_spam_message_persists_and_caches(self, flood_cog, isolated_logs): flood_cog.add_spam_message("mensaje malo") - assert "mensaje malo" in flood_cog.messages.spam + assert "mensaje malo" in flood_cog.spam assert "mensaje malo" in isolated_logs.log_spam_file.read_text() def test_add_spam_image_hash_persists_and_caches(self, flood_cog, isolated_logs): flood_cog.add_spam_image_hash("deadbeef") - assert "deadbeef" in flood_cog.messages.image_spam + assert "deadbeef" in flood_cog.image_spam assert "deadbeef" in isolated_logs.log_image_spam_file.read_text() @@ -339,12 +361,36 @@ def test_add_spam_image_hash_persists_and_caches(self, flood_cog, isolated_logs) # alert_moderation # --------------------------------------------------------------------------- class TestAlertModeration: + async def test_backticks_in_content_do_not_break_the_code_span(self, flood_cog): + """Regression test: the old repr(self._msg_content)[1:-1] trick + stripped repr()'s own quote characters but never escaped backticks, + so a message containing one could break out of the inline code + span in the "Mensaje" field. + """ + ctx = make_context(make_message(content="mira este `codigo` raro")) + + await flood_cog.alert_moderation(ctx, "Alerta", "scam") + + thread = flood_cog.main_mod_channel.create_thread.return_value + _, kwargs = thread.send.call_args + mensaje_field = next(f for f in kwargs["embed"].fields if f.name == "Mensaje") + assert mensaje_field.value.count("`") == 2 # only the wrapping backticks + + async def test_empty_content_shows_a_placeholder(self, flood_cog): + ctx = make_context(make_message(content="")) + + await flood_cog.alert_moderation(ctx, "Alerta", "known_image") + + thread = flood_cog.main_mod_channel.create_thread.return_value + _, kwargs = thread.send.call_args + mensaje_field = next(f for f in kwargs["embed"].fields if f.name == "Mensaje") + assert "(sin texto)" in mensaje_field.value + async def test_creates_thread_and_sends_embed(self, flood_cog): member = make_member(name="alguien") - message = make_message(author=member) - prime_cog(flood_cog, message) + ctx = make_context(make_message(author=member)) - await flood_cog.alert_moderation("Alerta de prueba", "scam") + await flood_cog.alert_moderation(ctx, "Alerta de prueba", "scam") flood_cog.main_mod_channel.create_thread.assert_awaited_once() _, kwargs = flood_cog.main_mod_channel.create_thread.call_args @@ -354,18 +400,15 @@ async def test_creates_thread_and_sends_embed(self, flood_cog): thread.send.assert_awaited_once() async def test_unknown_reason_raises(self, flood_cog): - message = make_message() - prime_cog(flood_cog, message) + ctx = make_context(make_message()) with pytest.raises(KeyError): - await flood_cog.alert_moderation("Título", "no-existe") + await flood_cog.alert_moderation(ctx, "Título", "no-existe") async def test_attachments_are_forwarded_sanitized_and_spoilered(self, flood_cog): - message = make_message() - prime_cog(flood_cog, message) - images = [make_attachment(data=make_png_bytes())] + ctx = make_context(make_message()) - await flood_cog.alert_moderation("Alerta", "known_image", attachments=images) + await flood_cog.alert_moderation(ctx, "Alerta", "known_image", image_bytes=[make_png_bytes()]) thread = flood_cog.main_mod_channel.create_thread.return_value _, kwargs = thread.send.call_args @@ -373,21 +416,18 @@ async def test_attachments_are_forwarded_sanitized_and_spoilered(self, flood_cog assert kwargs["files"][0].spoiler is True async def test_undecodable_attachment_is_skipped_not_forwarded(self, flood_cog): - message = make_message() - prime_cog(flood_cog, message) - images = [make_attachment(data=b"garbage" * 10)] + ctx = make_context(make_message()) - await flood_cog.alert_moderation("Alerta", "known_image", attachments=images) + await flood_cog.alert_moderation(ctx, "Alerta", "known_image", image_bytes=[b"garbage" * 10]) thread = flood_cog.main_mod_channel.create_thread.return_value _, kwargs = thread.send.call_args assert kwargs["files"] == [] async def test_no_attachments_means_no_warning_field(self, flood_cog): - message = make_message() - prime_cog(flood_cog, message) + ctx = make_context(make_message()) - await flood_cog.alert_moderation("Alerta", "scam") + await flood_cog.alert_moderation(ctx, "Alerta", "scam") thread = flood_cog.main_mod_channel.create_thread.return_value _, kwargs = thread.send.call_args @@ -399,6 +439,24 @@ async def test_no_attachments_means_no_warning_field(self, flood_cog): # on_message pipeline # --------------------------------------------------------------------------- class TestOnMessagePipeline: + async def test_uses_message_channel_directly_not_a_bot_cache_lookup(self, flood_cog): + """Regression test: on_message used to do + ``self._msg_channel = self.bot.get_channel(message.channel.id)`` + instead of just using ``message.channel``. A cache miss there made + ``_msg_channel`` None and crashed the first ``.send()`` downstream - + here the channel is never registered on the bot at all, so this + would fail the old way if the bug came back. + """ + member = make_member(name="repetidor") + message = make_message(content="discord nitro free http://x", author=member) + assert flood_cog.bot.get_channel(message.channel.id) is None + + await flood_cog.on_message(message) + + # The point here isn't *how many* times it's sent, just that it + # didn't crash trying to call .send() on a None channel. + message.channel.send.assert_awaited() + async def test_ignores_messages_from_bots(self, flood_cog): member = make_member(name="unbot", bot=True) message = make_message(content="discord nitro free http://x", author=member) @@ -421,8 +479,9 @@ async def test_ignores_short_textless_messages_without_attachments(self, flood_c await flood_cog.on_message(message) - # Never even gets far enough to set up per-message state. - assert flood_cog._msg_author is None + # Never even gets far enough to build a MessageContext or touch state. + message.channel.send.assert_not_awaited() + assert flood_cog.image_authors == {} async def test_short_caption_with_attachments_is_still_processed(self, flood_cog): member = make_member(name="alguien") @@ -434,9 +493,10 @@ async def test_short_caption_with_attachments_is_still_processed(self, flood_cog await flood_cog.on_message(message) - # It went through the pipeline (attachment_check saw it), even though - # the caption alone would have been skipped. - assert flood_cog._msg_author is member + # It went through the pipeline (attachment_check saw it and recorded + # this channel for the burst-tracking window), even though the + # caption alone would have been skipped. + assert member in flood_cog.image_authors async def test_skips_coordination_role_members(self, flood_cog): message = make_message( @@ -444,24 +504,16 @@ async def test_skips_coordination_role_members(self, flood_cog): author=make_member(name="mod", roles=[flood_cog.coord_role]), ) - result = None - try: - result = await flood_cog.on_message(message) - finally: - pass + await flood_cog.on_message(message) message.author.add_roles.assert_not_awaited() async def test_known_spam_text_is_deleted_and_author_muted( self, flood_cog, patched_message_delete ): - flood_cog.messages.spam.add("mensaje ya conocido como spam") + flood_cog.spam.add("mensaje ya conocido como spam") member = make_member(name="repetidor") - channel = make_text_channel(id=42) - flood_cog.bot.channels[channel.id] = channel - message = make_message( - content="Mensaje YA conocido como SPAM", author=member, channel=channel - ) + message = make_message(content="Mensaje YA conocido como SPAM", author=member) await flood_cog.on_message(message) diff --git a/tests/test_moderacion.py b/tests/test_moderacion.py index 6fd066c..5ec9411 100644 --- a/tests/test_moderacion.py +++ b/tests/test_moderacion.py @@ -1,10 +1,14 @@ +import csv +from types import SimpleNamespace from unittest.mock import AsyncMock import pandas as pd import pytest +from comandos.moderacion import _decode_message, _encode_message from tests.factories import ( encode_for_mod_row, + encode_for_mod_row_legacy, make_ctx, make_interaction, make_member, @@ -12,19 +16,47 @@ def add_pending_row(cog, post_id, *, channel="envio-eventos", author_id=42, author_name="autor", - content="contenido de prueba"): + content="contenido de prueba", legacy_encoding=False): + encode = encode_for_mod_row_legacy if legacy_encoding else encode_for_mod_row new_row = { "date": "2026-01-01 00:00:00", "message_id": str(post_id), "channel": channel, "author_id": str(author_id), "author": author_name, - "message": encode_for_mod_row(content), + "message": encode(content), } cog.bot.data_mod = pd.concat([cog.bot.data_mod, pd.DataFrame([new_row])], ignore_index=True) return new_row +def read_last_csv_row(path, delimiter=";"): + """isolated_logs seeds each log file with a blank line instead of the + real header, so skip empty rows and return the last one actually + written.""" + with path.open(newline="") as f: + rows = [row for row in csv.reader(f, delimiter=delimiter) if row] + return rows[-1] + + +class TestMessageEncoding: + def test_round_trips(self): + assert _decode_message(_encode_message("hola mundo")) == "hola mundo" + + def test_round_trips_accented_and_emoji(self): + text = "¿Cómo estás? 🎉" + assert _decode_message(_encode_message(text)) == text + + def test_decodes_the_legacy_bytes_repr_format(self): + """Rows written before the eval()-removal fix stored the repr of a + base64 bytes object (e.g. "b'aG9sYQ=='") instead of a plain base64 + string. _decode_message must still handle those without eval().""" + legacy = encode_for_mod_row_legacy("mensaje antiguo") + + assert legacy.startswith("b'") + assert _decode_message(legacy) == "mensaje antiguo" + + # --------------------------------------------------------------------------- # small helpers # --------------------------------------------------------------------------- @@ -54,13 +86,6 @@ def test_false_for_regular_user(self, moderacion_cog): assert moderacion_cog._is_bot(ctx) is False -class TestIsValidChannel: - def test_true_when_channel_is_registered_on_the_bot(self, moderacion_cog, moderacion_channels): - ctx = make_ctx(channel=moderacion_channels["mod"]) - - assert moderacion_cog._is_valid_channel(ctx) is True - - class TestGetChannelsMainModSub: def test_resolves_the_three_channels(self, moderacion_cog, moderacion_channels): main, mod, sub = moderacion_cog.get_channels_main_mod_sub(moderacion_channels["sub"].id) @@ -120,6 +145,18 @@ async def test_happy_path_resolves_everything(self, moderacion_cog, moderacion_c assert vp.ch_sub is moderacion_channels["sub"] assert vp.author.id == 99 + async def test_resolves_a_row_logged_before_the_eval_removal_fix( + self, moderacion_cog, moderacion_channels + ): + add_pending_row(moderacion_cog, post_id=2, author_id=99, legacy_encoding=True) + moderacion_cog.bot.users_by_id[99] = make_member(id=99, name="remitente") + ctx = make_ctx(channel=moderacion_channels["mod"], content="%aceptar 2") + + vp = await moderacion_cog._get_validated_post(ctx, None, "%aceptar") + + assert vp is not None + assert vp.message_dec == "contenido de prueba" + async def test_unknown_post_id_reports_error_and_returns_none( self, moderacion_cog, moderacion_channels ): @@ -152,17 +189,47 @@ def test_aceptar_writes_expected_line(self, moderacion_cog, isolated_logs): moderacion_cog._log_action("aceptar", row, "1", "moderador#0") - content = isolated_logs.log_accepted_file.read_text() - assert '"1"' in content - assert '"moderador#0"' in content + fields = read_last_csv_row(isolated_logs.log_accepted_file) + assert fields[1] == "1" # post_id + assert fields[6] == "moderador#0" # moderator + assert len(fields) == 7 # no "reason" column for aceptar def test_rechazar_includes_reason(self, moderacion_cog, isolated_logs): row = pd.DataFrame([add_pending_row(moderacion_cog, post_id=2)]) moderacion_cog._log_action("rechazar", row, "2", "moderador#0", "le falta info") - content = isolated_logs.log_rejected_file.read_text() - assert '"le falta info"' in content + fields = read_last_csv_row(isolated_logs.log_rejected_file) + assert fields[-1] == "le falta info" + + def test_rechazar_without_reason_still_writes_the_reason_column( + self, moderacion_cog, isolated_logs + ): + """Regression test: an empty reason used to skip the "reason" field + entirely (``if reason: line += ...``), leaving that row one column + short of log_rejected_file's fixed 8-column header - which + pd.read_csv (run on every bot startup) can choke on. + """ + row = pd.DataFrame([add_pending_row(moderacion_cog, post_id=3)]) + + moderacion_cog._log_action("rechazar", row, "3", "moderador#0", "") + + fields = read_last_csv_row(isolated_logs.log_rejected_file) + assert len(fields) == 8 + assert fields[-1] == "" + + def test_embedded_quotes_and_delimiters_round_trip(self, moderacion_cog, isolated_logs): + """Regression test: hand-built '"{value}"' quoting didn't escape + embedded quotes/delimiters, silently corrupting the row. A proper + csv.writer round-trips this correctly. + """ + tricky_name = 'mod "raro"; con punto y coma' + row = pd.DataFrame([add_pending_row(moderacion_cog, post_id=4, author_name=tricky_name)]) + + moderacion_cog._log_action("aceptar", row, "4", tricky_name) + + fields = read_last_csv_row(isolated_logs.log_accepted_file) + assert fields[6] == tricky_name class TestLogOnMessage: @@ -175,7 +242,8 @@ def test_appends_row_and_writes_log_line(self, moderacion_cog, isolated_logs): moderacion_cog.log_on_message("envio-eventos", author) assert len(moderacion_cog.bot.data_mod) == before + 1 - assert "777" in isolated_logs.log_mod_file.read_text() + fields = read_last_csv_row(isolated_logs.log_mod_file) + assert fields[1] == "777" # --------------------------------------------------------------------------- @@ -216,7 +284,9 @@ async def test_removes_pending_row_and_notifies_channels( ): add_pending_row(moderacion_cog, post_id=1, author_id=99, content="contenido aprobado") moderacion_cog.bot.users_by_id[99] = make_member(id=99, name="remitente") - moderacion_cog._msg_id = 12345 + moderacion_channels["main"].send = AsyncMock( + return_value=SimpleNamespace(jump_url="https://discord.com/channels/1/2/3") + ) ctx = make_ctx( author=make_member(name="moderador"), @@ -227,11 +297,18 @@ async def test_removes_pending_row_and_notifies_channels( await moderacion_cog._aceptar_mensaje(ctx) assert moderacion_cog.bot.data_mod.empty - moderacion_channels["mod"].send.assert_awaited_once() moderacion_channels["main"].send.assert_awaited_once() (msg,), _ = moderacion_channels["main"].send.call_args assert "contenido aprobado" in msg + # Regression test: the confirmation sent to the mod channel used to + # link to a jump_url built from self._msg_id (the *original + # submission's* id, in a different channel) instead of the message + # that was actually just posted to ch_main. + moderacion_channels["mod"].send.assert_awaited_once() + (mod_msg,), _ = moderacion_channels["mod"].send.call_args + assert "https://discord.com/channels/1/2/3" in mod_msg + async def test_unknown_post_id_does_not_touch_channels(self, moderacion_cog, moderacion_channels): ctx = make_ctx(channel=moderacion_channels["mod"], content="%aceptar 999") diff --git a/tests/test_utils.py b/tests/test_utils.py index 2ac0a48..dbd772d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,5 @@ from tests.factories import make_message -from utils import get_message_to_moderate, get_moderation_channel, strip_message +from utils import get_message_to_moderate, strip_message class TestStripMessage: @@ -25,18 +25,6 @@ def test_empty_string(self): assert strip_message("") == "" -class TestGetModerationChannel: - def test_returns_bot_get_channel_result(self): - sentinel = object() - - class FakeBot: - def get_channel(self, channel_id): - assert channel_id == 42 - return sentinel - - assert get_moderation_channel(FakeBot(), 42) is sentinel - - class TestGetMessageToModerate: def test_embed_contains_message_and_commands(self): message = make_message(content="hola, este es mi post") diff --git a/utils.py b/utils.py index 41cb7c1..18cf793 100644 --- a/utils.py +++ b/utils.py @@ -2,6 +2,7 @@ import discord from datetime import datetime, timezone +import colors from configuration import Config config = Config() @@ -10,10 +11,6 @@ aceptar_emoji = "\N{WHITE HEAVY CHECK MARK}" rechazar_emoji = "\N{CROSS MARK}" -def get_moderation_channel(bot, channel_id): - channel_mod = bot.get_channel(channel_id) - return channel_mod - def get_message_to_moderate(message): msg = ( @@ -28,7 +25,7 @@ def get_message_to_moderate(message): embed = discord.Embed( title="Moderación de mensaje", description=msg, - colour=0x2B597B, + colour=colors.BRAND, ) return embed