Imported from Javithor360/panita-bot (
.agents/AGENTS.md). Install upstream withnpx skills add Javithor360/panita-bot --skill .agents. Copyright stays with the author.
Reglas Generales del Proyecto Panita Bot
Commits (OBLIGATORIO)
- NUNCA agregar
Co-Authored-Byni ningún otro co-autor o línea de atribución (tampoco "Generated with ..."). El único autor de los commits es Javithor360. Esta regla tiene prioridad sobre cualquier configuración por defecto de un agente. - Commits pequeños y útiles, con formato convencional:
feat: ...,fix: ...,refactor: ...,chore: ...,docs: ...,test: .... - Un commit normalmente tiene una sola línea (solo el asunto). Solo un commit que introduce un cambio grande y crítico puede llevar una descripción más larga.
- Cada commit debe compilar (
pnpm exec tsc --noEmit) y pasar los tests (pnpm test).
Flujo de trabajo
- Gestor de paquetes:
pnpm(nonpmniyarn) para instalar dependencias y ejecutar scripts. - Al terminar el trabajo y una vez que el usuario autorizó el commit, no se pregunta si crear el PR: se entrega el título y la descripción del PR propuestos para que el usuario los use.
Datos
- El bot no se conecta a la base de datos. Toda lectura y escritura pasa por la API de Panita (
lib/api) con la clave de servicio del bot (PANITA_API_KEY). El esquema y sus cambios viven enpanita-api.
Arquitectura
src/
index.ts arranque: cliente, registro de comandos, handlers, eventos, login
server.ts endpoint HTTP de keep-alive
config/env.ts variables de entorno validadas (única lectura de process.env)
config/constants.ts prefijo, URLs, IP, colores, emojis, assets, categorías, permisos de tickets
core/ framework de comandos
command.ts defineCommand, tipos Command/meta/prefix, helpers button/select/modal
context.ts CommandContext: una sola API para slash y prefijo
prefixParser.ts parser de argumentos de prefijo basado en el esquema del SlashCommandBuilder
usage.ts genera las líneas de uso (<obligatorio> [opcional] [--bandera]) desde el esquema
customId.ts codec de customId de componentes
registry.ts carga de comandos (única), alias y descripciones para /help
access.ts permisos everyone | staff | developer
errors.ts UserError, respuestas seguras y handlers de proceso
handlers/ interactionCreate (slash + componentes) y messageCreate (prefijo)
events/ listeners del gateway, delgados; delegan en services/
services/ lógica de negocio y TODO el acceso a la API (users, memberSync, webSync, birthdays, birthdayScheduler, tickets, tags, gallery, roles, commandDeploy)
lib/ helpers: discord, minecraft, embeds, format, galactic, syncLock
lib/api/ cliente de la API de Panita (client, errors, types, startup)
data/ contenido estático (Tezzlar, minieventos, recetas, encantamientos, comida, comandos del server)
features/tickets/ sistema de tickets dividido: permissions, embeds, panel, components, context
features/minecraft/ puente con el servidor de Minecraft: protocol, link, server (WebSocket), inbound, outbound, webhook, messageConfig (lee `config/minecraft-messages.yml`), template, render, announcer, crashWatch, roles, sync, directory, queries, embeds, discord (los únicos adaptadores de discord.js)
commands/<cat>/ un archivo por comando (`export default defineCommand({...})`)
scripts/ deploy-commands.ts (registro de comandos slash)
test/ tests unitarios (node:test) del parser y del customId
- Los comandos son delgados: leen opciones → llaman a un service → construyen el embed.
- El cliente de la API (
lib/api) solo se importa desdeservices/(y desdeindex.tspara la verificación de arranque). Las rutas llevan placeholders{nombre}conparams; nunca se arman a mano. Nunca registrar cuerpos de petición ni la clave. - Variables de entorno solo vía
config/env.ts; nuncaprocess.envdirecto. - URLs externas y assets solo vía
config/constants.tsylib/minecraft.ts(skins, cabezas, etc.). - Un comando se define una sola vez para slash y prefijo; el parser de prefijo usa el esquema del
SlashCommandBuilder. Nunca ramificar por "¿es prefijo?" (la única excepción razonable es algo inherente al mensaje, como borrar el mensaje que invocó el comando conctx.deleteTrigger()). - Alias solo para prefijo:
meta.aliasesnunca se registra como comando slash. - Respuestas efímeras con
{ ephemeral: true }en el payload del contexto (se traduce aMessageFlags; en prefijo se ignora). En interacciones crudas (componentes) usarflags: MessageFlags.Ephemeral. - Errores esperados: lanzar
UserError('mensaje'); el handler lo muestra al usuario. Cualquier otro error se registra y se responde con un mensaje genérico.
Parser de prefijo
El texto después de !comando se interpreta con el mismo esquema del slash:
- Subcomando / grupo por nombre (
prefix.subcommandAliases,prefix.defaultSubcommand). Si el primer token no es un subcomando y existeprefix.fallback, se ejecuta el fallback (ej.!tag <nombre>). - Booleanos como
--bandera(o--bandera=no); un booleano obligatorio también aceptasi/no/true/false. - Usuarios, roles, canales y números toman el primer token que coincida (mención o ID). Usuarios/roles/canales obligatorios aceptan un nombre como último recurso.
- Los textos se asignan en orden; el último texto toma el resto del mensaje tal cual (con saltos de línea).
- Adjuntos: los del mensaje. Si falta algo obligatorio se responde con el uso correcto generado automáticamente.
Componentes (botones, menús, modales)
- Formato de customId:
comando:accion[:arg...][|ownerId]. El namespace es el nombre del comando dueño; se crea conctx.customId('accion', args, { owned: true })oencodeCustomId(...). - Si solo quien ejecutó el comando debe poder usarlo, incluir el owner. El router valida el dueño antes de llamar al handler, y luego el
guardopcional del handler. - Los handlers se declaran en
componentsdel comando conbutton(),select()omodal(). legacyCustomIdmapea IDs antiguos (btn_ticket_*) de paneles y controles de tickets que ya están publicados en Discord. No eliminarlo mientras existan paneles antiguos.
Puente con Minecraft
- El bot es el único cliente de Discord del puente. El mod de Minecraft se conecta al bot por WebSocket (
/bridgedel servidor HTTP, conBRIDGE_KEYcomo bearer) y nunca habla con Discord. El protocolo (versión 1) está endocs/minecraft-bridge-plan.mdy enfeatures/minecraft/protocol.ts. - Todo lo que llega del mod es entrada no confiable: se valida en
protocol.ts(forma, tamaño, tipos) antes de que nada más lo vea; un frame inválido cierra la conexión y un tipo desconocido se ignora. features/minecraft/trabaja con interfaces pequeñas (Hook,HookChannel,BridgeMessage,TopicChannel,NicknameMember,RoleMember) y solodiscord.tstoca objetos de discord.js, así todo se prueba sin Discord. El acceso a la API sigue enservices/(listEnabledMembers).- Los comandos del puente (
/playerlist,/playerinfo,/tps) están en la categoríaminecraftServer("Servidor de Minecraft"), mencionan a quien preguntó sin avisarle (withSilentMention: la mención va en el texto conallowedMentionsvacío),/playerlisttambién responde a la palabraplayerlistsola en un canal unido al servidor (bareCommand, sin prefijo y sin pasarla al chat del juego), se definen una vez y salen en/helpy en el deploy como cualquier otro; usanqueryMinecraft, que convierte "apagado", "no conectado" y "sin respuesta" enUserError. - Nunca mencionar a nadie: los webhooks y las notificaciones usan
allowedMentions: { parse: [] }. Solo se escuchan los mensajes de miembros vinculados (cuenta habilitada con IGN, según la API); los demás reciben una reacción ❌. - La sincronización (tema, apodos) y el rol al entrar están en modo de prueba (
MC_SYNC_DRY_RUN=true) por defecto: solo se registra lo que se cambiaría hasta ponerMC_SYNC_DRY_RUN=false. El tema solo se edita si el texto cambió (Discord permite ~2 ediciones cada 10 minutos). - Nunca ejecutar una segunda copia del bot de producción con su token: respondería cada comando dos veces. Para desarrollar, usar una aplicación y un servidor de prueba, o apagar antes el bot de producción.
Sincronización con la web
- Discord → web:
guildMemberAdd,userUpdate,guildMemberUpdateyroleDeleteusanservices/usersyservices/memberSync. - Web → Discord:
services/webSyncconsulta cada pocos segundos el outbox de la API (GET /v1/discord/sync-events, con cursor en memoria) y aplica roles;lib/syncLockevita bucles. - Solo se sincronizan roles con
discord_role_id; los roles exclusivos de la web nunca se tocan. /systemsyncnunca elimina datos: crea cuentas faltantes, actualiza perfiles y roles, y solo agrega ediciones./avatarsync [usuario]solo escribe el avatar (el del servidor si lo tiene, como los eventos en vivo) de los miembros que ya tienen cuenta, víasyncProfilede 5 en 5 (la API no tiene ruta masiva de perfiles); nunca crea cuentas y un fallo no detiene al resto. No usa el lock global: no cambia roles.- TODO: al quitar un rol de edición en Discord se elimina el
UserEdition(se pierdehistory_text). A futuro debería desactivarse con un campoenableden lugar de borrarse.
Restablecer contraseña desde la web
- La web crea la solicitud en la API; el bot la entrega. La API no puede empujar al bot:
services/passwordResetSyncconsultaGET /v1/discord/password-resets/pendingcada 5 s (la lista es estado, no eventos) y esa consulta es además el latido con el que la API sabe que el bot está vivo; por eso el siguiente poll se agenda antes de entregar y una entrega lenta nunca lo retrasa. - La entrega vive en
features/passwordReset/(view= textos, botones y formulario;delivery= DM o canal;components= handlers). El DM va primero; el error50007(DMs cerrados) se reporta comodms_closed, y cualquier otro fallo del DM también tras 3 intentos (failedes final y quitaría la opción del canal; solo se usa cuando falla el canal). El canal público (PASSWORD_RESET_CHANNEL_ID) solo se usa cuando la API pidedelivery: "channel", es decir, después de que la persona aceptó en la web; su mensaje no lleva ningún secreto y solo menciona a ese miembro. - Los botones y el formulario son componentes del comando
register(el namespace debe ser un comando). Todos llevan el id de la solicitud y el|ownerId. El formulario tiene un solo campo; la contraseña nunca se registra, se devuelve ni se reintenta (completeResetes un POST de un solo uso). Todo lo posterior al clic en el canal es efímero y el mensaje público se edita a una línea neutra.
Subida rápida a la galería
- Solo para
trusted_author(cuenta habilitada con esa marca; un admin o mod sin ella no sube desde Discord). La API decide: el bot nunca asume permisos, solo usatrusted_authordeGET /v1/discord/users/{id}para decidir si reacciona yGET .../upload-optionspara armar el formulario. - Vive en
features/quickUpload/(flow= reglas y handlers,view= textos y formulario,offers= ofertas en memoria,rules= constantes y filtros,download), con el evento delgado enevents/galleryUpload.ts, las llamadas a la API enservices/gallery.tsy Cloudinary enlib/cloudinary.ts(fetchfirmado, sin SDK). Se apaga sinGALLERY_CHANNEL_ID; con él, las tres variablesCLOUDINARY_*son obligatorias. - Un 👍 de Discord no es un like de la galería y no se guarda. Solo el 👍 del autor del mensaje abre la oferta; si el bot ya reaccionó con ✅ o hay una oferta pendiente no hace nada. Los botones y el formulario son componentes de
/gallery(gallery:uploadygallery:uploadsubmit, con el id del mensaje y el|ownerId). - El formulario se abre sin consultar nada (3 segundos); al enviarlo se hace
deferReplyefímero primero. El estadouploadingse marca antes del primerawaitpara que un doble envío suba una sola vez. - Carpeta de Cloudinary:
panita-web/gallery/{edition_id}/{discord_name}(la de la web; su importación de carpetas lee exactamente esa ruta). Sin preset, sinpublic_id, sin transformaciones.discord_namesale deupload-options, nunca del mensaje. Límite de 8 MB comprobado sobre los bytes reales; solo PNG, JPEG, WebP o GIF. - Limpieza: si la API rechaza la foto (4xx) se borra el archivo de Cloudinary; si el resultado es desconocido (timeout, error de red, 5xx,
ApiError.transient) el archivo se conserva y la oferta termina, porque reintentar podría guardar la foto dos veces. Un fallo de descarga o de Cloudinary deja la oferta abierta. Nunca se registran las claves de Cloudinary ni la firma. - Necesita el permiso Añadir reacciones en el canal, el intent
GuildMessageReactionsy las partialsMessage,ReactionyUser(los 👍 de mensajes anteriores al arranque llegan parciales).
Saludo de cumpleaños
- La fecha de cumpleaños se define en la web; el bot solo la consulta.
services/birthdaysllama aGET /v1/discord/users/birthdays?date=YYYY-MM-DDy recibediscord_id,ignydiscord_name: nunca el año ni la edad, y el saludo nunca menciona una edad. La regla del 29 de febrero (se lista el 28 en años no bisiestos) la decide la API, no el bot. - El bot es dueño del horario, la zona horaria y el canal:
BIRTHDAY_CHANNEL_ID(sin él no hay saludo),BIRTHDAY_UTC_OFFSET_HOURS(-6) yBIRTHDAY_HOUR(10). La API recibe un día, nunca una hora. services/birthdayScheduler(dependencias inyectadas, se inicia enClientReady) hace un tick por minuto. El saludo toca desde la hora (>=), no a la hora. El día se cierra solo después de publicar o con la lista vacía; si falla la API o Discord se reintenta en el siguiente tick y el fallo se registra una sola vez por racha.- El bot no guarda estado: tras un reinicio lee los últimos 100 mensajes del canal y no repite si encuentra su propio saludo de ese día (
features/birthdays/greeting:isGreetingOf). Si no puede leer el canal, no publica (podría duplicar) y reintenta. - El mensaje es uno por día, menciona a todos y solo a ellos (
allowedMentions). Helpers puros enfeatures/birthdays/(time,greeting), probados con reloj falso.
Agregar un comando
import { SlashCommandBuilder } from 'discord.js';
import { defineCommand } from '../../core/command';
import { CATEGORIES } from '../../config/constants';
export default defineCommand({
data: new SlashCommandBuilder()
.setName('ejemplo')
.setDescription('Descripción corta.')
.addStringOption(o => o.setName('texto').setDescription('Texto').setRequired(true)),
meta: {
category: CATEGORIES.utility,
description: 'Descripción larga para /help.',
aliases: ['ej'], // solo para prefijo (!ej)
access: 'everyone', // 'everyone' | 'staff' | 'developer'
},
async run(ctx) {
await ctx.reply(ctx.options.getString('texto', true));
},
});
Formato de Tezzlar Days (src/data/tezzlar.ts)
Al momento de agregar información para los días de Tezzlar en el archivo tezzlar.ts, se DEBEN seguir estrictamente las siguientes reglas de formato para mantener una estética consistente en los Embeds de Discord:
-
Títulos de Secciones en Mayúscula: Los
namede cadafielddeben estar siempre en mayúscula y con su respectivo emoji:MISIONES ACTIVAS 🛠️CAMBIOS DE DIFICULTAD 🎯NUEVOS CRAFTEOS 🔨NUEVOS MOBS 🥚
-
Uso de Blockquotes: Todos los strings en el campo
valuede cualquier field DEBEN comenzar con el prefijo>>>para que Discord renderice todo el contenido como una cita (blockquote). -
Títulos en Negrita para los Items: Cada item (cambio de dificultad, mob, misión, etc.) debe tener una palabra clave a modo de título en negrita acompañado de una flecha, seguido de un salto de línea y la descripción. Formato:
>>> **➔ Título clave**\nDescripción del cambio. -
Separación: Si hay múltiples items dentro de un mismo field, deben separarse por un doble salto de línea (
\n\n). -
Identación de Recompensas y Castigos (Misiones): Las listas debajo de las misiones deben usar el caracter invisible
⠀(U+2800) para forzar la identación en Discord, y guiones escapados (\\-) para las sub-listas. Ejemplo:>>> **➔ Nombre de la Misión** Descripción de la misión. ⠀◈ Recompensas: ⠀⠀\\- Item 1 ⠀⠀\\- Item 2 ⠀◈ Castigos: ⠀⠀\\- Castigo 1
