Portal de Desarrolladores
Experimental La plataforma de bots y apps está en desarrollo activo. La compatibilidad con servidores autoalojados llegó el 13-08-2026 con menos funciones que la nube. Consulta qué se admite.
← Documentación

Ajustes dentro de la app

GameVox puede mostrar el formulario de configuración de tu app dentro del cliente. Tu app describe el formulario por el gateway, alguien lo edita en Ajustes del servidor ▸ Integraciones ▸ Ajustes, y los valores editados vuelven directamente a tu bot. GameVox no guarda nada — tu app sigue siendo la única propietaria de su configuración.

Esto no tiene equivalente en Discord. Es la única parte de la plataforma de apps de GameVox que deliberadamente no es compatible, porque no hay nada al otro lado con lo que serlo.

La ventana de Ajustes del servidor del cliente de GameVox, mostrando la pestaña Ajustes de una app instalada con un desplegable de idioma, un interruptor y una lista de canales.
Un panel real, generado por completo a partir de la respuesta de un solo bot: un select, un boolean y un selector channels, agrupados en una sección con descripción. El bot no envió ningún id de canal — la lista la puso GameVox. Nombres de servidor y de canal ocultados.

Por qué existe

En Discord, un bot con cualquier configuración trae un dashboard web: un dominio, un inicio de sesión OAuth2, gestión de sesiones, una comprobación de permisos que vuelve a leer la lista de guilds de la persona, protección CSRF y alojamiento para todo ello, normalmente solo para que alguien elija un canal de registro y active tres funciones. GameVox ya sabe quién es esa persona, qué servidor está configurando y si tiene permiso, así que ofrece esa superficie directamente a tu app.

DiscordGameVox
Dónde se configura tu app Un dashboard web que alojas tú Ajustes del servidor ▸ Integraciones ▸ Ajustes
Qué tienes que construir App web, inicio de sesión OAuth2, sesiones, comprobación de permisos, alojamiento Un manejador de gateway y una llamada REST
Quién autentica a quien configura Tú, por OAuth2 GameVox, antes de preguntar siquiera a tu app
Quién guarda la configuración Tú. GameVox no conserva nada.
Selectores de canal y de rol Pides los canales y roles de la guild y los muestras tú Indicas un tipo; GameVox aporta las opciones
Coste de exponer un interruptor Un dashboard Unas veinte líneas

Esto no sustituye a nada. Los comandos de barra siguen funcionando, y si ya tienes un dashboard puedes conservarlo: no son excluyentes, y muchas apps querrán el panel para los cinco ajustes que se cambian de verdad y el dashboard para todo lo demás.

Cómo funciona

Alguien abre la pestaña Ajustes
      │
      ▼
GameVox ──── APP_SETTINGS_REQUEST (action: "describe") ────▶ tu bot
                                                                │
tu bot   ─── POST /applications/@me/settings-response ──────────┘
             { nonce, version: 2, sections: [ ... ] }
      │
      ▼
GameVox muestra el formulario, se edita y se pulsa Guardar
      │
      ▼
GameVox ──── APP_SETTINGS_REQUEST (action: "save", values) ─▶ tu bot
                                                                │
tu bot   ─── POST /applications/@me/settings-response ──────────┘
             { nonce, message: "Guardado." }

Dos acciones, una forma de petición, un endpoint de respuesta. describe pregunta qué expone tu app para este servidor; save devuelve lo que se ha cambiado. Ambas se responden igual.

1. Recibir la petición

GameVox envía APP_SETTINGS_REQUEST por el gateway a la sesión que sostiene la conexión de tu app. Ningún intent lo controla: el despacho va dirigido específicamente a tu app, no se filtra contra una máscara de intents, así que llega con cualquier identificación que hayas usado.

{
  "op": 0,
  "t": "APP_SETTINGS_REQUEST",
  "d": {
    "nonce": "5f3c1b0e-8a4d-4b2f-9c11-2f7a6d0b4e93",
    "server_id": "1387452901234567890",
    "action": "describe",
    "schema_version": 2
  }
}
CampoTipoNotas
nonce string Opaco. Devuélvelo tal cual; es lo único que enlaza tu respuesta con la persona que está esperando. Se puede responder durante 15 segundos.
server_id snowflake La guild, en el mismo espacio de ids que cualquier otro despacho. Es el id que traía tu GUILD_CREATE, así que tu caché de guilds ya lo resuelve.
action string "describe" o "save".
schema_version integer El esquema más rico que este servidor puede mostrar. Actualmente 2. Consulta Atender a un servidor antiguo.
values object Presente solo en save. Las claves son las claves de campo que enviaste.

server_id lo pone GameVox a partir de la petición autenticada de quien configura. Nunca se vuelve a leer de tu respuesta, así que una app no puede responder a una pregunta sobre un servidor con los ajustes de otro.

Leer el despacho en tu biblioteca

Ninguna biblioteca de bots trae un manejador para un evento que no existe en Discord, así que el paquete se descarta antes de llegar a un manejador de eventos. Todas ofrecen el flujo de despachos en bruto justo para este caso:

BibliotecaHookCómo activarlo
discord.js (v14) client.on('raw', packet) Activo por defecto. Se emite para cada despacho antes de procesar el paquete.
discord.py (v2) on_socket_raw_receive(msg) Pasa enable_debug_events=True al cliente.
Eris client.on('rawWS', packet) Activo por defecto.
JDA RawGatewayEvent JDABuilder.setRawEventsEnabled(true)
DSharpPlus DiscordClient.UnknownEvent Activo por defecto. EventName más el Json en bruto.
serenity RawEventHandler::raw_event ClientBuilder::raw_event_handler

Contra el Discord real este manejador nunca se dispara, así que la misma compilación funciona en ambas plataformas sin ramificar.

2. Responder

Responde por REST, no por el gateway. Todas las bibliotecas ofrecen un cliente HTTP; casi ninguna deja que el código de aplicación escriba opcodes arbitrarios en el socket, y por eso la respuesta tiene esta forma.

POST https://bot-api.gamevox.com/api/v10/applications/@me/settings-response
Authorization: Bot YOUR_BOT_TOKEN
Content-Type: application/json
{
  "nonce": "5f3c1b0e-8a4d-4b2f-9c11-2f7a6d0b4e93",
  "version": 2,
  "message": "Nota opcional mostrada encima del formulario.",
  "sections": [
    {
      "key": "welcome",
      "label": "Mensajes de bienvenida",
      "description": "Se publica cuando alguien entra.",
      "fields": [
        { "key": "welcome.enabled", "label": "Enviar un mensaje de bienvenida",
          "type": "boolean", "value": true }
      ]
    }
  ]
}

Una respuesta correcta devuelve 204 No Content. Puedes añadir ?server_id= con el id de la guild; solo se usa para atribuir la llamada en el registro de actividad de tu app.

ClaveTipoNotas
nonce string Obligatoria. Viene de la petición.
version integer El esquema en el que respondes. Envía 2 cuando envíes sections.
sections array Formulario del esquema v2. Excluye fields.
fields array Formulario plano del esquema v1. Se muestra como una única sección sin título.
message string En describe, una nota sobre el formulario. En save, el aviso de confirmación. Se recorta a 200 caracteres.
error string Se muestra en lugar del formulario. Se recorta a 300 caracteres.

Responde siempre, también cuando falle. Descartar la petición en silencio deja a quien configura con un indicador de carga hasta el timeout de 12 segundos de GameVox, y eso se lee como «esta app está rota» en lugar de «algo falló una vez».

3. Tipos de campo

Doce tipos, y la lista es cerrada: un type desconocido rechaza toda la respuesta en lugar de mostrarse como nada.

Tipo Control El value que envíasQué recibes al guardar
string Campo de texto de una línea string string ("" si está vacío)
text Área de texto string string
boolean Interruptor boolean true / false
number Campo numérico number number, o null si se vació
select Desplegable de tus options valor de opción string, o null si no hay valor
multiselect Lista de casillas de tus options array de valores de opción array de strings (puede estar vacío)
channel Desplegable de los canales de este servidor snowflake de canal string snowflake, o null
channels Lista de casillas de los canales de este servidor array de snowflakes array de strings snowflake
role Desplegable de los grupos de este servidor snowflake de rol string snowflake, o null
roles Lista de casillas de los grupos de este servidor array de snowflakes array de strings snowflake
color Muestra de color #rrggbb #rrggbb, siempre presente
static Línea de texto de solo lectura string Nunca se envía — la clave no aparece en values

Un campo de color siempre tiene valor, así que una app que no guardara ninguno recibirá #000000 en el primer guardado. Si necesitas «sin color», combina la muestra con un boolean.

4. Atributos de campo

AtributoSe aplica aEfecto
key todos Obligatorio. [A-Za-z0-9][A-Za-z0-9._:-]{0,63}. Único en toda la respuesta, no solo por sección.
label todos Si falta, se usa la clave. Se recorta a 100 caracteres.
help todos Línea de pista bajo el control. Se recorta a 200.
placeholder campos de texto, number Se recorta a 100.
options select, multiselect Obligatorio para esos dos. { value, label, description }, máximo 25. Se descarta en cualquier otro tipo.
min , max , step number Da forma al control. Solo orientativo: vuelve a comprobar el valor que recibas.
max_length string, text Limita la entrada, hasta el techo de la plataforma para ese tipo.
channel_kinds channel, channels Acota el selector. text, voice, forum, news, fileshare, header. Los tipos desconocidos se descartan; un resultado vacío significa todos los tipos.
required todos Marca la etiqueta y sustituye la opción «Ninguno» del selector por «Seleccionar…». Es presentación: aplícalo de verdad al guardar.
disabled todos Atenúa el control. Se sigue enviando, con su valor actual.
secret string, text Enmascara el campo y vacía el valor actual a la salida. En cualquier otro tipo, rechaza la respuesta.
show_if todos { key, equals }. Oculta el campo hasta que otro campo de la misma respuesta tenga ese valor.

show_if es una comprobación de igualdad y nada más: sin expresiones ni operadores. La clave referenciada debe pertenecer a un campo de valor único de la misma respuesta y no puede ser la propia clave del campo; una condición que incumpla algo de eso se descarta y el campo se muestra sin condición. equals puede ser string, boolean, número o null.

Un campo oculto se sigue enviando. Tiene un valor que envió tu app, y omitirlo se leería en tu app como si alguien lo hubiera vaciado.

5. Selectores de canal y de rol

Esta es la parte que merece entenderse, porque invierte la disposición habitual. Tu app indica un tipo; GameVox aporta las opciones.

Un campo de canal no lleva ids. Envías { "type": "channel" } y, si quieres, un filtro channel_kinds. GameVox adjunta la lista de canales de este servidor, el cliente construye un selector a partir de ella, y solo se puede enviar una opción que haya emitido GameVox.

Cada entrada del catálogo está indexada por la misma snowflake que tu app ya ve para ese canal o grupo. No hay ningún paso de traducción de ids que puedas equivocar: se elige una etiqueta, tu app recibe un id en su propio espacio de nombres, y un valor que nombre algo fuera de este servidor simplemente no coincide con ninguna opción.

Un campo channels mostrado como una lista de casillas con los canales del propio servidor, cada uno precedido por el símbolo de su tipo.
Todo lo que la app envió para este campo fue { "key": "ignored", "type": "channels", "channel_kinds": ["text", "news", "forum", "fileshare"] }. La lista, los símbolos de tipo y los ids los pone GameVox.
SituaciónQué se ve
Todavía no hay nada guardado Una opción «Ninguno» seleccionada. Un campo required muestra «Seleccionar…» en su lugar, de modo que al guardar no se puede fijar un valor que nadie eligió.
El id guardado ya no existe (canal eliminado) Se mantiene como «Selección actual (ya no listada)», todavía seleccionada. Guardar no la borra en silencio.
Ids guardados en un selector múltiple que ya no aparecen Se muestran y se marcan junto a los actuales, por el mismo motivo.
Catálogo no disponible (instancia autoalojada apagada) Selector desactivado con el texto «No disponible ahora mismo». El valor guardado se devuelve tal cual al guardar, así que abrir el panel durante una incidencia no puede borrar un ajuste.
El servidor tiene más de 500 canales o grupos La lista se corta en 500 en lugar de enviar un megabyte de opciones.

El catálogo de roles excluye dos cosas: el grupo de propiedad del servidor, que ninguna app debería recibir como rol asignable, y los grupos por app que llevan los permisos de las apps instaladas.

6. Escenarios

Escenario 1 — El panel útil más pequeño

Un interruptor, sin secciones. Un array plano de fields es válido en cualquier versión del esquema y se muestra como un único grupo sin título.

{
  "nonce": nonce,
  "fields": [
    { "key": "greetings", "label": "Saludar a los nuevos miembros", "type": "boolean", "value": true }
  ]
}

Escenario 2 — Agrupar en secciones

{
  "nonce": nonce,
  "version": 2,
  "sections": [
    {
      "key": "general",
      "label": "General",
      "description": "Cómo se comporta el bot en este servidor.",
      "fields": [
        { "key": "prefix", "label": "Prefijo de comandos", "type": "string",
          "value": "!", "max_length": 4, "help": "Se usa para los comandos de texto clásicos." }
      ]
    },
    {
      "key": "logging",
      "label": "Registro de auditoría",
      "fields": [
        { "key": "log.enabled", "label": "Registrar acciones de moderación", "type": "boolean", "value": false }
      ]
    }
  ]
}

La key de una sección es opcional, pero debe ser única cuando está presente. label y description son opcionales; una sección sin campos no se muestra.

Escenario 3 — Pedir un canal

Sin ids y sin pedir la lista de canales de la guild. Restringe el selector a los tipos que pueden recibir un mensaje.

{
  "key": "log.channel",
  "label": "Canal de registro",
  "type": "channel",
  "value": stored.logChannelId ?? null,
  "channel_kinds": ["text", "news"],
  "help": "Dónde se dejan constancia de las acciones de moderación."
}

Usa "channel_kinds": ["header"] cuando quieras una categoría y no un sitio donde publicar: las cabeceras son las categorías de GameVox.

Escenario 4 — Mostrar campos según una condición

La forma habitual: un boolean que habilita el resto de su función.

"fields": [
  { "key": "welcome.enabled", "label": "Enviar un mensaje de bienvenida",
    "type": "boolean", "value": true },

  { "key": "welcome.channel", "label": "Canal", "type": "channel",
    "value": stored.welcomeChannel ?? null,
    "channel_kinds": ["text", "news"],
    "show_if": { "key": "welcome.enabled", "equals": true } },

  { "key": "welcome.message", "label": "Mensaje", "type": "text",
    "value": stored.welcomeMessage ?? "",
    "max_length": 1800,
    "placeholder": "¡Bienvenida a {server}, {user}!",
    "help": "{user}, {server} y {membercount} se sustituyen al enviarse.",
    "show_if": { "key": "welcome.enabled", "equals": true } }
]

Las condiciones también pueden depender de un select, que es como se construye un conmutador de modo: "show_if": { "key": "mode", "equals": "advanced" }.

Escenario 5 — Tus propias opciones

select y multiselect son los dos tipos que llevan sus propias opciones. Ambos requieren al menos una y admiten un máximo de 25.

{
  "key": "automod.action",
  "label": "Cuando un filtro salta",
  "type": "select",
  "value": stored.action,
  "required": true,
  "options": [
    { "value": "delete",  "label": "Eliminar el mensaje" },
    { "value": "warn",    "label": "Advertir a quien lo escribió",  "description": "Elimina el mensaje y envía un MD a la persona." },
    { "value": "timeout", "label": "Aplicar un tiempo fuera", "description": "10 minutos." },
    { "value": "none",    "label": "No hacer nada" }
  ]
}
{
  "key": "automod.filters",
  "label": "Filtros activos",
  "type": "multiselect",
  "value": stored.filters,
  "options": [
    { "value": "invites",   "label": "Invitaciones de Discord/GameVox" },
    { "value": "links",     "label": "Enlaces" },
    { "value": "mentions",  "label": "Menciones masivas" },
    { "value": "caps",      "label": "Exceso de mayúsculas" }
  ]
}

La description de una opción aparece tras la etiqueta en un desplegable y como tooltip en una lista de casillas.

Escenario 6 — Roles

La misma regla de catálogo que con los canales. GameVox lista los grupos de este servidor por rango, con sus colores.

"fields": [
  { "key": "autorole.role", "label": "Rol al entrar",
    "type": "role", "value": stored.autoRole ?? null },

  { "key": "moderator.roles", "label": "Roles que pueden usar comandos de moderación",
    "type": "roles", "value": stored.modRoles,
    "help": "Quien tenga cualquiera de estos roles podrá ejecutar /ban y /timeout." }
]

Escenario 7 — Números con límites

{
  "key": "automod.threshold",
  "label": "Mensajes antes de declarar un raid",
  "type": "number",
  "value": stored.threshold ?? 10,
  "min": 3,
  "max": 100,
  "step": 1,
  "help": "Se cuentan en una ventana móvil de 60 segundos."
}

min, max y step solo dan forma al control. El navegador de quien configura no es un validador que tú controles, así que vuelve a acotar el valor cuando llegue — y ten en cuenta que vaciar el campo envía null, no 0.

Escenario 8 — Secretos

Marca una credencial como secret y GameVox vacía su valor actual a la salida. No está en el frame del WebSocket, ni en el DOM, ni en una captura del panel: se ve un campo enmascarado vacío con el marcador «Sin cambios».

{
  "key": "integrations.apiKey",
  "label": "Clave de API meteorológica",
  "type": "string",
  "secret": true,
  "help": "Déjalo en blanco para conservar la clave actual."
}

De ahí se deriva el contrato: un secreto enviado vacío significa «déjalo como está», nunca «bórralo». Si hace falta eliminar una credencial, ofrece un boolean explícito para hacerlo. secret en algo que no sea string o text rechaza la respuesta.

Escenario 9 — Estado de solo lectura

static muestra una línea de texto y nunca se envía. Úsalo para información que hace falta al configurar pero que no se puede editar aquí.

"fields": [
  { "key": "plan", "label": "Plan", "type": "static", "value": "Pro — 4.000 consultas/día" },
  { "key": "usage", "label": "Usadas hoy", "type": "static", "value": "1.284 consultas" },
  { "key": "lastSync", "label": "Última sincronización", "type": "static", "value": "2026-09-02 14:31 UTC" }
]

Escenario 10 — Gestionar un guardado

La petición de guardado lleva values, indexado por las claves de campo que enviaste. Guárdalos y responde con una confirmación.

// action === "save"
const v = req.values ?? {};

const guild = client.guilds.cache.get(req.server_id);
if (!guild) return reply(nonce, { error: "Este bot ya no está en ese servidor." });

const patch = {};

// boolean: siempre un boolean real
patch.welcomeEnabled = v["welcome.enabled"] === true;

// channel: una cadena snowflake o null — vuelve a comprobarla contra ESTA guild
const chan = v["welcome.channel"];
patch.welcomeChannel =
  (typeof chan === "string" && guild.channels.cache.has(chan)) ? chan : null;

// number: número o null cuando se ha vaciado el campo
const n = v["automod.threshold"];
patch.threshold = typeof n === "number" ? Math.min(100, Math.max(3, Math.round(n))) : 10;

// secret: vacío significa sin cambios
const key = v["integrations.apiKey"];
if (typeof key === "string" && key.trim() !== "") patch.apiKey = key.trim();

await store.update(req.server_id, patch);
await reply(nonce, { message: "Ajustes guardados." });

Tu message se convierte en el aviso de confirmación. El formulario se queda tal cual estaba, así que se puede seguir editando.

Escenario 11 — Rechazar un guardado

Devuelve error y se verá tu texto en lugar de una confirmación. Úsalo para todo lo que no puedas aceptar: un valor que no pasa tu validación, un límite de plan, una credencial externa que ya no funciona.

// `required` es presentación — aplícalo aquí de verdad.
const action = v["automod.action"];
if (!["delete", "warn", "timeout", "none"].includes(action)) {
  return reply(nonce, guildId, {
    error: "Elige qué debe hacer AutoMod cuando salte un filtro.",
  });
}

// Cualquier cosa que solo tu lado puede saber.
if (patch.apiKey && !(await upstream.verifyKey(patch.apiKey))) {
  return reply(nonce, guildId, {
    error: "El servicio meteorológico rechazó esa clave de API.",
  });
}

Lo mismo vale para describe: si tu base de datos está caída, responde con un error en lugar de no responder. «Esta app no pudo leer sus ajustes ahora mismo» es un desenlace mucho mejor que doce segundos de indicador de carga.

Escenario 12 — Atender a un servidor antiguo

La petición lleva schema_version, así que nunca tienes que tantear. Sirve el formulario más rico que el servidor vaya a mostrar de verdad.

const version = typeof req.schema_version === "number" ? req.schema_version : 1;

if (version >= 2) {
  await reply(nonce, { version: 2, sections: describeV2(guildId) });
} else {
  // v1: lista plana, cuatro tipos — string, boolean, number, select
  await reply(nonce, { fields: describeV1(guildId) });
}

También vale al revés. Una respuesta v2 que llega a un cliente antiguo se muestra igualmente: GameVox envía un array fields aplanado junto a sections, y un cliente anterior a los tipos nuevos los dibuja como campos de texto. Degradado, pero sin ocultar nada.

Escenario 13 — Servidores autoalojados

Funciona sin cambios. En un servidor autoalojado los catálogos de canales y roles se piden a la instancia de la clientela en lugar de a las tablas en la nube, algo invisible para tu app: los mismos tipos de campo, las mismas snowflakes, la misma respuesta.

La única diferencia observable: si la instancia no es accesible, los selectores llegan vacíos y se muestran como «No disponible ahora mismo». Los valores guardados de tu app se devuelven tal cual al guardar, así que no se pierde nada. No interpretes un selector vacío como «se ha borrado el valor».

7. Qué valida GameVox y qué no

GameVox comprueba la forma de un guardado y explícitamente no su significado. No conserva el formulario que mostró —no guardar nada de tu configuración es justo el objetivo—, así que cuando llega un guardado nada en nuestro lado sabe qué clave era un selector de canal y cuál un campo de texto libre.

GameVox garantizaTú debes comprobar
Las claves cumplen el juego de caracteres y no son __proto__, constructor ni prototype Que la clave sea una que enviaste de verdad
Los valores son null, boolean, número, string o un array de strings — nunca un objeto anidado Que el tipo coincida con el campo que declaraste
Las cadenas están acotadas (2.000 caracteres) y los arrays tienen 25 entradas como mucho Tus propios límites, más estrictos
Todo id de selector lo emitió GameVox a partir del catálogo de este servidor Que el id todavía exista en esta guild: los catálogos son una instantánea, y un canal puede eliminarse entre que se muestra y se guarda
Quien configura es propietaria del servidor o tiene «Gestionar servidor», y la app está instalada aquí Cualquier autorización tuya (un nivel de plan, una cuenta vinculada)

Esto no es un hueco que abra el panel: quien configura ya puede hacer POST de lo que quiera al propio dashboard de una app. Es el contrato, y es exactamente por lo que los tipos de selector te dan ids generados por GameVox en lugar de dejar que una app decida qué significa un id.

8. Límites

Aquí hay dos comportamientos, y la diferencia importa: los recuentos y la estructura rechazan toda la respuesta, así que te enteras; los techos de texto recortan en silencio, así que nunca se ve un diseño roto.

LímiteValorSi se supera
Secciones por respuesta 12 Rechazado
Campos por respuesta (en total, no por sección) 60 Rechazado
Opciones por select / multiselect 1–25 Rechazado
Entradas seleccionadas en un valor múltiple 25 Rechazado
Clave de campo o de sección 64 caracteres Rechazado
Valor de opción 64 caracteres Rechazado
Cuerpo de la respuesta 256 KB 413
Etiqueta, marcador 100 caracteres Recortado
Texto de pista 200 caracteres Recortado
Descripción de sección 300 caracteres Recortado
message 200 caracteres Recortado
error 300 caracteres Recortado
Valor de un campo de la familia string 1.000 caracteres Recortado
Valor de un campo text / static 2.000 caracteres Recortado
Canales o roles en un catálogo 500 Truncado

Un rechazo se comunica como «Esta app envió unos ajustes que GameVox no pudo leer: <motivo>», nombrando el límite que se superó y el campo que lo hizo. Las claves duplicadas, un tipo desconocido, un select sin opciones y enviar a la vez sections y fields se rechazan igual.

Se corrige en silencio en lugar de rechazarse

  • Un value actual que no encaja con su tipo pasa a estar vacío. Un valor obsoleto debería vaciar un campo, no tumbar el panel.
  • options en un tipo que no las usa se descarta.
  • min / max / step en un campo no numérico se descartan.
  • Las entradas desconocidas de channel_kinds se descartan.
  • Un show_if que nombre una clave desconocida, a sí mismo o un campo de varios valores se descarta, y el campo se muestra sin condición.

9. Tiempos y modos de fallo

ComportamientoValorQué ocurre
Ventana de respuesta 12 s Se muestra el mensaje «Esta app no respondió. Puede que no admita ajustes dentro de la app.»
Vida del nonce 15 s Una respuesta tardía se descarta en lugar de entregarse a nadie. Responder con un nonce caducado devuelve 404.
Tiempo de espera 2 s por acción, por app y por servidor «Dale un momento a la app y vuelve a intentarlo.» describe y save tienen ventanas separadas, así que abrir el panel no bloquea un guardado inmediato.
App desconectada Se rechaza de entrada: «Esta app está desconectada, así que no se puede configurar ahora mismo.» No se intenta ningún despacho.
Responde la app equivocada 403. Un nonce está ligado a la aplicación para la que se emitió.
Respuesta duplicada Gana la primera respuesta; la segunda no hace nada.

Cada petición se convierte en un despacho de gateway hacia tu app, así que el tiempo de espera existe para que nadie pueda usar GameVox para machacar el bot de otra persona. Dimensiona tu manejador de describe en consecuencia: se ejecuta al abrir la página, es normal que consulte la base de datos, pero doce segundos es el muro.

10. Ejemplo completo (discord.js)

Listo para usar, salvo tu propia capa de almacenamiento. Es la misma forma que usa la implementación de referencia.

const SCHEMA_V2 = 2;
const POSTABLE = ["text", "news", "forum", "fileshare"];

client.on("raw", (packet) => {
  if (!packet || packet.t !== "APP_SETTINGS_REQUEST") return;
  void handleSettings(packet.d ?? {});
});

async function reply(nonce, guildId, body) {
  await client.rest.post("/applications/@me/settings-response", {
    body: { nonce, ...body },
    query: new URLSearchParams({ server_id: guildId }),
  });
}

async function handleSettings(req) {
  const nonce = typeof req.nonce === "string" ? req.nonce : "";
  const guildId = typeof req.server_id === "string" ? req.server_id : "";
  if (!nonce || !guildId) return;

  try {
    if (req.action === "save") {
      const message = await applySettings(guildId, req.values ?? {});
      return await reply(nonce, guildId, { message });
    }

    const version = typeof req.schema_version === "number" ? req.schema_version : 1;
    if (version >= SCHEMA_V2) {
      return await reply(nonce, guildId, {
        version: SCHEMA_V2,
        sections: await describe(guildId),
      });
    }
    return await reply(nonce, guildId, { fields: await describeLegacy(guildId) });
  } catch (err) {
    // Responde incluso al fallar — descartarlo en silencio se lee como una app rota.
    await reply(nonce, guildId, {
      error: "El bot no pudo leer sus ajustes para este servidor.",
    }).catch(() => undefined);
  }
}

async function describe(guildId) {
  const cfg = await store.get(guildId);
  return [
    {
      key: "general",
      label: "General",
      description: "Cómo se comporta el bot en este servidor.",
      fields: [
        { key: "prefix", label: "Prefijo de comandos", type: "string",
          value: cfg.prefix, max_length: 4 },
        { key: "ignored", label: "Canales ignorados", type: "channels",
          value: cfg.ignored, channel_kinds: POSTABLE,
          help: "En estos canales no se responden comandos." },
      ],
    },
    {
      key: "welcome",
      label: "Mensajes de bienvenida",
      description: "Se publica cuando alguien entra.",
      fields: [
        { key: "welcome.enabled", label: "Enviar un mensaje de bienvenida",
          type: "boolean", value: cfg.welcome.enabled === true },
        { key: "welcome.channel", label: "Canal", type: "channel",
          value: cfg.welcome.channel ?? null, channel_kinds: POSTABLE,
          show_if: { key: "welcome.enabled", equals: true } },
        { key: "welcome.message", label: "Mensaje", type: "text",
          value: cfg.welcome.message ?? "", max_length: 1800,
          placeholder: "¡Bienvenida a {server}, {user}!",
          show_if: { key: "welcome.enabled", equals: true } },
      ],
    },
  ];
}

async function applySettings(guildId, v) {
  const guild = client.guilds.cache.get(guildId);
  if (!guild) throw new Error("not in guild");

  const channel = (id) =>
    typeof id === "string" ? (guild.channels.cache.has(id) ? id : null) : null;

  await store.set(guildId, {
    prefix: String(v.prefix ?? "!").slice(0, 4) || "!",
    ignored: Array.isArray(v.ignored) ? v.ignored.filter(channel).slice(0, 25) : [],
    welcome: {
      enabled: v["welcome.enabled"] === true,
      channel: channel(v["welcome.channel"]),
      message: String(v["welcome.message"] ?? "").slice(0, 1800),
    },
  });

  return "Ajustes guardados.";
}

11. Lista de comprobación antes de publicar

  • Cada clave de campo es estable. Renombrar una deja huérfano lo que ya se hubiera configurado.
  • Cada id que venga de un selector se vuelve a comprobar contra la guild antes de escribirlo.
  • Un secret vacío se trata como «sin cambios», nunca como «borrado».
  • La ruta de describe responde bastante por debajo de 12 segundos, consulta a la base de datos incluida.
  • Cada ruta de fallo responde igualmente, con error, no con silencio.
  • required y min/max se vuelven a aplicar al guardar.
  • Un selector vacío significa que el catálogo no estaba disponible, no que se haya borrado el campo.
  • El manejador no hace nada en Discord, así que una sola compilación sirve para ambas plataformas.

Quién puede abrir el panel

Quien sea propietaria del servidor, o alguien con Gestionar servidor: la misma barrera que rige instalar y desinstalar apps. En un servidor autoalojado la comprobación se hace contra la instancia de la clientela, así que a la administración de allí no se le deniega por no tener una fila de permisos en la nube.

GameVox rechaza la petición de plano si tu app no está instalada en ese servidor, así que una petición de panel solo puede llegar a una app que ya se haya autorizado.

← Migrar desde Discord  ·  Volver a la documentación