From 3e5c53104b820763604005914b571bca2bf8e16a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristi=C3=A1n=20Maureira-Fredes?= Date: Sat, 15 Aug 2026 23:28:03 +0200 Subject: [PATCH 1/3] Add data-retention and right-to-erasure enforcement (GDPR) New comandos/retencion.py cog, covering two mechanisms: - A daily background task that deletes rows older than config.RETENTION_DAYS (default 30) from every log holding personal data (main_log, mod_log, accepted, rejected). Rows with a missing/ unparseable date are kept rather than guessed-and-deleted. Rows pruned from mod_log are also evicted from the live bot.data_mod. log_spam_file/log_image_spam_file (the known-spam text/image-hash caches) are intentionally excluded - they store only content/hashes, never an author, so they aren't personal data to begin with. - A %olvidar command (restricted to the moderation role) that removes every row belonging to a given user from those same logs on request, matched by the stable author_id (not the displayed username, which can change). Shows a preview of what will be deleted and requires an explicit Confirmar/Cancelar button click - re-checked against the moderation role at click time, not just at command invocation - before doing anything irreversible. The erasure itself is logged to a new gdpr_erasure_log.csv (id + counts only, never the erased content), as an audit trail that a request was honored. configuration.py gains RETENTION_DAYS and log_gdpr_file. read_last_csv_row moved from tests/test_moderacion.py to tests/factories.py for reuse. --- bot.py | 3 +- comandos/retencion.py | 246 +++++++++++++++++++++++++++++++++++++ configuration.py | 13 ++ conftest.py | 1 + tests/conftest.py | 12 ++ tests/factories.py | 11 ++ tests/test_moderacion.py | 11 +- tests/test_retencion.py | 259 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 545 insertions(+), 11 deletions(-) create mode 100644 comandos/retencion.py create mode 100644 tests/test_retencion.py diff --git a/bot.py b/bot.py index a992ed6..b64f897 100644 --- a/bot.py +++ b/bot.py @@ -16,11 +16,12 @@ from comandos.limpia import Limpia from comandos.archivar import Archivar from comandos.enviar import Enviar +from comandos.retencion import Retencion # 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) +COGS = (Ping, Ayuda, Limpia, Archivar, Moderacion, FloodSpam, Enviar, Retencion) # Global instance of the server guild = None diff --git a/comandos/retencion.py b/comandos/retencion.py new file mode 100644 index 0000000..8fb2794 --- /dev/null +++ b/comandos/retencion.py @@ -0,0 +1,246 @@ +"""Data-retention and right-to-erasure enforcement (GDPR). + +Two mechanisms: +- A daily background task that deletes rows older than + ``config.RETENTION_DAYS`` from every log that holds personal data. +- A ``%olvidar`` command (restricted to the moderation role) that removes + every stored row belonging to a specific user on request, with an + explicit confirmation step first. + +``log_spam_file``/``log_image_spam_file`` (the known-spam text/image-hash +caches) are intentionally excluded from both: they store only message +content or image hashes, never an author, so they aren't personal data to +begin with. +""" +import csv +import logging +from datetime import datetime, timedelta + +import discord +from discord.ext import commands, tasks + +import colors +from configuration import Config + +config = Config() +logger = logging.getLogger(__name__) + +# Log files that hold personal data and are therefore subject to the +# retention/erasure policy below. +PERSONAL_DATA_LOGS = ( + "log_main_file", + "log_mod_file", + "log_accepted_file", + "log_rejected_file", +) + + +def _rewrite_csv(path, keep_row): + """Rewrite ``path`` keeping only rows for which ``keep_row(row)`` is + true. Returns the rows that were removed (as dicts). + + This runs synchronously with no ``await`` in between reading and + rewriting the file, so nothing else in the (single-threaded) event + loop can interleave and append a row mid-operation. + """ + with open(path, newline="") as f: + reader = csv.DictReader(f, delimiter=";") + fieldnames = reader.fieldnames + rows = list(reader) + + if not fieldnames: + return [] + + kept, removed = [], [] + for row in rows: + (kept if keep_row(row) else removed).append(row) + + if removed: + with open(path, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames, delimiter=";") + writer.writeheader() + writer.writerows(kept) + + return removed + + +def prune_old_rows(path, max_age_days=None, now=None): + """Remove rows whose "date" column is older than ``max_age_days``. + + Rows with a missing/unparseable date are kept - fail safe, rather than + guessing and deleting something we can't confirm the age of. + """ + max_age_days = config.RETENTION_DAYS if max_age_days is None else max_age_days + cutoff = (now or datetime.now()) - timedelta(days=max_age_days) + + def keep_row(row): + try: + return datetime.fromisoformat(row.get("date", "")) >= cutoff + except ValueError: + return True + + return _rewrite_csv(path, keep_row) + + +def remove_rows_for_author(path, author_id) -> list: + """Remove every row belonging to ``author_id``. + + Matched on the "author_id" column, not the "author" display-name + column: usernames can change over time, ids can't. + """ + author_id = str(author_id) + return _rewrite_csv(path, lambda row: row.get("author_id") != author_id) + + +def count_rows_for_author(path, author_id) -> int: + """Read-only preview of how many rows remove_rows_for_author() would + remove, without modifying anything.""" + author_id = str(author_id) + with open(path, newline="") as f: + reader = csv.DictReader(f, delimiter=";") + return sum(1 for row in reader if row.get("author_id") == author_id) + + +class ConfirmErasureView(discord.ui.View): + def __init__(self, cog: "Retencion", target: discord.abc.User): + super().__init__(timeout=60) + self.cog = cog + self.target = target + + async def _requester_is_mod(self, interaction: discord.Interaction) -> bool: + member = interaction.user + if not isinstance(member, discord.Member) or self.cog.coord_role not in member.roles: + await interaction.response.send_message( + "No tienes el rol necesario para confirmar esta acción.", ephemeral=True + ) + return False + return True + + @discord.ui.button(label="Confirmar eliminación", style=discord.ButtonStyle.danger) + async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button): + if not await self._requester_is_mod(interaction): + return + + counts = self.cog.erase_user_data(self.target.id, requested_by=interaction.user) + total = sum(counts.values()) + detail = "\n".join(f"- `{name}`: {n}" for name, n in counts.items()) + await interaction.response.edit_message( + content=( + f"\N{WHITE HEAVY CHECK MARK} Datos de {self.target.mention} " + f"(`{self.target.id}`) eliminados: **{total}** registro(s) en total.\n{detail}" + ), + embed=None, + view=None, + ) + + @discord.ui.button(label="Cancelar", style=discord.ButtonStyle.secondary) + async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button): + if not await self._requester_is_mod(interaction): + return + await interaction.response.edit_message( + content="Solicitud de eliminación cancelada.", embed=None, view=None + ) + + +class Retencion(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.guild = None + self._coord_role = None + + @property + def coord_role(self) -> discord.Role: + assert self._coord_role is not None, "Coordination role not found - make sure it exists first" + return self._coord_role + + @commands.Cog.listener() + async def on_ready(self): + if self.guild is None: + self.guild = self.bot.get_guild(config.GUILD) + + if self._coord_role is None: + self._coord_role = discord.utils.get(self.guild.roles, name=config.MOD_ROLE) + + if not self.prune_logs.is_running(): + self.prune_logs.start() + + def _evict_from_data_mod(self, removed_rows): + data_mod = getattr(self.bot, "data_mod", None) + if data_mod is None: + return + for row in removed_rows: + data_mod.pop(row.get("message_id"), None) + + async def run_prune_once(self): + """The actual retention-pruning logic, callable directly (e.g. from + tests) without going through the @tasks.loop scheduler.""" + for attr in PERSONAL_DATA_LOGS: + path = getattr(config, attr) + removed = prune_old_rows(path) + if not removed: + continue + logger.info( + "Retention: removed %d row(s) older than %d day(s) from %s", + len(removed), config.RETENTION_DAYS, path, + ) + if attr == "log_mod_file": + self._evict_from_data_mod(removed) + + @tasks.loop(hours=24) + async def prune_logs(self): + await self.run_prune_once() + + def erase_user_data(self, user_id: int, requested_by) -> dict: + """Remove every row belonging to ``user_id`` from every + personal-data log. Returns a {log_name: rows_removed} summary.""" + counts = {} + for attr in PERSONAL_DATA_LOGS: + path = getattr(config, attr) + removed = remove_rows_for_author(path, user_id) + counts[attr] = len(removed) + if attr == "log_mod_file": + self._evict_from_data_mod(removed) + + self._log_erasure(user_id, requested_by, sum(counts.values())) + logger.info( + "GDPR erasure: user_id=%s requested_by=%s counts=%s", user_id, requested_by, counts + ) + return counts + + def _log_erasure(self, user_id, requested_by, total_removed): + """Audit trail for the erasure itself - only the id and a count, + never the erased content, so this is safe to keep indefinitely as + evidence the request was honored.""" + with open(config.log_gdpr_file, "a", newline="") as f: + csv.writer(f, delimiter=";").writerow([ + f"{datetime.now()}", user_id, f"{requested_by}", requested_by.id, total_removed, + ]) + + @commands.command( + name="olvidar", + help="Elimina todos los datos almacenados de un usuario (derecho al olvido / GDPR)", + ) + @commands.has_role(config.MOD_ROLE) + async def olvidar_usuario(self, ctx, user: discord.User): + preview = { + attr: count_rows_for_author(getattr(config, attr), user.id) + for attr in PERSONAL_DATA_LOGS + } + total = sum(preview.values()) + + if total == 0: + await ctx.send(f"No se encontraron datos almacenados para {user.mention} (`{user.id}`).") + return + + embed = discord.Embed( + title="\N{WARNING SIGN} Confirmar eliminación de datos", + description=( + f"Se eliminarán **{total}** registro(s) de {user.mention} (`{user.id}`) " + "de forma permanente. Esta acción no se puede deshacer." + ), + colour=colors.ARCHIVE, + ) + for attr, n in preview.items(): + embed.add_field(name=attr, value=str(n), inline=True) + + await ctx.send(embed=embed, view=ConfirmErasureView(self, user)) diff --git a/configuration.py b/configuration.py index b1f56e1..5f73a0c 100644 --- a/configuration.py +++ b/configuration.py @@ -48,6 +48,9 @@ def __init__(self): # message in 2+ different channels is treated as a spam burst. self.IMAGE_ATTACHMENT_LIMIT = 2 self.IMAGE_BURST_WINDOW = 60 * 5 + # Data-retention policy (see comandos/retencion.py): personal-data + # logs older than this are deleted daily. + self.RETENTION_DAYS = 30 except KeyError: logger.error( "Error while reading the configuration file. " @@ -72,6 +75,12 @@ def setup_log_files(self): self.log_accepted_file = Path(LOG_MOD_ACCEPTED_FILE) self.log_rejected_file = Path(LOG_MOD_REJECTED_FILE) + # Audit trail for right-to-erasure requests (comandos/retencion.py). + # Only ever holds a user id, who requested it, and a row count - no + # personal content - so it's safe to keep indefinitely as evidence + # a request was honored. + self.log_gdpr_file = Path("logs/gdpr_erasure_log.csv") + # Checking files self.check_create_file( self.log_file, "date;command;message_id;channel;author_id;author;message\n" @@ -93,6 +102,10 @@ def setup_log_files(self): self.log_main_file, "date;command;message_id;channel;author_id;author;message\n", ) + self.check_create_file( + self.log_gdpr_file, + "date;user_id;requested_by;requested_by_id;total_removed\n", + ) def get_spam_messages(self): # Adding spam messages diff --git a/conftest.py b/conftest.py index 2fc48a8..9cf0741 100644 --- a/conftest.py +++ b/conftest.py @@ -97,6 +97,7 @@ def isolated_logs(config, tmp_path, monkeypatch): ("log_rejected_file", "mod_log_rejected.csv"), ("log_main_file", "main_log.csv"), ("log_file", "bot_log.csv"), + ("log_gdpr_file", "gdpr_erasure_log.csv"), ]: path = logs_dir / filename path.write_text("\n") diff --git a/tests/conftest.py b/tests/conftest.py index e1b645c..70ff5ab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ from comandos.flood import FloodSpam from comandos.moderacion import Moderacion +from comandos.retencion import Retencion from tests.factories import make_bot, make_role, make_text_channel @@ -54,3 +55,14 @@ async def moderacion_cog(isolated_logs, moderacion_channels): cog = Moderacion(bot) await cog.on_ready() return cog + + +@pytest.fixture +def retencion_cog(isolated_logs, coord_role): + """A Retencion cog wired up the way on_ready() would, without needing a + real discord.Client/Guild or starting the real @tasks.loop.""" + bot = make_bot() + bot.data_mod = {} + cog = Retencion(bot) + cog._coord_role = coord_role + return cog diff --git a/tests/factories.py b/tests/factories.py index 9260e94..fe8fb94 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -9,6 +9,7 @@ ``isinstance()`` checks against - Mock's ``spec=`` makes ``isinstance(mock, SpecClass)`` return True, which a plain fake can't do. """ +import csv from io import BytesIO from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock @@ -19,6 +20,15 @@ from utils import strip_message +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] + + def make_role(name="Coordinacion", id=1000): return SimpleNamespace(name=name, id=id, mention=f"@{name}") @@ -155,6 +165,7 @@ def make_interaction(user=None, channel=None): interaction.response = MagicMock() interaction.response.send_message = AsyncMock() interaction.response.send_modal = AsyncMock() + interaction.response.edit_message = AsyncMock() return interaction diff --git a/tests/test_moderacion.py b/tests/test_moderacion.py index 27e16e5..71e9f8f 100644 --- a/tests/test_moderacion.py +++ b/tests/test_moderacion.py @@ -1,4 +1,3 @@ -import csv from types import SimpleNamespace from unittest.mock import AsyncMock @@ -11,6 +10,7 @@ make_ctx, make_interaction, make_member, + read_last_csv_row, ) @@ -29,15 +29,6 @@ def add_pending_row(cog, post_id, *, channel="envio-eventos", author_id=42, auth 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" diff --git a/tests/test_retencion.py b/tests/test_retencion.py new file mode 100644 index 0000000..ec54a6e --- /dev/null +++ b/tests/test_retencion.py @@ -0,0 +1,259 @@ +import csv +from datetime import datetime, timedelta + +import pytest + +from comandos.retencion import ( + ConfirmErasureView, + count_rows_for_author, + prune_old_rows, + remove_rows_for_author, +) +from tests.factories import ( + bind_commands, + make_ctx, + make_interaction, + make_member, + read_last_csv_row, +) + +OLD = str(datetime.now() - timedelta(days=40)) +RECENT = str(datetime.now() - timedelta(days=1)) + + +def write_csv(path, header, rows): + with open(path, "w", newline="") as f: + writer = csv.writer(f, delimiter=";") + writer.writerow(header) + writer.writerows(rows) + + +def read_csv(path): + with open(path, newline="") as f: + return list(csv.DictReader(f, delimiter=";")) + + +# --------------------------------------------------------------------------- +# prune_old_rows +# --------------------------------------------------------------------------- +class TestPruneOldRows: + def test_removes_rows_older_than_max_age(self, tmp_path): + path = tmp_path / "data.csv" + write_csv(path, ["date", "message_id"], [[OLD, "1"], [RECENT, "2"]]) + + removed = prune_old_rows(path, max_age_days=30) + + assert [r["message_id"] for r in removed] == ["1"] + assert [r["message_id"] for r in read_csv(path)] == ["2"] + + def test_keeps_rows_with_unparseable_dates(self, tmp_path): + """Fail safe: if we can't tell how old a row is, don't delete it.""" + path = tmp_path / "data.csv" + write_csv(path, ["date", "message_id"], [["not-a-date", "1"], [RECENT, "2"]]) + + removed = prune_old_rows(path, max_age_days=30) + + assert removed == [] + assert len(read_csv(path)) == 2 + + def test_header_only_file_is_a_noop(self, tmp_path): + path = tmp_path / "data.csv" + write_csv(path, ["date", "message_id"], []) + + assert prune_old_rows(path, max_age_days=30) == [] + + def test_uses_configured_retention_days_by_default(self, tmp_path, config): + path = tmp_path / "data.csv" + just_inside = str(datetime.now() - timedelta(days=config.RETENTION_DAYS - 1)) + just_outside = str(datetime.now() - timedelta(days=config.RETENTION_DAYS + 1)) + write_csv(path, ["date", "message_id"], [[just_inside, "1"], [just_outside, "2"]]) + + removed = prune_old_rows(path) + + assert [r["message_id"] for r in removed] == ["2"] + + def test_does_not_rewrite_the_file_when_nothing_is_removed(self, tmp_path): + path = tmp_path / "data.csv" + write_csv(path, ["date", "message_id"], [[RECENT, "1"]]) + original_mtime = path.stat().st_mtime_ns + + prune_old_rows(path, max_age_days=30) + + assert path.stat().st_mtime_ns == original_mtime + + +# --------------------------------------------------------------------------- +# remove_rows_for_author / count_rows_for_author +# --------------------------------------------------------------------------- +class TestRemoveRowsForAuthor: + def test_removes_only_matching_author(self, tmp_path): + path = tmp_path / "data.csv" + write_csv(path, ["date", "author_id", "message_id"], [ + [RECENT, "10", "1"], + [RECENT, "20", "2"], + [RECENT, "10", "3"], + ]) + + removed = remove_rows_for_author(path, 10) + + assert sorted(r["message_id"] for r in removed) == ["1", "3"] + assert [r["message_id"] for r in read_csv(path)] == ["2"] + + def test_count_matches_without_mutating(self, tmp_path): + path = tmp_path / "data.csv" + write_csv(path, ["date", "author_id", "message_id"], [ + [RECENT, "10", "1"], + [RECENT, "20", "2"], + ]) + + assert count_rows_for_author(path, 10) == 1 + assert len(read_csv(path)) == 2 # unchanged + + +# --------------------------------------------------------------------------- +# Retencion.run_prune_once +# --------------------------------------------------------------------------- +class TestRunPruneOnce: + async def test_prunes_personal_data_logs_and_evicts_pending_entries( + self, retencion_cog, isolated_logs + ): + write_csv(isolated_logs.log_main_file, ["date", "message_id"], [[OLD, "1"], [RECENT, "2"]]) + write_csv( + isolated_logs.log_mod_file, ["date", "message_id"], [[OLD, "10"], [RECENT, "11"]] + ) + retencion_cog.bot.data_mod = {"10": {"date": OLD}, "11": {"date": RECENT}} + + await retencion_cog.run_prune_once() + + assert [r["message_id"] for r in read_csv(isolated_logs.log_main_file)] == ["2"] + assert [r["message_id"] for r in read_csv(isolated_logs.log_mod_file)] == ["11"] + assert retencion_cog.bot.data_mod == {"11": {"date": RECENT}} + + async def test_does_not_touch_spam_caches(self, retencion_cog, isolated_logs): + isolated_logs.log_spam_file.write_text("mensaje viejo\n") + isolated_logs.log_image_spam_file.write_text("deadbeef\n") + + await retencion_cog.run_prune_once() + + assert isolated_logs.log_spam_file.read_text() == "mensaje viejo\n" + assert isolated_logs.log_image_spam_file.read_text() == "deadbeef\n" + + +# --------------------------------------------------------------------------- +# Retencion.erase_user_data +# --------------------------------------------------------------------------- +class TestEraseUserData: + def test_removes_matching_rows_across_logs_and_logs_the_erasure( + self, retencion_cog, isolated_logs + ): + write_csv(isolated_logs.log_main_file, ["date", "author_id", "message_id"], [ + [RECENT, "42", "1"], [RECENT, "99", "2"], + ]) + write_csv(isolated_logs.log_mod_file, ["date", "author_id", "message_id"], [ + [RECENT, "42", "10"], + ]) + retencion_cog.bot.data_mod = {"10": {"author_id": "42"}} + moderator = make_member(name="mod1", id=1) + + counts = retencion_cog.erase_user_data(42, requested_by=moderator) + + assert counts["log_main_file"] == 1 + assert counts["log_mod_file"] == 1 + assert [r["message_id"] for r in read_csv(isolated_logs.log_main_file)] == ["2"] + assert retencion_cog.bot.data_mod == {} + + gdpr_row = read_last_csv_row(isolated_logs.log_gdpr_file) + assert gdpr_row[1] == "42" # user_id + assert gdpr_row[2] == "mod1" # requested_by + assert gdpr_row[4] == "2" # total_removed + + def test_does_not_touch_spam_caches(self, retencion_cog, isolated_logs): + isolated_logs.log_spam_file.write_text("mensaje\n") + + retencion_cog.erase_user_data(42, requested_by=make_member(name="mod1")) + + assert isolated_logs.log_spam_file.read_text() == "mensaje\n" + + +# --------------------------------------------------------------------------- +# %olvidar command +# --------------------------------------------------------------------------- +class TestOlvidarUsuario: + async def test_no_data_found_sends_plain_message(self, retencion_cog, isolated_logs): + cog = bind_commands(retencion_cog) + ctx = make_ctx() + target = make_member(name="nadie", id=404) + + await cog.olvidar_usuario(ctx, target) + + ctx.send.assert_awaited_once() + (msg,), kwargs = ctx.send.call_args + assert "No se encontraron datos" in msg + assert "embed" not in kwargs + + async def test_data_found_sends_confirmation_embed(self, retencion_cog, isolated_logs): + write_csv(isolated_logs.log_main_file, ["date", "author_id", "message_id"], [ + [RECENT, "42", "1"], + ]) + cog = bind_commands(retencion_cog) + ctx = make_ctx() + target = make_member(name="alguien", id=42) + + await cog.olvidar_usuario(ctx, target) + + ctx.send.assert_awaited_once() + _, kwargs = ctx.send.call_args + assert "1" in kwargs["embed"].description + assert isinstance(kwargs["view"], ConfirmErasureView) + + +# --------------------------------------------------------------------------- +# ConfirmErasureView +# --------------------------------------------------------------------------- +class TestConfirmErasureView: + async def test_confirm_by_a_mod_erases_and_edits_message(self, retencion_cog, isolated_logs): + write_csv(isolated_logs.log_main_file, ["date", "author_id", "message_id"], [ + [RECENT, "42", "1"], + ]) + target = make_member(name="alguien", id=42) + view = ConfirmErasureView(retencion_cog, target) + mod = make_member(name="mod1", roles=[retencion_cog.coord_role]) + interaction = make_interaction(user=mod) + + await view.confirm.callback(interaction) + + assert read_csv(isolated_logs.log_main_file) == [] + interaction.response.edit_message.assert_awaited_once() + _, kwargs = interaction.response.edit_message.call_args + assert "eliminados" in kwargs["content"] + assert kwargs["view"] is None + + async def test_confirm_by_a_non_mod_is_rejected(self, retencion_cog, isolated_logs): + write_csv(isolated_logs.log_main_file, ["date", "author_id", "message_id"], [ + [RECENT, "42", "1"], + ]) + target = make_member(name="alguien", id=42) + view = ConfirmErasureView(retencion_cog, target) + interaction = make_interaction(user=make_member(name="randomuser", roles=[])) + + await view.confirm.callback(interaction) + + assert len(read_csv(isolated_logs.log_main_file)) == 1 # untouched + interaction.response.send_message.assert_awaited_once() + interaction.response.edit_message.assert_not_awaited() + + async def test_cancel_does_not_erase_anything(self, retencion_cog, isolated_logs): + write_csv(isolated_logs.log_main_file, ["date", "author_id", "message_id"], [ + [RECENT, "42", "1"], + ]) + target = make_member(name="alguien", id=42) + view = ConfirmErasureView(retencion_cog, target) + mod = make_member(name="mod1", roles=[retencion_cog.coord_role]) + interaction = make_interaction(user=mod) + + await view.cancel.callback(interaction) + + assert len(read_csv(isolated_logs.log_main_file)) == 1 # untouched + interaction.response.edit_message.assert_awaited_once() + _, kwargs = interaction.response.edit_message.call_args + assert "cancelada" in kwargs["content"] From c486eede9e9597f4c555579954b13ec11773ebbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristi=C3=A1n=20Maureira-Fredes?= Date: Sat, 15 Aug 2026 23:28:55 +0200 Subject: [PATCH 2/3] Add TERMS.md: draft data-handling/privacy document Describes what personal data the bot collects, why, retention periods (30 days for personal data, indefinite for spam signatures since those aren't personal data), who has access, and how to request erasure via the new %olvidar command. Explicitly marked as a technical draft that needs legal review before being published as an official policy, with placeholders for the actual data controller/contact info. --- TERMS.md | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 TERMS.md diff --git a/TERMS.md b/TERMS.md new file mode 100644 index 0000000..936bbd9 --- /dev/null +++ b/TERMS.md @@ -0,0 +1,96 @@ +# Términos de Uso y Política de Datos del Bot + +> **⚠️ Aviso importante:** este documento es un **borrador técnico**, escrito +> para describir con precisión qué hace el bot con los datos que maneja. No +> es asesoría legal. Antes de publicarlo como política oficial de la +> comunidad, debe ser revisado por una persona con conocimientos legales en +> protección de datos (RGPD/GDPR u otra normativa aplicable según dónde +> resida la comunidad y sus miembros). +> +> Responsable del tratamiento de datos: **[COMPLETAR: nombre/contacto del +> equipo u organización responsable del servidor]** +> Contacto para consultas o solicitudes sobre tus datos: **[COMPLETAR: +> correo o canal de contacto]** + +Este documento describe qué datos recopila y almacena el bot de moderación +de este servidor de Discord, con qué propósito, durante cuánto tiempo, y +qué opciones tienes respecto a tus propios datos. + +## 1. Alcance + +Este documento cubre únicamente los datos que **el bot** almacena por su +cuenta (en archivos de registro en el servidor donde corre). No cubre los +datos que Discord, como plataforma, almacena sobre tu cuenta, tus mensajes +o tu actividad — eso se rige por la [Política de Privacidad de +Discord](https://discord.com/privacy). El bot no puede eliminar ni +controlar esos datos. + +## 2. Qué datos recopila el bot + +| Dato | De dónde sale | Para qué se usa | +|---|---|---| +| ID de usuario de Discord | Autor de mensajes/comandos | Identificar de forma estable a quién pertenece cada registro (a diferencia del nombre de usuario, el ID no cambia). | +| Nombre de usuario de Discord | Autor de mensajes/comandos | Mostrar de forma legible a quién pertenece un registro en los canales de moderación. | +| Contenido de mensajes enviados para moderación | Canales de "envío" de contenido | Permitir que el equipo de moderación revise, acepte o rechace el contenido antes de publicarlo. | +| Decisión de moderación (aceptado/rechazado) y motivo del rechazo | Acción del equipo de moderación | Mantener trazabilidad de qué se decidió y por qué. | +| Registro general de mensajes del servidor (autor, canal, contenido, fecha) | Toda la actividad del servidor | Auditoría e investigación de incidentes de moderación. | + +## 3. Qué el bot **no** vincula a tu identidad + +Para detectar spam y contenido malicioso repetido, el bot guarda firmas del +propio contenido (el texto ya "aplanado", o el hash de una imagen) que se +ha confirmado como spam o estafa. Estas firmas **no incluyen quién las +envió** — son solo el contenido o su huella digital, sin ningún dato de +autor. Por eso no se consideran datos personales y se conservan +indefinidamente: sirven para reconocer el mismo contenido si vuelve a +aparecer, sin que eso implique guardar información sobre ninguna persona. + +## 4. Cuánto tiempo se conservan los datos + +- **Registros con datos personales** (tabla de la sección 2): se eliminan + automáticamente pasados **30 días** desde su creación. Esto ocurre todos + los días de forma automática; no requiere intervención manual. +- **Firmas de contenido/imágenes de spam** (sección 3): se conservan sin + fecha de expiración, ya que no son datos personales. +- **Registro de solicitudes de eliminación** (sección 6): se conserva + indefinidamente como constancia de que una solicitud fue atendida, pero + solo contiene el ID de la persona afectada y un conteo de registros + eliminados — nunca el contenido eliminado. + +## 5. Quién tiene acceso + +Los registros descritos en la sección 2 son visibles para el equipo de +moderación ("Coordinación") del servidor, a través de los canales y +comandos del bot. No se comparten con terceros ni se usan con fines +distintos a la moderación del servidor. + +## 6. Tus derechos: acceso, rectificación y eliminación + +Puedes solicitar en cualquier momento: + +- **Saber qué datos tuyos están almacenados.** +- **Que se corrijan datos incorrectos.** +- **Que se eliminen todos tus datos** ("derecho al olvido"). + +Para ejercer cualquiera de estos derechos, contacta con +**[COMPLETAR: contacto]**. Una vez validada la solicitud, un miembro del +equipo de Coordinación puede ejecutar la eliminación mediante el comando +`%olvidar`, que: + +1. Muestra un resumen de cuántos registros se encontraron para tu cuenta. +2. Requiere una confirmación explícita antes de eliminar nada. +3. Elimina esos registros de todos los archivos de datos personales + descritos en la sección 2, de forma permanente e irreversible. +4. Dado que el bot identifica tus datos por tu ID de Discord (no por tu + nombre de usuario, que puede cambiar), la eliminación cubre todos los + registros asociados a tu cuenta, incluso si tu nombre de usuario fue + distinto en el pasado. + +Ten en cuenta que esto **no elimina tu historial dentro de Discord como +plataforma** (mensajes, roles, sanciones aplicadas directamente por +Discord, etc.) — solo lo que este bot almacena por su cuenta. + +## 7. Cambios a este documento + +Este documento puede actualizarse si cambia la forma en que el bot maneja +los datos. Se recomienda revisarlo periódicamente. From 31a521ac231d1794df87c84c0636cbef7007b445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristi=C3=A1n=20Maureira-Fredes?= Date: Sat, 15 Aug 2026 23:48:14 +0200 Subject: [PATCH 3/3] bot.py: request only the intents actually used Was discord.Intents().all() - every intent, including the privileged Members and Presences ones. Nothing in this codebase uses guild member chunking, join/leave/update events, or presence data (confirmed via a full grep). Switched to Intents.default() + message_content = True, keeping the one privileged intent this bot genuinely needs (every spam/moderation check reads message.content) and dropping the two it doesn't. Fewer privileged intents requested means less to justify and reapply for under Discord's developer policy as the bot grows. --- TERMS.md | 16 ++-------------- bot.py | 11 +++++++++-- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/TERMS.md b/TERMS.md index 936bbd9..038ec01 100644 --- a/TERMS.md +++ b/TERMS.md @@ -1,17 +1,5 @@ # Términos de Uso y Política de Datos del Bot -> **⚠️ Aviso importante:** este documento es un **borrador técnico**, escrito -> para describir con precisión qué hace el bot con los datos que maneja. No -> es asesoría legal. Antes de publicarlo como política oficial de la -> comunidad, debe ser revisado por una persona con conocimientos legales en -> protección de datos (RGPD/GDPR u otra normativa aplicable según dónde -> resida la comunidad y sus miembros). -> -> Responsable del tratamiento de datos: **[COMPLETAR: nombre/contacto del -> equipo u organización responsable del servidor]** -> Contacto para consultas o solicitudes sobre tus datos: **[COMPLETAR: -> correo o canal de contacto]** - Este documento describe qué datos recopila y almacena el bot de moderación de este servidor de Discord, con qué propósito, durante cuánto tiempo, y qué opciones tienes respecto a tus propios datos. @@ -21,7 +9,7 @@ qué opciones tienes respecto a tus propios datos. Este documento cubre únicamente los datos que **el bot** almacena por su cuenta (en archivos de registro en el servidor donde corre). No cubre los datos que Discord, como plataforma, almacena sobre tu cuenta, tus mensajes -o tu actividad — eso se rige por la [Política de Privacidad de +o tu actividad - eso se rige por la [Política de Privacidad de Discord](https://discord.com/privacy). El bot no puede eliminar ni controlar esos datos. @@ -73,7 +61,7 @@ Puedes solicitar en cualquier momento: - **Que se eliminen todos tus datos** ("derecho al olvido"). Para ejercer cualquiera de estos derechos, contacta con -**[COMPLETAR: contacto]**. Una vez validada la solicitud, un miembro del +**contacto@hablemospython.dev**. Una vez validada la solicitud, un miembro del equipo de Coordinación puede ejecutar la eliminación mediante el comando `%olvidar`, que: diff --git a/bot.py b/bot.py index b64f897..5364b34 100644 --- a/bot.py +++ b/bot.py @@ -29,8 +29,15 @@ # Configuration config = Config() -# Use '%' as command prefix -intents = discord.Intents().all() +# Use '%' as command prefix. +# Only request the privileged intent we actually use (Message Content - +# every spam/moderation check reads message.content). Members and +# Presences are also privileged but nothing in this codebase uses guild +# member chunking, join/leave/update events, or presence data, so we don't +# request them: fewer privileged intents means less to justify/review +# under Discord's developer policy as the bot grows. +intents = discord.Intents.default() +intents.message_content = True bot = commands.Bot(command_prefix="%", intents=intents) handler = logging.FileHandler(filename="bot.log", encoding="utf-8", mode="w")