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
84 changes: 84 additions & 0 deletions TERMS.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 11 additions & 3 deletions bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,28 @@
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

# 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")
Expand Down
246 changes: 246 additions & 0 deletions comandos/retencion.py
Original file line number Diff line number Diff line change
@@ -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))
13 changes: 13 additions & 0 deletions configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. "
Expand All @@ -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"
Expand All @@ -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
Expand Down
Loading
Loading