Categoría: Bots de Discord

Crear un bot de Discord#14¡Guarda tus datos! JSON vs SQLite (ejemplo en Python)

Esta entrada es una traducción del original en coreano.

Índice11 secciones

¡Hola! Soy Mojangcam 😎

En el #13 dejamos la administración del servidor por completo en manos del bot, automatizando roles y canales. Pero quedó una cosa pendiente. Igual que temp_channels del #13, los datos que solo guardas en variables desaparecen por completo en cuanto reinicias el bot.

Por eso, en este #14 le vamos a dar memoria al bot. Vamos a crear un bot de asistencia + puntos + ranking con dos métodos que ya vienen incluidos en Python, JSON y SQLite, y de paso dejaremos claro cuándo conviene usar cada uno.

Registro de asistencia, sistema de puntos (economía), niveles, acumulación de advertencias… todas las funciones populares de los servidores de comunidad necesitan un "bot que recuerde datos". ¡En cuanto superes este paso, la cantidad de funciones que puedes crear se dispara!

🧠 ¿Por qué hace falta guardar los datos?

Todas las variables de Python existen solo en la memoria (RAM). En cuanto reinicias el bot, se reinicia el VPS o el bot se cae por un error, los puntos y registros que había en las variables se borran sin dejar rastro. Por eso hay que dejar los datos en un archivo y volver a leerlos cuando el bot arranca. A esto se le llama, con una palabra más técnica, persistencia (Persistence).

🧩 JSON vs SQLite, ¿en qué se diferencian?

¡Vamos directo a la conclusión! Para datos pequeños y simples, como la configuración, usa JSON; para datos que se van acumulando y necesitan búsquedas o rankings, usa SQLite.

Aspecto JSON SQLite
Qué es Un archivo de texto (guarda el dict tal cual) Una base de datos de verdad en un solo archivo
Instalación No hace falta (módulo integrado json) No hace falta (módulo integrado sqlite3)
Lectura y escritura Siempre el archivo completo de una vez Solo las filas que necesitas
Búsqueda y orden Lo programas tú mismo en Python Una línea de SQL (ORDER BY, etc.)
Cuando crecen los datos Se vuelve cada vez más lento Rápido gracias a los índices
Si algo falla al guardar Riesgo de que el archivo se corrompa Seguro gracias a las transacciones
Dónde encaja Configuración del bot, registros pequeños Datos que no paran de crecer, como puntos, economía o logs

JSON es básicamente un dict de Python pasado a un archivo, así que es intuitivo, y su mayor ventaja es que puedes abrirlo con el Bloc de notas y revisarlo a simple vista. A cambio, como reescribe el archivo entero cada vez que guardas, se vuelve más lento a medida que crecen los datos. SQLite es una base de datos de verdad que funciona con un solo archivo y sin instalar ningún servidor, así que puedes leer solo los datos que necesitas y dejarle a SQL hasta el ordenamiento y los cálculos.

📌 Lo que necesitas

  • Python 3.8 o superior
  • discord.py 2.x (cualquier versión 2.x reciente sirve)
  • Módulos json y sqlite3 — vienen integrados en Python, ¡así que no hay nada que instalar!
Terminal
pip install -U discord.py

Como vamos a usar comandos con prefijo (!출석), tienes que activar Message Content Intent en el Discord Developer Portal → pestaña Bot. (Si es la primera vez que tocas este ajuste, ¡revisa los capítulos anteriores de la serie!)

📌 Paso 1 - Lo básico de guardar en JSON: las funciones load y save

La clave del método JSON son solo dos funciones: una que lee el dict desde el archivo y otra que guarda el dict en el archivo.

Python
import json
import os

DATA_FILE = "points.json"

def load_data():
    """Si el archivo existe lo lee; si no, devuelve un dict vacío."""
    if not os.path.exists(DATA_FILE):
        return {}
    with open(DATA_FILE, "r", encoding="utf-8") as f:
        return json.load(f)

def save_data(data):
    """Guarda el dict completo en el archivo JSON."""
    with open(DATA_FILE, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)

Vamos a verlo parte por parte 💡

  • json.load(f) : lee el contenido del archivo y lo convierte en un dict de Python.
  • json.dump(data, f) : convierte el dict en texto con formato JSON y lo escribe en el archivo.
  • encoding="utf-8" + ensure_ascii=False : es el combo para que los caracteres no ASCII (coreano, ñ, tildes…) no se rompan. Si quitas cualquiera de los dos, el texto se guarda en un idioma alienígena como \uD55C\uAE00.
  • indent=2 : añade sangría para que el archivo quede fácil de leer para las personas.

¡Un detalle importante! Las claves (key) de JSON solo pueden ser cadenas. El ID de usuario en realidad es un número, pero lo más seguro es convertirlo siempre a cadena al guardarlo, como str(ctx.author.id).

📌 Paso 2 - Bot de asistencia y puntos completo en versión JSON

Si a esas dos funciones les sumas tres comandos (!출석 = asistencia, !포인트 = puntos, !랭킹 = ranking), ya está listo. Este código es la versión completa y puedes ejecutarlo tal cual.

Python
# discord.py 2.x / Python 3.8+ / json viene integrado en Python
import json
import os
from datetime import date

import discord
from discord.ext import commands

TOKEN = "PON_AQUI_EL_TOKEN_DE_TU_BOT"
DATA_FILE = "points.json"

def load_data():
    if not os.path.exists(DATA_FILE):
        return {}
    with open(DATA_FILE, "r", encoding="utf-8") as f:
        return json.load(f)

def save_data(data):
    with open(DATA_FILE, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)

intents = discord.Intents.default()
intents.message_content = True  # para comandos con prefijo

bot = commands.Bot(command_prefix="!", intents=intents)

@bot.event
async def on_ready():
    print(f"✅ ¡Sesión iniciada como {bot.user}!")

@bot.command(name="출석")
async def attend(ctx: commands.Context):
    data = load_data()
    uid = str(ctx.author.id)
    today = date.today().isoformat()   # ej.: '2026-07-30'

    user = data.get(uid, {"points": 0, "last_check": ""})
    if user["last_check"] == today:
        await ctx.send(f"{ctx.author.mention} ¡Hoy ya registraste tu asistencia! 😅")
        return

    user["points"] += 10
    user["last_check"] = today
    data[uid] = user
    save_data(data)
    await ctx.send(f"✅ {ctx.author.mention} ¡Asistencia registrada! +10P (total {user['points']}P)")

@bot.command(name="포인트")
async def my_points(ctx: commands.Context):
    data = load_data()
    user = data.get(str(ctx.author.id))
    total = user["points"] if user else 0
    await ctx.send(f"💰 Puntos de {ctx.author.mention}: {total}P")

@bot.command(name="랭킹")
async def ranking(ctx: commands.Context):
    data = load_data()
    if not data:
        await ctx.send("Todavía nadie ha registrado asistencia. ¡Tu oportunidad de ser el número 1! 🏃")
        return

    top = sorted(data.items(), key=lambda x: x[1]["points"], reverse=True)[:5]
    medals = ["🥇", "🥈", "🥉", "4️⃣", "5️⃣"]
    lines = [f"{medals[i]} <@{uid}> — {info['points']}P"
             for i, (uid, info) in enumerate(top)]
    embed = discord.Embed(title="🏆 Ranking de puntos TOP 5",
                          description="\n".join(lines),
                          color=0x5865F2)
    await ctx.send(embed=embed)

bot.run(TOKEN)

Vamos a verlo parte por parte 💡

  • date.today().isoformat() : convierte la fecha de hoy en una cadena con el formato 2026-07-30. Al compararla con last_check, es la clave para permitir una sola asistencia al día.
  • data.get(uid, valor por defecto) : si es la primera vez que el usuario registra asistencia, crea un dict por defecto que empieza con 0 puntos.
  • sorted(..., reverse=True)[:5] : para sacar el ranking, cargamos todos los datos en Python y los ordenamos nosotros mismos. ¡Este es justo el límite del método JSON! Compáralo más adelante con SQLite.
  • Mención <@ID_del_usuario> dentro del embed : las menciones dentro de un embed no envían notificación (ping), así que son perfectas para mostrar un ranking.

Cuando lo ejecutes y escribas !출석, se creará el archivo points.json en la carpeta del bot. Si lo abres con el Bloc de notas, se ve así.

JSON
{
  "123456789012345678": {
    "points": 30,
    "last_check": "2026-07-30"
  }
}

Para un servidor de unos 10 usuarios, con esto basta. Pero cuando hay cientos de usuarios y los registros se acumulan, empiezan los problemas. La estructura es así: con cada asistencia se reescribe el archivo entero, y con cada ranking se vuelve a leer todo. Además, si el bot se cierra a la fuerza mientras guarda, el archivo puede corromperse por completo. ¡Para estos casos necesitas SQLite!

📌 Paso 3 - Pasar el mismo bot a SQLite

SQLite se puede usar directamente con el módulo integrado sqlite3 de Python. Primero te conectas al archivo de la base de datos y creas una tabla.

Python
import sqlite3

con = sqlite3.connect("points.db")   # ¡si el archivo no existe, se crea solo!
con.execute("""
CREATE TABLE IF NOT EXISTS points (
    user_id    TEXT PRIMARY KEY,
    points     INTEGER NOT NULL DEFAULT 0,
    last_check TEXT    NOT NULL DEFAULT ''
)
""")
con.commit()

El comando de asistencia queda así.

Python
@bot.command(name="출석")
async def attend(ctx: commands.Context):
    uid = str(ctx.author.id)
    today = date.today().isoformat()

    row = con.execute(
        "SELECT last_check FROM points WHERE user_id = ?", (uid,)
    ).fetchone()
    if row is not None and row[0] == today:
        await ctx.send(f"{ctx.author.mention} ¡Hoy ya registraste tu asistencia! 😅")
        return

    con.execute("INSERT OR IGNORE INTO points (user_id) VALUES (?)", (uid,))
    con.execute(
        "UPDATE points SET points = points + 10, last_check = ? WHERE user_id = ?",
        (today, uid),
    )
    con.commit()

Vamos a verlo parte por parte 💡

  • sqlite3.connect("points.db") : se conecta al archivo de la base de datos. Si el archivo no existe, lo crea por ti.
  • CREATE TABLE IF NOT EXISTS : crea la tabla solo si no existe. Por eso es seguro reiniciar el bot las veces que quieras.
  • PRIMARY KEY : es la clave principal que impide que user_id se repita.
  • Marcador de posición con signo de interrogación (?) : pasa los valores de forma segura sin pegarlos directamente en la sentencia SQL. Si armas el SQL con f-strings, quedas expuesto a ataques de inyección SQL, ¡así que usa siempre ?!
  • INSERT OR IGNORE → UPDATE : primero "si no existe, lo crea; si existe, lo deja como está", y después suma los puntos. Es el patrón con el que menos se equivocan los principiantes.
  • con.commit() : confirma los cambios en el archivo. ¡Si te lo saltas, no se guarda nada!

¿Y el ranking? Con JSON cargábamos todo y lo ordenábamos en Python, pero con SQLite basta una línea de SQL.

Python
rows = con.execute(
    "SELECT user_id, points FROM points ORDER BY points DESC LIMIT 5"
).fetchall()

ORDER BY points DESC LIMIT 5 — significa "ordena por puntos de mayor a menor y dame solo los 5 primeros". Aunque haya 100.000 usuarios, la base de datos los selecciona sola. ¡Ese es el poder de una base de datos! 💪

📌 Código completo (listo para copiar y pegar)

Esta es la versión completa con SQLite. Solo cambia TOKEN y funciona al instante. (Para la versión completa con JSON, usa tal cual el código del Paso 2 de arriba).

Python
# discord.py 2.x / Python 3.8+ / sqlite3 viene integrado en Python
import sqlite3
from datetime import date

import discord
from discord.ext import commands

TOKEN = "PON_AQUI_EL_TOKEN_DE_TU_BOT"
DB_FILE = "points.db"

# ── Preparar la base de datos ──────────────────────────
con = sqlite3.connect(DB_FILE)
con.execute("""
CREATE TABLE IF NOT EXISTS points (
    user_id    TEXT PRIMARY KEY,
    points     INTEGER NOT NULL DEFAULT 0,
    last_check TEXT    NOT NULL DEFAULT ''
)
""")
con.commit()

intents = discord.Intents.default()
intents.message_content = True  # para comandos con prefijo

bot = commands.Bot(command_prefix="!", intents=intents)

@bot.event
async def on_ready():
    print(f"✅ ¡Sesión iniciada como {bot.user}!")

@bot.command(name="출석")
async def attend(ctx: commands.Context):
    uid = str(ctx.author.id)
    today = date.today().isoformat()

    row = con.execute(
        "SELECT last_check FROM points WHERE user_id = ?", (uid,)
    ).fetchone()
    if row is not None and row[0] == today:
        await ctx.send(f"{ctx.author.mention} ¡Hoy ya registraste tu asistencia! 😅")
        return

    con.execute("INSERT OR IGNORE INTO points (user_id) VALUES (?)", (uid,))
    con.execute(
        "UPDATE points SET points = points + 10, last_check = ? WHERE user_id = ?",
        (today, uid),
    )
    con.commit()

    total = con.execute(
        "SELECT points FROM points WHERE user_id = ?", (uid,)
    ).fetchone()[0]
    await ctx.send(f"✅ {ctx.author.mention} ¡Asistencia registrada! +10P (total {total}P)")

@bot.command(name="포인트")
async def my_points(ctx: commands.Context):
    row = con.execute(
        "SELECT points FROM points WHERE user_id = ?", (str(ctx.author.id),)
    ).fetchone()
    total = row[0] if row else 0
    await ctx.send(f"💰 Puntos de {ctx.author.mention}: {total}P")

@bot.command(name="랭킹")
async def ranking(ctx: commands.Context):
    rows = con.execute(
        "SELECT user_id, points FROM points ORDER BY points DESC LIMIT 5"
    ).fetchall()
    if not rows:
        await ctx.send("Todavía nadie ha registrado asistencia. ¡Tu oportunidad de ser el número 1! 🏃")
        return

    medals = ["🥇", "🥈", "🥉", "4️⃣", "5️⃣"]
    lines = [f"{medals[i]} <@{uid}> — {pts}P"
             for i, (uid, pts) in enumerate(rows)]
    embed = discord.Embed(title="🏆 Ranking de puntos TOP 5",
                          description="\n".join(lines),
                          color=0x5865F2)
    await ctx.send(embed=embed)

bot.run(TOKEN)

📌 Resultado

Enciende el bot y prueba los comandos uno por uno en tu servidor.

  1. Escribe !출석 → responde "✅ ¡Asistencia registrada! +10P (total 10P)". Si lo vuelves a escribir el mismo día: "¡Hoy ya registraste tu asistencia! 😅"
  2. Escribe !포인트 → muestra tus puntos acumulados.
  3. Escribe !랭킹 → aparece un embed con el TOP 5 y las medallas 🥇🥈🥉.
  4. ¡Y aquí viene lo mejor! Apaga el bot y vuelve a encenderlo. Si escribes !포인트, tus puntos siguen ahí. Es gracias a que la memoria quedó guardada en el archivo points.db (o points.json) de la carpeta del bot. 🎉

📌 Errores comunes

1. json.decoder.JSONDecodeError: Expecting value: line 1 column 1 Causa: points.json está vacío, o el bot se cerró a la fuerza mientras guardaba y el archivo se corrompió. Solución: borra el archivo y vuelve a ejecutar el bot; se creará uno nuevo. Si no quieres perder los datos, ábrelo con el Bloc de notas y arregla la parte dañada. Para prevenirlo, apaga el bot con un solo Ctrl+C cuando no esté guardando, para que se cierre de forma normal.

2. El texto se guarda en un idioma alienígena como \uc548\ub155 Causa: olvidaste poner ensure_ascii=False en json.dump. Solución: json.dump(data, f, ensure_ascii=False, indent=2) — y al abrir el archivo, no olvides añadir encoding="utf-8".

3. sqlite3.OperationalError: no such table: points Causa: hiciste un SELECT antes de ejecutar CREATE TABLE, o ejecutaste el bot desde otra carpeta y se creó un archivo de base de datos vacío en otro lugar. Solución: comprueba que el código que prepara la base de datos (CREATE TABLE) se ejecute antes de bot.run(), y entra a la carpeta del bot con cd en la terminal antes de ejecutarlo.

4. sqlite3.OperationalError: database is locked Causa: un programa visor de bases de datos tiene abierto points.db, u otra conexión sin commit() tiene el archivo bloqueado. Solución: cierra el visor y acostúmbrate a poner siempre con.commit() después del código que modifica datos.

💡 Consejos extra

  • Evita el spam de asistencia : además del límite de una vez al día, si le pones el cooldown del #12, bloqueas también el spam por completo.
  • Para hacer una copia de seguridad, basta con copiar un archivo : ¡copia solo points.db y listo! Si lo tienes funcionando 24 horas en un VPS, ten en cuenta que la fecha de asistencia se basa en la hora del servidor, así que en un VPS en el extranjero ajusta la zona horaria a Seúl (Asia/Seoul).
  • Si los datos crecen muchísimo : sqlite3 funciona de forma síncrona, así que con operaciones muy grandes el bot puede quedarse congelado un momento. A escala de principiante no se nota, ¡pero más adelante en la serie veremos cómo pasar a la librería asíncrona aiosqlite!

📌 Para terminar

Hoy aprendimos a guardar datos para darle memoria al bot. Para datos pequeños y simples, JSON; para datos que se van acumulando y necesitan búsquedas o rankings, SQLite — con recordar solo este criterio es suficiente. Ya creamos el bot de asistencia, puntos y ranking con los dos métodos, así que ahora puedes aplicarlo a lo que quieras, ¡ya sea un sistema de niveles o un contador de advertencias!

En el próximo capítulo veremos los grupos de comandos slash (Group), que sirven para ordenar un bot con muchos comandos. ¡No te lo pierdas! 🙌

📚 Serie Crear un bot de Discord

Serie · Parte 14 de 32

Crear un bot de Discord

  1. #1Crear un bot de Discord - ¡Domina por completo los Slash Commands!
  2. #2¡Añade opciones (argumentos) a tus Slash Commands!
  3. #3¡Añade subcomandos (Subcommand) a tus Slash Commands!
  4. #4¡Envía embeds con Slash Commands!
  5. #5¡Implementa interacciones con botones (Button)!
  6. #6¡Implementa menús desplegables (Select Menu)!
  7. #7Pide texto al usuario con un modal (Modal) (ejemplo en Python)
  8. #8Decora embeds (Embed) con imágenes y campos (ejemplo en Python)
  9. #9¡Integra la IA (API de Claude y ChatGPT)! (ejemplo en Python)
  10. #10¡Añade soporte multilingüe (i18n)! (ejemplo en Python)
  11. #11¡Crea un panel web con Flask! (ejemplo en Python)
  12. #12¡Gestiona los cooldowns y permisos de tus comandos! (ejemplo en Python)
  13. #13¡Administra tu servidor automatizando roles y canales! (ejemplo en Python)
  14. #14¡Guarda tus datos! JSON vs SQLite (ejemplo en Python)Estás aquí
  15. #15슬래시 명령어 그룹(Group)으로 명령어 정리하기! (파이썬 예제) (en coreano)
  16. #16리마인더·알림 기능 만들기! 봇이 먼저 말 걸게 하기 (파이썬 예제) (en coreano)
  17. #17RAG로 문서 기반 답변봇 만들기! 우리 서버 전용 FAQ 봇 (파이썬 예제) (en coreano)
  18. #18투표(Poll) 기능 만들기! 다수결로 정하는 서버 규칙 (파이썬 예제) (en coreano)
  19. #19Cog로 코드 쪼개기! 1000줄 봇도 안 더러워지는 구조 (파이썬 예제) (en coreano)
  20. #20자동 환영 메시지·온보딩 만들기! 규칙 동의 버튼으로 자동 역할 부여 (파이썬 예제) (en coreano)
  21. #21키워드 자동응답·금지어 필터 만들기! 경고 누적으로 자동 제재까지 (파이썬 예제) (en coreano)
  22. #22슬래시 명령어 자동완성(Autocomplete) 만들기! 금지어를 골라서 추가·삭제 (파이썬 예제) (en coreano)
  23. #23aiosqlite로 비동기 DB 제대로 설계하기! JSON 탈출 (파이썬 예제) (en coreano)
  24. #24채널 난독화(Channel Obfuscation) 대응하기! 11/16부터 안 보이는 채널이 생겨요 (파이썬 예제) (en coreano)
  25. #25봇이 죽지 않게! 에러 핸들링·로깅 체계 잡기 (파이썬 예제) (en coreano)
  26. #26티켓(Ticket) 문의 시스템 만들기! 버튼 하나로 1:1 상담 채널 자동 생성 (파이썬 예제) (en coreano)
  27. #27티켓 담당자 배정·우선순위 만들기! 스태프가 여러 명이어도 안 꼬이게 (파이썬 예제) (en coreano)
  28. #28레벨·경험치(XP) 시스템 만들기! 채팅할수록 레벨업, 랭킹판까지 (파이썬 예제) (en coreano)
  29. #29화폐·상점(Economy) 시스템 만들기! 코인 벌어서 아이템 사기 (파이썬 예제) (en coreano)
  30. #30¡Crea un bot de encuestas y votaciones (Poll)! Encuestas nativas + botones con recuento en tiempo real (ejemplo en Python) 🗳️
  31. #32¡Crea un canal de registros y auditoría (Audit Log) del servidor! Registra automáticamente mensajes borrados y editados, entradas, salidas y expulsiones (ejemplo en Python) 🕵️
  32. #33임시 음성방 확장, 파티방 만들기! 방장 위임·강퇴·초대 전용(비공개) 방 + 전용 채팅방 자동 생성 (파이썬 예제) 🎉 (en coreano)
Ver las 32Contraer

Autor

Mojangcam

Un registro práctico de desarrollo e IT: código que escribo, servidores que mantengo y los errores que resuelvo por el camino.