Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions bot.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import asyncio
import csv
import pandas as pd
import discord
import logging
from discord.ext import commands
from datetime import datetime

from configuration import Config
from utils import read_csv_dicts

# Add cogs
from comandos.ping import Ping
Expand Down Expand Up @@ -71,16 +71,20 @@ async def on_command_error(msg, error):

async def main():
# Reading data
data_mod = pd.read_csv(str(config.log_mod_file), sep=";", dtype=str)
data_accepted = pd.read_csv(str(config.log_accepted_file), sep=";", dtype=str)
data_rejected = pd.read_csv(str(config.log_rejected_file), sep=";", dtype=str)
data_mod = read_csv_dicts(config.log_mod_file)
data_accepted = read_csv_dicts(config.log_accepted_file)
data_rejected = read_csv_dicts(config.log_rejected_file)

# Pending moderation
# Get 'message_id' from the 'accepted' and 'rejected' files
ready_ids = set(data_accepted["message_id"]).union(data_rejected["message_id"])

# keeping the data in the bot instance
bot.data_mod = data_mod[~data_mod["message_id"].isin(ready_ids)] # type: ignore[attr-defined]
ready_ids = {row["message_id"] for row in data_accepted} | {
row["message_id"] for row in data_rejected
}

# keeping the data in the bot instance, keyed by message_id
bot.data_mod = { # type: ignore[attr-defined]
row["message_id"]: row for row in data_mod if row["message_id"] not in ready_ids
}

for cog_cls in COGS:
await bot.add_cog(cog_cls(bot))
Expand Down
54 changes: 24 additions & 30 deletions comandos/moderacion.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from dataclasses import dataclass
from typing import Optional

import pandas as pd
import discord
from discord.ext import commands

Expand Down Expand Up @@ -43,13 +42,12 @@ def _decode_message(stored: str) -> str:
@dataclass
class ValidatedPost:
post_id: str
mod_row: pd.DataFrame
mod_row: dict
ch_main: discord.TextChannel
ch_mod: discord.TextChannel
ch_sub: discord.TextChannel
message_dec: str
author: discord.User
condition: pd.Series


class RejectModal(discord.ui.Modal, title="Rechazar Mensaje"):
Expand Down Expand Up @@ -154,20 +152,18 @@ async def _get_validated_post(
if post_id is None:
return None

if post_id not in set(self.bot.data_mod["message_id"]):
mod_row = self.bot.data_mod.get(post_id)
if mod_row is None:
await channel_mod.send(f"El ID {post_id} no fue encontrado")
return None

condition = self.bot.data_mod["message_id"] == post_id
mod_row = self.bot.data_mod[condition]

channel_id = config.CHANNELS[
mod_row["channel"].values[0].replace("envio-", "")
mod_row["channel"].replace("envio-", "")
]["submission"]
ch_main, ch_mod, ch_sub = self.get_channels_main_mod_sub(channel_id)

message_dec = _decode_message(mod_row["message"].values[0])
author = self.bot.get_user(int(mod_row["author_id"].values[0]))
message_dec = _decode_message(mod_row["message"])
author = self.bot.get_user(int(mod_row["author_id"]))

return ValidatedPost(
post_id=post_id,
Expand All @@ -177,7 +173,6 @@ async def _get_validated_post(
ch_sub=ch_sub,
message_dec=message_dec,
author=author,
condition=condition,
)

def _log_action(self, action: str, row, post_id, moderator, reason: str = ""):
Expand All @@ -187,9 +182,8 @@ def _log_action(self, action: str, row, post_id, moderator, reason: str = ""):

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.
the row - these files are re-parsed 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
Expand All @@ -198,10 +192,10 @@ def _log_action(self, action: str, row, post_id, moderator, reason: str = ""):
fields = [
date_str,
post_id,
row["channel"].values[0],
row["author_id"].values[0],
row["author"].values[0],
row["message"].values[0],
row["channel"],
row["author_id"],
row["author"],
row["message"],
moderator,
]
# log_rejected_file's header always has a "reason" column - include
Expand All @@ -215,15 +209,16 @@ def _log_action(self, action: str, row, post_id, moderator, reason: str = ""):

def log_on_message(self, channel_sub, author):
date_str = f"{datetime.now()}"
message_id = f"{self._msg_id}"
new_data = {
"date": date_str,
"message_id": f"{self._msg_id}",
"message_id": message_id,
"channel": f"{channel_sub}",
"author_id": f"{author.id}",
"author": f"{author}",
"message": f"{self._msg_enc}",
}
self.bot.data_mod = pd.concat([self.bot.data_mod, pd.DataFrame([new_data])])
self.bot.data_mod[message_id] = new_data

with open(str(config.log_mod_file), "a", newline="") as f:
csv.writer(f, delimiter=";").writerow([
Expand Down Expand Up @@ -279,7 +274,7 @@ async def _aceptar_mensaje(self, ctx, message_id: Optional[int] = None):

moderator = self._resolve_author(ctx)
self._log_action("aceptar", vp.mod_row, vp.post_id, moderator)
self.bot.data_mod = self.bot.data_mod[~vp.condition]
del self.bot.data_mod[vp.post_id]

# Send to the destination channel first so the confirmation below can
# link to the message that was actually posted there, instead of
Expand Down Expand Up @@ -311,7 +306,7 @@ async def _rechazar_mensaje(

moderator = self._resolve_author(ctx)
self._log_action("rechazar", vp.mod_row, vp.post_id, moderator, reason or "")
self.bot.data_mod = self.bot.data_mod[~vp.condition]
del self.bot.data_mod[vp.post_id]

embed = discord.Embed(
title="Mensaje rechazado",
Expand Down Expand Up @@ -342,7 +337,7 @@ def get_mod_pending(self, data):
title="Mensajes pendientes de moderación",
colour=colors.BRAND,
)
for idx, mod_row in data.iterrows():
for mod_row in data.values():
author = self.bot.get_user(int(mod_row["author_id"]))
if not author:
logger.warning("El author '%s' ya no existe en el server.", mod_row["author_id"])
Expand Down Expand Up @@ -376,20 +371,19 @@ async def mostrar_mensajes(self, ctx):
if post_id is None:
return

if post_id not in self.bot.data_mod["message_id"].to_list():
mod_row = self.bot.data_mod.get(post_id)
if mod_row is None:
await channel_mod.send(f"ID no encontrado: {post_id}")
return

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 = _decode_message(mod_row["message"].values[0])
author = self.bot.get_user(int(mod_row["author_id"]))
m_message = _decode_message(mod_row["message"])

embed = discord.Embed(
title="Mensaje pendiente de moderación",
description=(
f"Post de {author.mention} el {mod_row['date'].values[0]}\n"
f"**ID:** {mod_row['message_id'].values[0]}\n"
f"Post de {author.mention} el {mod_row['date']}\n"
f"**ID:** {mod_row['message_id']}\n"
f"**Mensaje:**\n```\n{m_message}\n```\n"
),
colour=colors.BRAND,
Expand Down
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
discord.py
pandas
toml
Pillow
5 changes: 1 addition & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import pandas as pd
import pytest

from comandos.flood import FloodSpam
Expand Down Expand Up @@ -51,9 +50,7 @@ async def moderacion_cog(isolated_logs, moderacion_channels):

channels = {c.id: c for c in moderacion_channels.values()}
bot = make_bot(channels=channels, guild=SimpleNamespace(id=333333333333333333))
bot.data_mod = pd.DataFrame(
columns=["date", "message_id", "channel", "author_id", "author", "message"]
)
bot.data_mod = {} # message_id -> row dict, same shape as read_csv_dicts() rows
cog = Moderacion(bot)
await cog.on_ready()
return cog
19 changes: 9 additions & 10 deletions tests/test_moderacion.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock

import pandas as pd
import pytest

from comandos.moderacion import _decode_message, _encode_message
Expand All @@ -26,7 +25,7 @@ def add_pending_row(cog, post_id, *, channel="envio-eventos", author_id=42, auth
"author": author_name,
"message": encode(content),
}
cog.bot.data_mod = pd.concat([cog.bot.data_mod, pd.DataFrame([new_row])], ignore_index=True)
cog.bot.data_mod[str(post_id)] = new_row
return new_row


Expand Down Expand Up @@ -185,7 +184,7 @@ async def test_bot_author_returns_none(self, moderacion_cog, moderacion_channels
# ---------------------------------------------------------------------------
class TestLogAction:
def test_aceptar_writes_expected_line(self, moderacion_cog, isolated_logs):
row = pd.DataFrame([add_pending_row(moderacion_cog, post_id=1)])
row = add_pending_row(moderacion_cog, post_id=1)

moderacion_cog._log_action("aceptar", row, "1", "moderador#0")

Expand All @@ -195,7 +194,7 @@ def test_aceptar_writes_expected_line(self, moderacion_cog, isolated_logs):
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)])
row = add_pending_row(moderacion_cog, post_id=2)

moderacion_cog._log_action("rechazar", row, "2", "moderador#0", "le falta info")

Expand All @@ -207,10 +206,10 @@ def test_rechazar_without_reason_still_writes_the_reason_column(
):
"""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.
short of log_rejected_file's fixed 8-column header - which the CSV
reader (run on every bot startup) can choke on.
"""
row = pd.DataFrame([add_pending_row(moderacion_cog, post_id=3)])
row = add_pending_row(moderacion_cog, post_id=3)

moderacion_cog._log_action("rechazar", row, "3", "moderador#0", "")

Expand All @@ -224,7 +223,7 @@ def test_embedded_quotes_and_delimiters_round_trip(self, moderacion_cog, isolate
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)])
row = add_pending_row(moderacion_cog, post_id=4, author_name=tricky_name)

moderacion_cog._log_action("aceptar", row, "4", tricky_name)

Expand Down Expand Up @@ -296,7 +295,7 @@ async def test_removes_pending_row_and_notifies_channels(

await moderacion_cog._aceptar_mensaje(ctx)

assert moderacion_cog.bot.data_mod.empty
assert moderacion_cog.bot.data_mod == {}
moderacion_channels["main"].send.assert_awaited_once()
(msg,), _ = moderacion_channels["main"].send.call_args
assert "contenido aprobado" in msg
Expand Down Expand Up @@ -332,7 +331,7 @@ async def test_removes_pending_row_and_notifies_with_reason(

await moderacion_cog._rechazar_mensaje(ctx)

assert moderacion_cog.bot.data_mod.empty
assert moderacion_cog.bot.data_mod == {}
moderacion_channels["sub"].send.assert_awaited_once()
_, kwargs = moderacion_channels["sub"].send.call_args
assert "le falta info aqui" in kwargs["embed"].fields[0].value
Expand Down
30 changes: 29 additions & 1 deletion tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from tests.factories import make_message
from utils import get_message_to_moderate, strip_message
from utils import get_message_to_moderate, read_csv_dicts, strip_message


class TestStripMessage:
Expand All @@ -25,6 +25,34 @@ def test_empty_string(self):
assert strip_message("") == ""


class TestReadCsvDicts:
def test_reads_rows_as_dicts(self, tmp_path):
path = tmp_path / "data.csv"
path.write_text("date;message_id;author\n2026-01-01;1;alice\n2026-01-02;2;bob\n")

rows = read_csv_dicts(path)

assert rows == [
{"date": "2026-01-01", "message_id": "1", "author": "alice"},
{"date": "2026-01-02", "message_id": "2", "author": "bob"},
]

def test_handles_embedded_quotes_delimiters_and_newlines(self, tmp_path):
path = tmp_path / "data.csv"
path.write_text('date;author;message\n2026-01-01;"mod ""raro""; con coma";"linea uno\nlinea dos"\n')

rows = read_csv_dicts(path)

assert rows[0]["author"] == 'mod "raro"; con coma'
assert rows[0]["message"] == "linea uno\nlinea dos"

def test_only_header_returns_empty_list(self, tmp_path):
path = tmp_path / "data.csv"
path.write_text("date;message_id;author\n")

assert read_csv_dicts(path) == []


class TestGetMessageToModerate:
def test_embed_contains_message_and_commands(self):
message = make_message(content="hola, este es mi post")
Expand Down
8 changes: 8 additions & 0 deletions utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import csv
import re
import discord
from datetime import datetime, timezone
Expand All @@ -12,6 +13,13 @@
rechazar_emoji = "\N{CROSS MARK}"


def read_csv_dicts(path, delimiter=";"):
"""Read a semicolon-delimited CSV file (as written by csv.writer
elsewhere in this project) into a list of {column: value} dicts."""
with open(path, newline="") as f:
return list(csv.DictReader(f, delimiter=delimiter))


def get_message_to_moderate(message):
msg = (
f"{datetime.now(timezone.utc).replace(tzinfo=None)} UTC\n"
Expand Down
Loading