diff --git a/TERMS.md b/TERMS.md new file mode 100644 index 0000000..038ec01 --- /dev/null +++ b/TERMS.md @@ -0,0 +1,84 @@ +# Términos de Uso y Política de Datos del Bot + +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 +**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: + +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. diff --git a/bot.py b/bot.py index a992ed6..5364b34 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 @@ -28,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") 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"]