Configurações no app
O GameVox pode exibir o formulário de configuração do seu app dentro do cliente. Seu app descreve o formulário pelo gateway, alguém o edita em Configurações do servidor ▸ Integrações ▸ Configurações, e os valores editados voltam direto para o seu bot. O GameVox não guarda nada — seu app segue sendo o único dono da configuração dele.
Isso não tem equivalente no Discord. É a única parte da plataforma de apps do GameVox que propositalmente não é compatível, porque não há nada do outro lado com que ser compatível.
select, um boolean e um seletor channels, agrupados em uma seção com descrição. O bot não enviou nenhum id de canal — a lista veio do GameVox. Nomes de servidor e de canais ocultados.Por que isso existe
No Discord, um bot com qualquer configuração traz um dashboard web: um domínio, um login OAuth2, gerenciamento de sessões, uma verificação de permissão que relê a lista de guilds da pessoa, proteção CSRF e hospedagem para tudo isso — normalmente só para alguém escolher um canal de log e ligar três recursos. O GameVox já sabe quem é essa pessoa, qual servidor ela está configurando e se ela pode, então oferece essa superfície direto ao seu app.
| Discord | GameVox | |
|---|---|---|
| Onde seu app é configurado | Um dashboard web hospedado por você | Configurações do servidor ▸ Integrações ▸ Configurações |
| O que você precisa construir | App web, login OAuth2, sessões, verificações de permissão, hospedagem | Um handler de gateway e uma chamada REST |
| Quem autentica a pessoa | Você, via OAuth2 | O GameVox, antes mesmo de acionar o seu app |
| Quem guarda a configuração | Você | Você. O GameVox não retém nada. |
| Seletores de canal e de cargo | Você busca os canais e cargos da guild e os exibe | Você indica um tipo; o GameVox fornece as opções |
| Custo de expor uma única chave | Um dashboard | Cerca de vinte linhas |
Isso não substitui nada. Os comandos de barra continuam funcionando, e se você já tem um dashboard pode mantê-lo — os dois não se excluem, e muitos apps vão querer o painel para as cinco configurações que realmente mudam e o dashboard para o resto.
Como funciona
Alguém abre a aba Configurações
│
▼
GameVox ──── APP_SETTINGS_REQUEST (action: "describe") ────▶ seu bot
│
seu bot ─── POST /applications/@me/settings-response ──────────┘
{ nonce, version: 2, sections: [ ... ] }
│
▼
O GameVox exibe o formulário, a pessoa edita e clica em Salvar
│
▼
GameVox ──── APP_SETTINGS_REQUEST (action: "save", values) ─▶ seu bot
│
seu bot ─── POST /applications/@me/settings-response ──────────┘
{ nonce, message: "Salvo." } Duas ações, um formato de requisição, um endpoint de resposta. describe pergunta o que o seu app expõe para este servidor; save devolve o que foi alterado. Ambas são respondidas do mesmo jeito.
1. Recebendo a requisição
O GameVox envia APP_SETTINGS_REQUEST pelo gateway para a sessão que mantém a conexão do seu app. Nenhum intent controla isso — o despacho é endereçado especificamente ao seu app, não passa por uma máscara de intents, então chega independentemente de como você se identificou.
{
"op": 0,
"t": "APP_SETTINGS_REQUEST",
"d": {
"nonce": "5f3c1b0e-8a4d-4b2f-9c11-2f7a6d0b4e93",
"server_id": "1387452901234567890",
"action": "describe",
"schema_version": 2
}
} | Campo | Tipo | Observações |
|---|---|---|
nonce | string | Opaco. Devolva-o exatamente como veio; é a única coisa que liga a sua resposta à pessoa que está esperando. Pode ser respondido por 15 segundos. |
server_id | snowflake | A guild, no mesmo espaço de ids de qualquer outro despacho. É o id que o seu GUILD_CREATE trouxe, então seu cache de guilds já resolve. |
action | string | "describe" ou "save". |
schema_version | integer | O esquema mais rico que este servidor consegue exibir. Atualmente 2. Veja Atendendo um servidor mais antigo. |
values | object | Presente só no save. As chaves são as chaves de campo que você enviou. |
O server_id é carimbado pelo GameVox a partir da requisição autenticada da pessoa. Ele nunca é relido da sua resposta, então um app não consegue responder uma pergunta sobre um servidor com as configurações de outro.
Lendo o despacho na sua biblioteca
Nenhuma biblioteca de bots traz um handler para um evento que não existe no Discord, então o pacote é descartado antes de chegar a um handler de eventos. Todas expõem o fluxo bruto de despachos justamente para este caso:
| Biblioteca | Hook | Como ativar |
|---|---|---|
| discord.js (v14) | client.on('raw', packet) | Ativo por padrão. Emitido para cada despacho antes do pacote ser tratado. |
| discord.py (v2) | on_socket_raw_receive(msg) | Passe enable_debug_events=True ao cliente. |
| Eris | client.on('rawWS', packet) | Ativo por padrão. |
| JDA | RawGatewayEvent | JDABuilder.setRawEventsEnabled(true) |
| DSharpPlus | DiscordClient.UnknownEvent | Ativo por padrão. EventName mais o Json bruto. |
| serenity | RawEventHandler::raw_event | ClientBuilder::raw_event_handler |
Contra o Discord real esse handler nunca dispara, então a mesma build roda nas duas plataformas sem ramificação.
2. Respondendo
Responda por REST, não pelo gateway. Toda biblioteca expõe um cliente HTTP; a maioria não deixa o código da aplicação escrever opcodes arbitrários no socket, e é por isso que a resposta tem esse formato.
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 exibida acima do formulário.",
"sections": [
{
"key": "welcome",
"label": "Mensagens de boas-vindas",
"description": "Publicada quando alguém entra.",
"fields": [
{ "key": "welcome.enabled", "label": "Enviar uma mensagem de boas-vindas",
"type": "boolean", "value": true }
]
}
]
} Uma resposta bem-sucedida devolve 204 No Content. Você pode acrescentar ?server_id= com o id da guild; isso só serve para atribuir a chamada no registro de atividades do seu app.
| Chave | Tipo | Observações |
|---|---|---|
nonce | string | Obrigatória. Vem da requisição. |
version | integer | O esquema em que você está respondendo. Envie 2 quando enviar sections. |
sections | array | Formulário do esquema v2. Exclui fields. |
fields | array | Formulário plano do esquema v1. Exibido como uma única seção sem título. |
message | string | No describe, uma nota acima do formulário. No save, o aviso de confirmação. Cortado em 200 caracteres. |
error | string | Exibido no lugar do formulário. Cortado em 300 caracteres. |
Sempre responda, inclusive em caso de falha. Descartar a requisição em silêncio deixa a pessoa olhando um indicador de carregamento até o tempo limite de 12 segundos do GameVox, o que soa como “este app está quebrado” em vez de “algo falhou uma vez”.
3. Tipos de campo
Doze tipos, e a lista é fechada — um type desconhecido faz a resposta inteira falhar em vez de não exibir nada.
| Tipo | Controle | O value que você envia | O que você recebe ao salvar |
|---|---|---|---|
string | Campo de texto de uma linha | string | string ("" quando vazio) |
text | Área de texto | string | string |
boolean | Chave liga/desliga | boolean | true / false |
number | Campo numérico | number | number, ou null se esvaziado |
select | Lista suspensa das suas options | valor de opção | string, ou null se não definido |
multiselect | Lista de marcação das suas options | array de valores de opção | array de strings (pode ser vazio) |
channel | Lista suspensa dos canais deste servidor | snowflake de canal | string snowflake, ou null |
channels | Lista de marcação dos canais deste servidor | array de snowflakes | array de strings snowflake |
role | Lista suspensa dos grupos deste servidor | snowflake de cargo | string snowflake, ou null |
roles | Lista de marcação dos grupos deste servidor | array de snowflakes | array de strings snowflake |
color | Amostra de cor | #rrggbb | #rrggbb, sempre presente |
static | Linha de texto somente leitura | string | Nunca enviado — a chave não aparece em values |
Um campo de cor sempre tem valor, então um app que não guardava nenhum recebe #000000 no primeiro salvamento. Se você precisa de “sem cor”, combine a amostra com um boolean.
4. Atributos de campo
| Atributo | Aplica-se a | Efeito |
|---|---|---|
key | todos | Obrigatório. [A-Za-z0-9][A-Za-z0-9._:-]{0,63}. Único em toda a resposta, não apenas por seção. |
label | todos | Na falta, usa-se a chave. Cortado em 100 caracteres. |
help | todos | Linha de dica abaixo do controle. Cortada em 200. |
placeholder | campos de texto, number | Cortado em 100. |
options | select, multiselect | Obrigatório para esses dois. { value, label, description }, no máximo 25. Descartado em qualquer outro tipo. |
min , max , step | number | Molda o controle. Apenas orientativo — reconfira o valor recebido. |
max_length | string, text | Limita a entrada, até o teto da plataforma para aquele tipo. |
channel_kinds | channel, channels | Restringe o seletor. text, voice, forum, news, fileshare, header. Tipos desconhecidos são descartados; resultado vazio significa todos os tipos. |
required | todos | Marca o rótulo e troca a opção “Nenhum” do seletor por “Selecionar…”. É apresentação — imponha de verdade ao salvar. |
disabled | todos | Esmaece o controle. Ainda assim é enviado, com o valor atual. |
secret | string, text | Mascara a entrada e esvazia o valor atual na saída. Em qualquer outro tipo, a resposta é recusada. |
show_if | todos | { key, equals }. Esconde o campo até que outro campo da mesma resposta tenha esse valor. |
show_if é um teste de igualdade e nada mais — sem expressões, sem operadores. A chave referenciada precisa pertencer a um campo de valor único da mesma resposta e não pode ser a própria chave do campo; uma condição que falhe em qualquer um desses pontos é descartada e o campo aparece sem condição. equals pode ser string, booleano, número ou null.
Um campo escondido ainda assim é enviado. Ele carrega um valor que o seu app mandou, e omiti-lo pareceria, do lado do seu app, que a pessoa apagou aquilo.
5. Seletores de canal e de cargo
Esta é a parte que vale entender, porque inverte o arranjo usual. Seu app indica um tipo; o GameVox fornece as opções.
Um campo de canal não carrega ids. Você envia { "type": "channel" } e, se quiser, um filtro channel_kinds. O GameVox anexa a lista de canais deste servidor, o cliente monta um seletor a partir dela, e só é possível enviar uma opção que o GameVox emitiu.
Cada entrada do catálogo é indexada pela mesma snowflake que o seu app já enxerga para aquele canal ou grupo. Não existe uma etapa de tradução de id para errar: a pessoa escolhe um rótulo, seu app recebe um id no próprio espaço de nomes, e um valor que aponte para algo fora deste servidor simplesmente não corresponde a nenhuma opção.
{ "key": "ignored", "type": "channels", "channel_kinds": ["text", "news", "forum", "fileshare"] }. A lista, os símbolos de tipo e os ids são do GameVox.| Situação | O que a pessoa vê |
|---|---|
| Ainda não há nada salvo | Uma opção “Nenhum”, selecionada. Um campo required mostra “Selecionar…” no lugar, então salvar não consegue gravar um valor que ninguém escolheu. |
| O id salvo não existe mais (canal excluído) | Mantido como “Seleção atual (não está mais na lista)”, ainda selecionado. Salvar não apaga isso em silêncio. |
| Ids salvos num seletor múltiplo que não constam mais | São exibidos e marcados ao lado dos atuais, pelo mesmo motivo. |
| Catálogo indisponível (instância auto-hospedada offline) | Seletor desativado com o texto “Indisponível no momento”. O valor salvo é devolvido como está ao salvar, então abrir o painel durante uma indisponibilidade não apaga nenhuma configuração. |
| O servidor tem mais de 500 canais ou grupos | A lista é cortada em 500 em vez de enviar um megabyte de opções. |
O catálogo de cargos exclui duas coisas: o grupo de propriedade do servidor, que nenhum app deveria receber como cargo atribuível, e os grupos por app que carregam as permissões dos apps instalados.
6. Cenários
Cenário 1 — O menor painel útil
Uma chave, sem seções. Um array fields plano é válido em qualquer versão do esquema e aparece como um único grupo sem título.
{
"nonce": nonce,
"fields": [
{ "key": "greetings", "label": "Dar boas-vindas a novos integrantes", "type": "boolean", "value": true }
]
} Cenário 2 — Agrupando em seções
{
"nonce": nonce,
"version": 2,
"sections": [
{
"key": "general",
"label": "Geral",
"description": "Como o bot se comporta neste servidor.",
"fields": [
{ "key": "prefix", "label": "Prefixo de comando", "type": "string",
"value": "!", "max_length": 4, "help": "Usado para comandos de texto antigos." }
]
},
{
"key": "logging",
"label": "Registro de auditoria",
"fields": [
{ "key": "log.enabled", "label": "Registrar ações de moderação", "type": "boolean", "value": false }
]
}
]
} A key de uma seção é opcional, mas precisa ser única quando existe. label e description são opcionais; uma seção sem campos não é exibida.
Cenário 3 — Pedir um canal
Sem ids e sem buscar a lista de canais da guild. Restrinja o seletor aos tipos que podem receber uma mensagem.
{
"key": "log.channel",
"label": "Canal de log",
"type": "channel",
"value": stored.logChannelId ?? null,
"channel_kinds": ["text", "news"],
"help": "Onde as ações de moderação são registradas."
} Use "channel_kinds": ["header"] quando quiser uma categoria em vez de um lugar para publicar — cabeçalhos são as categorias do GameVox.
Cenário 4 — Mostrar campos condicionalmente
O formato usual: um booleano que libera o resto do recurso.
"fields": [
{ "key": "welcome.enabled", "label": "Enviar uma mensagem de boas-vindas",
"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": "Mensagem", "type": "text",
"value": stored.welcomeMessage ?? "",
"max_length": 1800,
"placeholder": "Boas-vindas, {user}, ao {server}!",
"help": "{user}, {server} e {membercount} são substituídos no envio.",
"show_if": { "key": "welcome.enabled", "equals": true } }
] Condições também podem se basear em um select, e é assim que se monta um seletor de modo: "show_if": { "key": "mode", "equals": "advanced" }.
Cenário 5 — Suas próprias opções
select e multiselect são os dois tipos que trazem opções próprias. Ambos exigem pelo menos uma opção e permitem no máximo 25.
{
"key": "automod.action",
"label": "Quando um filtro é acionado",
"type": "select",
"value": stored.action,
"required": true,
"options": [
{ "value": "delete", "label": "Excluir a mensagem" },
{ "value": "warn", "label": "Advertir quem escreveu", "description": "Exclui a mensagem e manda uma DM para a pessoa." },
{ "value": "timeout", "label": "Aplicar um tempo fora", "description": "10 minutos." },
{ "value": "none", "label": "Não fazer nada" }
]
} {
"key": "automod.filters",
"label": "Filtros ativos",
"type": "multiselect",
"value": stored.filters,
"options": [
{ "value": "invites", "label": "Convites do Discord/GameVox" },
{ "value": "links", "label": "Links" },
{ "value": "mentions", "label": "Menções em massa" },
{ "value": "caps", "label": "Excesso de maiúsculas" }
]
} A description de uma opção aparece depois do rótulo em uma lista suspensa e como dica em uma lista de marcação.
Cenário 6 — Cargos
A mesma regra de catálogo dos canais. O GameVox lista os grupos deste servidor por rank, com as cores deles.
"fields": [
{ "key": "autorole.role", "label": "Cargo dado na entrada",
"type": "role", "value": stored.autoRole ?? null },
{ "key": "moderator.roles", "label": "Cargos que podem usar comandos de moderação",
"type": "roles", "value": stored.modRoles,
"help": "Quem tiver qualquer um destes cargos pode executar /ban e /timeout." }
] Cenário 7 — Números com limites
{
"key": "automod.threshold",
"label": "Mensagens antes de declarar um raid",
"type": "number",
"value": stored.threshold ?? 10,
"min": 3,
"max": 100,
"step": 1,
"help": "Contadas em uma janela móvel de 60 segundos."
} min, max e step só moldam o controle. O navegador da pessoa não é um validador sob o seu controle, então limite o valor de novo quando ele voltar — e note que esvaziar o campo envia null, não 0.
Cenário 8 — Segredos
Marque uma credencial como secret e o GameVox esvazia o valor atual na saída. Ele não está no frame do WebSocket, nem no DOM, nem em uma captura de tela do painel — a pessoa vê um campo mascarado vazio com o texto “Sem alteração”.
{
"key": "integrations.apiKey",
"label": "Chave da API de clima",
"type": "string",
"secret": true,
"help": "Deixe em branco para manter a chave atual."
} Daí decorre o contrato: um segredo enviado vazio significa “deixe como está”, nunca “apague isto”. Se for preciso remover uma credencial, ofereça um booleano explícito para isso. secret em qualquer coisa que não seja string ou text faz a resposta falhar.
Cenário 9 — Estado somente leitura
static exibe uma linha de texto e nunca é enviado. Use para informações necessárias durante a configuração, mas que não podem ser editadas aqui.
"fields": [
{ "key": "plan", "label": "Plano", "type": "static", "value": "Pro — 4.000 consultas/dia" },
{ "key": "usage", "label": "Usadas hoje", "type": "static", "value": "1.284 consultas" },
{ "key": "lastSync", "label": "Última sincronização", "type": "static", "value": "2026-09-02 14:31 UTC" }
] Cenário 10 — Tratando um salvamento
A requisição de salvamento traz values, indexado pelas chaves de campo que você enviou. Persista e depois responda com uma confirmação.
// action === "save"
const v = req.values ?? {};
const guild = client.guilds.cache.get(req.server_id);
if (!guild) return reply(nonce, { error: "Este bot não está mais nesse servidor." });
const patch = {};
// boolean: sempre um booleano de verdade
patch.welcomeEnabled = v["welcome.enabled"] === true;
// channel: uma string snowflake ou null — reconfira contra ESTA guild
const chan = v["welcome.channel"];
patch.welcomeChannel =
(typeof chan === "string" && guild.channels.cache.has(chan)) ? chan : null;
// number: número ou null quando o campo foi esvaziado
const n = v["automod.threshold"];
patch.threshold = typeof n === "number" ? Math.min(100, Math.max(3, Math.round(n))) : 10;
// secret: vazio significa sem alteração
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: "Configurações salvas." }); Sua message vira o aviso de confirmação. O formulário fica exatamente como estava, então dá para continuar editando.
Cenário 11 — Recusando um salvamento
Devolva error e o seu texto aparece no lugar da confirmação. Use para tudo que você não pode aceitar: um valor que não passa na sua validação, um limite de plano, uma credencial externa que deixou de funcionar.
// `required` é apresentação — imponha de verdade aqui.
const action = v["automod.action"];
if (!["delete", "warn", "timeout", "none"].includes(action)) {
return reply(nonce, guildId, {
error: "Escolha o que o AutoMod deve fazer quando um filtro for acionado.",
});
}
// Qualquer coisa que só o seu lado sabe.
if (patch.apiKey && !(await upstream.verifyKey(patch.apiKey))) {
return reply(nonce, guildId, {
error: "Essa chave de API foi recusada pelo provedor de clima.",
});
} O mesmo vale para o describe: se o seu banco estiver fora do ar, responda com um error em vez de não responder. “Este app não conseguiu ler as configurações agora” é um resultado bem melhor do que doze segundos de carregamento.
Cenário 12 — Atendendo um servidor mais antigo
A requisição traz schema_version, então você nunca precisa adivinhar. Entregue o formulário mais rico que o servidor realmente vai exibir.
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, quatro tipos — string, boolean, number, select
await reply(nonce, { fields: describeV1(guildId) });
} O inverso também vale. Uma resposta v2 que chega a um cliente mais antigo ainda é exibida: o GameVox envia um array fields achatado junto com sections, e um cliente anterior aos tipos novos os desenha como campos de texto. Degradado, mas nada fica escondido.
Cenário 13 — Servidores auto-hospedados
Funciona sem mudanças. Em um servidor auto-hospedado, os catálogos de canais e cargos vêm da instância do cliente em vez das tabelas na nuvem, o que é invisível para o seu app — mesmos tipos de campo, mesmas snowflakes, mesma resposta.
A única diferença observável: se a instância estiver inacessível, os seletores chegam vazios e aparecem como “Indisponível no momento”. Os valores salvos do seu app são devolvidos como estão ao salvar, então nada se perde. Não trate um seletor vazio como “a pessoa apagou isso”.
7. O que o GameVox valida e o que não valida
O GameVox confere o formato de um salvamento e explicitamente não o significado dele. Ele não guarda o formulário que exibiu — não armazenar nada da sua configuração é justamente o ponto — então, quando um salvamento chega, nada do nosso lado sabe qual chave era um seletor de canal e qual era um campo de texto livre.
| O GameVox garante | Você ainda precisa conferir |
|---|---|
As chaves respeitam o conjunto de caracteres e não são __proto__, constructor nem prototype | Que a chave é uma que você realmente enviou |
Os valores são null, booleano, número, string ou um array de strings — nunca um objeto aninhado | Que o tipo corresponde ao campo que você declarou |
| As strings têm limite (2.000 caracteres) e os arrays comportam no máximo 25 entradas | Os seus próprios limites, mais rígidos |
| Todo id de seletor foi emitido pelo GameVox a partir do catálogo deste servidor | Que o id ainda existe nesta guild — catálogos são um instantâneo, e um canal pode ser excluído entre exibir e salvar |
| A pessoa é dona do servidor ou tem “Gerenciar servidor”, e o app está instalado aqui | Qualquer autorização sua (um nível de plano, uma conta vinculada) |
Isso não é uma brecha que o painel cria — a pessoa já pode enviar qualquer coisa por POST ao dashboard de um app. É o contrato, e é exatamente por isso que os tipos de seletor entregam ids gerados pelo GameVox em vez de deixar um app decidir o que um id significa.
8. Limites
Há dois comportamentos aqui, e a diferença importa: contagens e estrutura fazem a resposta inteira falhar, então você fica sabendo; os tetos de texto cortam em silêncio, então ninguém vê um layout quebrado.
| Limite | Valor | Se estourar |
|---|---|---|
| Seções por resposta | 12 | Recusado |
| Campos por resposta (no total, não por seção) | 60 | Recusado |
Opções por select / multiselect | 1 a 25 | Recusado |
| Entradas selecionadas em um valor múltiplo | 25 | Recusado |
| Chave de campo / de seção | 64 caracteres | Recusado |
| Valor de opção | 64 caracteres | Recusado |
| Corpo da resposta | 256 KB | 413 |
| Rótulo, texto de exemplo | 100 caracteres | Cortado |
| Texto de ajuda | 200 caracteres | Cortado |
| Descrição de seção | 300 caracteres | Cortado |
message | 200 caracteres | Cortado |
error | 300 caracteres | Cortado |
Valor de um campo da família string | 1.000 caracteres | Cortado |
Valor de um campo text / static | 2.000 caracteres | Cortado |
| Canais ou cargos em um catálogo | 500 | Truncado |
Uma recusa é comunicada como “Este app enviou configurações que o GameVox não conseguiu ler: <motivo>”, nomeando o limite atingido e o campo que o atingiu. Chaves duplicadas, um tipo desconhecido, um select sem opções e o envio simultâneo de sections e fields são recusados do mesmo jeito.
Corrigido em silêncio em vez de recusado
- Um
valueatual que não combina com o tipo dele fica vazio. Um valor desatualizado deve esvaziar um campo, não derrubar o painel. optionsem um tipo que não as usa é descartado.min/max/stepem um campo não numérico são descartados.- Entradas desconhecidas de
channel_kindssão descartadas. - Um
show_ifque aponte para uma chave desconhecida, para si mesmo ou para um campo de múltiplos valores é descartado, e o campo aparece sem condição.
9. Tempos e modos de falha
| Comportamento | Valor | O que acontece |
|---|---|---|
| Janela de resposta | 12 s | Aparece a mensagem “Este app não respondeu. Talvez ele não suporte configurações no app.” |
| Vida do nonce | 15 s | Uma resposta atrasada é descartada em vez de ser entregue a ninguém. Responder com um nonce expirado devolve 404. |
| Intervalo mínimo | 2 s por ação, por app, por servidor | “Dê um momento ao app e tente de novo.” describe e save têm janelas separadas, então abrir o painel não bloqueia um salvamento imediato. |
| App offline | — | Recusado de saída: “Este app está offline, então não pode ser configurado agora.” Nenhum despacho é tentado. |
| App errado respondendo | — | 403. Um nonce é vinculado à aplicação para a qual foi emitido. |
| Resposta duplicada | — | A primeira resposta vence; a segunda não faz nada. |
Cada requisição vira um despacho de gateway no seu app, então o intervalo mínimo existe para impedir que alguém use o GameVox para martelar o bot de terceiros. Dimensione seu handler de describe com isso em mente: ele roda ao abrir a página, uma leitura no banco é esperada, mas doze segundos é o teto.
10. Exemplo completo (discord.js)
Pronto para usar, tirando a sua própria camada de armazenamento. É o mesmo formato usado pela implementação de referência.
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) {
// Responda mesmo em caso de falha — descartar em silêncio soa como um app quebrado.
await reply(nonce, guildId, {
error: "O bot não conseguiu ler as configurações dele para este servidor.",
}).catch(() => undefined);
}
}
async function describe(guildId) {
const cfg = await store.get(guildId);
return [
{
key: "general",
label: "Geral",
description: "Como o bot se comporta neste servidor.",
fields: [
{ key: "prefix", label: "Prefixo de comando", type: "string",
value: cfg.prefix, max_length: 4 },
{ key: "ignored", label: "Canais ignorados", type: "channels",
value: cfg.ignored, channel_kinds: POSTABLE,
help: "Comandos não são respondidos nestes canais." },
],
},
{
key: "welcome",
label: "Mensagens de boas-vindas",
description: "Publicada quando alguém entra.",
fields: [
{ key: "welcome.enabled", label: "Enviar uma mensagem de boas-vindas",
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: "Mensagem", type: "text",
value: cfg.welcome.message ?? "", max_length: 1800,
placeholder: "Boas-vindas, {user}, ao {server}!",
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 "Configurações salvas.";
} 11. Lista de verificação antes de publicar
- Cada chave de campo é estável. Renomear uma deixa órfão o que já foi configurado.
- Todo id vindo de um seletor é reconferido contra a guild antes de ser gravado.
- Um
secretvazio é tratado como “sem alteração”, nunca como “apagado”. - O caminho de
describeresponde bem abaixo de 12 segundos, incluindo a leitura no banco. - Todo caminho de falha ainda responde — com
error, não com silêncio. requiredemin/maxsão reaplicados ao salvar.- Um seletor vazio significa que o catálogo estava indisponível, não que o campo foi apagado.
- O handler não faz nada no Discord, então uma única build serve para as duas plataformas.
Quem pode abrir o painel
Quem é dono do servidor, ou alguém com Gerenciar servidor — a mesma barreira que rege instalar e desinstalar apps. Em um servidor auto-hospedado a verificação é feita contra a instância do cliente, então a administração de lá não é recusada por falta de uma linha de permissão na nuvem.
O GameVox recusa a requisição de imediato se o seu app não estiver instalado naquele servidor, então um pedido de painel só chega a um app que já foi autorizado.