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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ unas de las siguientes reglas:
* **Menciones**: Personas que envían mensajes con 3 menciones o más
a roles o personas.

## Privacidad y datos

El bot almacena algunos datos personales (ver [TERMS.md](TERMS.md)) con
fines de moderación, con borrado automático a los 30 días y comandos para
ejercer tus derechos de acceso (`%exportar`, uso del equipo de moderación)
y de eliminación (`%olvidar`). El comando `%terminos` muestra el enlace a
la política completa en cualquier momento.

## Tengo una idea para el bot

Las futuras ideas del bot están definidas como 'Issues' en este repositorio.
Expand Down
127 changes: 97 additions & 30 deletions TERMS.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
# 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.
*Última actualización: 2026-08-17*

Este documento describe qué datos recopila y almacena **LlamaBot**, bot
oficial de moderación del servidor de Discord **Python en Español**, con
qué propósito, durante cuánto tiempo, y qué opciones tienes respecto a tus
propios datos. LlamaBot no está en funcionamiento en ningún otro servidor;
este documento se refiere únicamente a su uso en Python en Español.

Puedes consultar este documento en cualquier momento con el comando
`%terminos`.

## 1. Alcance

Este documento cubre únicamente los datos que **el bot** almacena por su
Este documento cubre únicamente los datos que **LlamaBot** 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
Expand All @@ -23,6 +30,16 @@ controlar esos datos.
| 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. |

**Base legal (RGPD/GDPR):** el bot procesa estos datos por **interés
legítimo** del servidor en poder moderar su propio contenido y prevenir
abuso/spam - es la funcionalidad esencial por la que el bot existe. No se
usan para ningún otro fin (marketing, perfilado, venta a terceros, etc.).

LlamaBot solo solicita a Discord el permiso ("intent") de **contenido de
mensajes**, que es el mínimo necesario para las funciones descritas
arriba. No solicita acceso a la lista de miembros del servidor ni a datos
de presencia/actividad (en línea, jugando, etc.).

## 3. Qué el bot **no** vincula a tu identidad

Para detectar spam y contenido malicioso repetido, el bot guarda firmas del
Expand All @@ -33,52 +50,102 @@ 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
## 4. Archivado de canales

El comando `%archivar` (uso exclusivo del equipo de moderación) genera una
copia del historial completo de un canal - incluyendo autor, ID de autor y
contenido de cada mensaje - y la publica como archivo adjunto en el canal
privado de moderación. Es una función que se usa **rara vez, típicamente
solo al cerrar/archivar un canal de forma permanente**, no como parte de
la moderación cotidiana descrita en la sección 2.

Por su distinto propósito (dejar un registro histórico del canal cerrado,
no auditar actividad reciente), este archivo se trata de forma separada:

- El bot **no conserva una copia propia**: el archivo se sube al canal de
moderación y se borra inmediatamente del servidor donde corre el bot.
La única copia que persiste es ese mensaje en el canal de moderación,
sujeto a los mismos controles de acceso que el resto de esa sección
(solo Coordinación) y a la propia retención de datos de Discord.
- No está sujeto al borrado automático de 30 días de la sección 5, ya
que su propósito es servir como referencia histórica a largo plazo, no
como registro operativo reciente.
- Una solicitud de eliminación (sección 6) no puede editar automáticamente
un archivo ya publicado en Discord. Si nos pides eliminar tus datos de
un canal ya archivado, el equipo de Coordinación evaluará el pedido caso
por caso (por ejemplo, editando o eliminando manualmente ese archivo).

## 5. 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.
- **Archivos de canales cerrados** (sección 4): se conservan sin fecha de
expiración fija, como registro histórico, únicamente en el canal de
moderación.
- **Registro de solicitudes de eliminación y de exportación** (sección 6):
se conservan indefinidamente como constancia de que una solicitud fue
atendida, pero solo contienen el ID de la persona afectada y un conteo
de registros — nunca el contenido eliminado o exportado.

## 5. Quién tiene acceso
## 6. 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.
distintos a la moderación del servidor. LlamaBot no utiliza ningún
servicio externo (analítica, IA, hosting de terceros con acceso a los
datos, etc.) para procesar estos datos: corre en un único servidor
administrado por el equipo de Python en Español, y los registros se
guardan como archivos de texto sin cifrar en ese mismo servidor. El
acceso al servidor está restringido a las personas del equipo de
Coordinación que lo administran.

## 6. Tus derechos: acceso, rectificación y eliminación
## 7. Tus derechos: acceso, rectificación y eliminación

Puedes solicitar en cualquier momento:

- **Saber qué datos tuyos están almacenados.**
- **Saber qué datos tuyos están almacenados** ("derecho de acceso").
- **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
**contacto@hablemospython.dev**. Una vez validada la solicitud, un miembro
del equipo de Coordinación puede:

- **Exportar tus datos** mediante el comando `%exportar`, que genera un
archivo `.zip` con todas tus filas en los registros de datos personales
descritos en la sección 2, y lo entrega de forma privada a quien hizo la
solicitud.
- **Eliminar tus datos** 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 ninguno de los dos comandos alcanza a los archivos de
canales cerrados (sección 4) ni 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
## 8. Menores de edad

LlamaBot no está dirigido a, ni recopila datos intencionalmente de,
personas menores de 13 años, en línea con los [Términos de Servicio de
Discord](https://discord.com/terms). Si tomamos conocimiento de que se
almacenaron datos de una persona menor de 13 años, los eliminaremos de
forma inmediata al ser notificados, usando el mismo mecanismo de la
sección 7.

## 9. Cambios a este documento

Este documento puede actualizarse si cambia la forma en que el bot maneja
los datos. Se recomienda revisarlo periódicamente.
los datos. La fecha de "Última actualización" al inicio refleja la
versión vigente; se recomienda revisarlo periódicamente. Cambios
relevantes serán anunciados en el servidor de Python en Español.
3 changes: 2 additions & 1 deletion bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@
from comandos.archivar import Archivar
from comandos.enviar import Enviar
from comandos.retencion import Retencion
from comandos.exportar import Exportar

# 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, Retencion)
COGS = (Ping, Ayuda, Limpia, Archivar, Moderacion, FloodSpam, Enviar, Retencion, Exportar)

# Global instance of the server
guild = None
Expand Down
8 changes: 8 additions & 0 deletions comandos/archivar.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
from datetime import datetime
from pathlib import Path
from typing import List, Optional

import discord
Expand Down Expand Up @@ -40,6 +41,13 @@ async def archivar(self, ctx, *, channel: discord.TextChannel) -> Optional[disco
colour=colors.ARCHIVE,
)
await self.mod_channel.send(embed=e, file=discord.File(archived_filename))
# The message just sent to the mod channel is the durable copy
# (per TERMS.md, channel archives are preserved there as an
# institutional record, not by this bot) - delete the local
# temp file now instead of leaving a personal-data CSV sitting
# unmanaged on the host's filesystem outside the retention
# policy in comandos/retencion.py.
Path(archived_filename).unlink(missing_ok=True)
else:
await self.mod_channel.send(f"Error: Canal '{channel.name}' no fue archivado.")

Expand Down
15 changes: 15 additions & 0 deletions comandos/ayuda.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ async def mensaje_ayuda(self, ctx):

await ctx.channel.send(embed=self.get_mod_help())

@commands.command(
name="terminos",
help="Muestra el enlace a los Términos de Uso y Política de Datos del bot",
)
async def terminos(self, ctx):
await ctx.channel.send(
"\N{PAGE FACING UP} Términos de Uso y Política de Datos de LlamaBot: "
f"{config.TERMS_URL}"
)

def get_mod_help(self):
embed = discord.Embed(
title="Comandos Disponibles",
Expand Down Expand Up @@ -52,4 +62,9 @@ def get_mod_help(self):
value="Limpia N mensajes del canal de moderación",
inline=False,
)
embed.add_field(
name="`%terminos`",
value="Muestra el enlace a los Términos de Uso y Política de Datos del bot",
inline=False,
)
return embed
92 changes: 92 additions & 0 deletions comandos/exportar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Right-of-access / data-export requests (GDPR), the read-only counterpart
to ``comandos/retencion.py``'s right-to-erasure (``%olvidar``).

``%exportar`` collects every row belonging to a user across the same
``PERSONAL_DATA_LOGS`` covered by retention/erasure into an in-memory .zip
(one CSV per log) and posts it to the moderation channel, for a moderator
to hand over to whoever requested their data - it never messages the user
directly, since only the moderation team can confirm a request is
legitimate in the first place.
"""
import csv
import io
import logging
import zipfile
from datetime import datetime

import discord
from discord.ext import commands

from configuration import Config
from comandos.retencion import PERSONAL_DATA_LOGS, rows_for_author

config = Config()
logger = logging.getLogger(__name__)


def build_export_zip(user_id) -> tuple:
"""Collect every row belonging to ``user_id`` from the personal-data
logs into an in-memory zip, one CSV per log.

Read-only w.r.t. the log files themselves, and nothing is written to
disk - the zip only ever exists in memory before being attached to a
Discord message.
"""
buffer = io.BytesIO()
counts = {}
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
for attr in PERSONAL_DATA_LOGS:
path = getattr(config, attr)
rows = rows_for_author(path, user_id)
counts[attr] = len(rows)
if not rows:
continue
out = io.StringIO()
writer = csv.DictWriter(out, fieldnames=rows[0].keys(), delimiter=";")
writer.writeheader()
writer.writerows(rows)
zf.writestr(f"{attr}.csv", out.getvalue())
buffer.seek(0)
return buffer, counts


class Exportar(commands.Cog):
def __init__(self, bot):
self.bot = bot

@commands.command(
name="exportar",
help="Genera un .zip con todos los datos almacenados de un usuario (derecho de acceso / GDPR)",
)
@commands.has_role(config.MOD_ROLE)
async def exportar_usuario(self, ctx, user: discord.User):
buffer, counts = build_export_zip(user.id)
total = sum(counts.values())

if total == 0:
await ctx.send(f"No se encontraron datos almacenados para {user.mention} (`{user.id}`).")
return

self._log_access(user.id, requested_by=ctx.author, total_exported=total)

detail = "\n".join(f"- `{name}`: {n}" for name, n in counts.items())
filename = f"{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}_export_{user.id}.zip"
await ctx.send(
content=(
f"\N{OPEN MAILBOX WITH RAISED FLAG} Exportación de datos de {user.mention} "
f"(`{user.id}`): **{total}** registro(s) en total.\n{detail}\n\n"
"Este archivo contiene datos personales: entrégalo únicamente a la persona "
"que lo solicitó, por un canal privado, y bórralo de tu equipo después. "
f"Más información en los términos del bot: {config.TERMS_URL}"
),
file=discord.File(buffer, filename=filename),
)

def _log_access(self, user_id, requested_by, total_exported):
"""Audit trail for access/export requests - mirrors
Retencion._log_erasure: only the id, requester and a count, never
the exported content itself, so it's safe to keep indefinitely."""
with open(config.log_gdpr_access_file, "a", newline="") as f:
csv.writer(f, delimiter=";").writerow([
f"{datetime.now()}", user_id, f"{requested_by}", requested_by.id, total_exported,
])
14 changes: 14 additions & 0 deletions comandos/retencion.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
every stored row belonging to a specific user on request, with an
explicit confirmation step first.

The read-only counterpart to erasure - the right of access - lives in
``comandos/exportar.py`` (``%exportar``), built on top of the
``rows_for_author()`` helper defined here.

``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
Expand Down Expand Up @@ -101,6 +105,16 @@ def count_rows_for_author(path, author_id) -> int:
return sum(1 for row in reader if row.get("author_id") == author_id)


def rows_for_author(path, author_id) -> list:
"""Read-only: every row belonging to ``author_id``, without modifying
anything. Used for right-of-access/export requests (comandos/exportar.py),
the read counterpart to remove_rows_for_author()'s right-to-erasure."""
author_id = str(author_id)
with open(path, newline="") as f:
reader = csv.DictReader(f, delimiter=";")
return [row 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)
Expand Down
6 changes: 6 additions & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ muted_role="..."
[server]
guild=

[links]
# Public URL to the bot's terms of use / data policy (TERMS.md), shown by
# the %terminos command. Also set this same URL as the bot's privacy
# policy link in the Discord Developer Portal.
terms_url="https://raw.githubusercontent.com/python-discord-es/bot/main/TERMS.md"

[channels]
[channels.eventos]
main=
Expand Down
Loading
Loading