Entwicklerportal
Experimentell Die Bot- & App-Plattform wird aktiv weiterentwickelt. Die Unterstützung für selbst gehostete Server ist am 13.08.2026 erschienen und bietet weniger Funktionen als die Cloud. Sieh dir an, was unterstützt wird.
← Doku

In-App-Einstellungen

GameVox kann das Konfigurationsformular deiner App direkt im Client rendern. Deine App beschreibt das Formular über das Gateway, eine betreibende Person bearbeitet es unter Servereinstellungen ▸ Integrationen ▸ Einstellungen, und die bearbeiteten Werte kommen direkt zu deinem Bot zurück. GameVox speichert nichts davon — deine App bleibt die einzige Eigentümerin ihrer Konfiguration.

Dafür gibt es keine Discord-Entsprechung. Es ist der eine Teil der GameVox-App-Plattform, der bewusst nicht kompatibel ist, weil es auf der Gegenseite nichts gibt, womit man kompatibel sein könnte.

Das Fenster „Servereinstellungen“ des GameVox-Clients mit dem Einstellungs-Tab einer installierten App: ein Sprach-Dropdown, ein Schalter und eine Kanal-Checkliste.
Ein echtes Panel, vollständig aus der Antwort eines einzigen Bots gerendert: ein select, ein boolean und ein channels-Picker, gruppiert in einem Abschnitt mit Beschreibung. Der Bot hat keine Kanal-IDs gesendet — die Liste stammt von GameVox. Server- und Kanalnamen wurden geschwärzt.

Warum es das gibt

Auf Discord bringt ein Bot mit irgendeiner Konfiguration ein Web-Dashboard mit: eine Domain, ein OAuth2-Login, Sitzungsverwaltung, eine Berechtigungsprüfung, die die Guild-Liste der Person erneut ausliest, CSRF-Schutz und Hosting für all das — meist nur, damit eine betreibende Person einen Log-Kanal wählen und drei Funktionen umschalten kann. GameVox weiß bereits, wer die betreibende Person ist, welchen Server sie konfiguriert und ob sie das darf, und bietet diese Fläche deshalb direkt deiner App an.

DiscordGameVox
Wo betreibende Personen deine App konfigurieren Ein Web-Dashboard, das du hostest Servereinstellungen ▸ Integrationen ▸ Einstellungen
Was du bauen musst Web-App, OAuth2-Login, Sitzungen, Berechtigungsprüfungen, Hosting Ein Gateway-Handler und ein REST-Aufruf
Wer die betreibende Person authentifiziert Du, per OAuth2 GameVox, bevor deine App überhaupt gefragt wird
Wer die Konfiguration speichert Du Du. GameVox behält nichts.
Kanal- und Rollen-Picker Du holst die Kanäle und Rollen der Guild und renderst sie Du benennst einen Typ; GameVox liefert die Auswahl
Aufwand, um einen Schalter anzubieten Ein Dashboard Etwa zwanzig Zeilen

Das ersetzt nichts. Slash-Befehle funktionieren weiterhin, und wenn du bereits ein Dashboard hast, kannst du es behalten — beides schließt sich nicht aus, und viele Apps wollen das Panel für die fünf Einstellungen, die tatsächlich geändert werden, und das Dashboard für alles andere.

So funktioniert es

Betreibende Person öffnet den Einstellungs-Tab
      │
      ▼
GameVox ──── APP_SETTINGS_REQUEST (action: "describe") ────▶ dein Bot
                                                                │
dein Bot ─── POST /applications/@me/settings-response ──────────┘
             { nonce, version: 2, sections: [ ... ] }
      │
      ▼
GameVox rendert das Formular, es wird bearbeitet, Speichern wird geklickt
      │
      ▼
GameVox ──── APP_SETTINGS_REQUEST (action: "save", values) ─▶ dein Bot
                                                                │
dein Bot ─── POST /applications/@me/settings-response ──────────┘
             { nonce, message: "Gespeichert." }

Zwei Aktionen, eine Anfrageform, ein Antwort-Endpunkt. describe fragt, was deine App für diesen Server anbietet; save reicht zurück, was geändert wurde. Beide werden gleich beantwortet.

1. Die Anfrage empfangen

GameVox liefert APP_SETTINGS_REQUEST über das Gateway an die Sitzung aus, die gerade die Verbindung deiner App hält. Kein Intent regelt das — der Dispatch ist gezielt an deine App adressiert und wird nicht gegen eine Intent-Maske gefiltert, kommt also unabhängig davon an, womit du dich identifiziert hast.

{
  "op": 0,
  "t": "APP_SETTINGS_REQUEST",
  "d": {
    "nonce": "5f3c1b0e-8a4d-4b2f-9c11-2f7a6d0b4e93",
    "server_id": "1387452901234567890",
    "action": "describe",
    "schema_version": 2
  }
}
FeldTypHinweise
nonce string Undurchsichtig. Gib ihn wortgetreu zurück; er ist das Einzige, was deine Antwort mit der wartenden Person verbindet. 15 Sekunden lang beantwortbar.
server_id snowflake Die Guild, im selben ID-Raum wie jeder andere Dispatch. Es ist die ID, die dein GUILD_CREATE mitgeführt hat, dein bestehender Guild-Cache löst sie also auf.
action string "describe" oder "save".
schema_version integer Das reichhaltigste Schema, das dieser Server rendern kann. Derzeit 2. Siehe Ältere Server bedienen.
values object Nur bei save vorhanden. Die Schlüssel sind die Feldschlüssel, die du gesendet hast.

server_id wird von GameVox aus der authentifizierten Anfrage der betreibenden Person gesetzt. Es wird nie aus deiner Antwort zurückgelesen, eine App kann also nicht eine Frage zu einem Server mit den Einstellungen eines anderen beantworten.

Den Dispatch in deiner Bibliothek lesen

Keine Bot-Bibliothek liefert einen Handler für ein Ereignis mit, das es bei Discord nicht gibt, das Paket wird also verworfen, bevor es einen Event-Handler erreicht. Jede Bibliothek bietet genau für diesen Fall den rohen Dispatch-Strom:

BibliothekHookAktivieren
discord.js (v14) client.on('raw', packet) Standardmäßig an. Wird für jeden Dispatch ausgelöst, bevor das Paket verarbeitet wird.
discord.py (v2) on_socket_raw_receive(msg) Übergib enable_debug_events=True an den Client.
Eris client.on('rawWS', packet) Standardmäßig an.
JDA RawGatewayEvent JDABuilder.setRawEventsEnabled(true)
DSharpPlus DiscordClient.UnknownEvent Standardmäßig an. EventName plus rohes Json.
serenity RawEventHandler::raw_event ClientBuilder::raw_event_handler

Gegen das echte Discord löst dieser Handler nie aus, derselbe Build läuft also ohne Verzweigung auf beiden Plattformen.

2. Antworten

Antworte über REST, nicht über das Gateway. Jede Bibliothek bietet einen HTTP-Client; die wenigsten lassen Anwendungscode beliebige Opcodes auf den Socket schreiben — deshalb ist die Antwort so geformt.

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": "Optionale Notiz über dem Formular.",
  "sections": [
    {
      "key": "welcome",
      "label": "Willkommensnachrichten",
      "description": "Wird gepostet, wenn jemand beitritt.",
      "fields": [
        { "key": "welcome.enabled", "label": "Willkommensnachricht senden",
          "type": "boolean", "value": true }
      ]
    }
  ]
}

Eine erfolgreiche Antwort gibt 204 No Content zurück. Du kannst ?server_id= mit der Guild-ID anhängen; das wird nur genutzt, um den Aufruf im Aktivitätsprotokoll deiner App zuzuordnen.

SchlüsselTypHinweise
nonce string Erforderlich. Aus der Anfrage.
version integer Das Schema, in dem du antwortest. Sende 2, wenn du sections sendest.
sections array Formular in Schema v2. Schließt fields aus.
fields array Flaches Formular in Schema v1. Wird als ein unbenannter Abschnitt gerendert.
message string Bei describe eine Notiz über dem Formular. Bei save die Bestätigungsmeldung. Wird bei 200 Zeichen gekürzt.
error string Wird der betreibenden Person statt eines Formulars angezeigt. Wird bei 300 Zeichen gekürzt.

Antworte immer, auch im Fehlerfall. Die Anfrage stillschweigend fallen zu lassen, lässt die betreibende Person bis zum 12-Sekunden-Timeout von GameVox auf einem Ladekreis sitzen — und das liest sich als „diese App ist kaputt“ statt als „einmal ist etwas schiefgegangen“.

3. Feldtypen

Zwölf Typen, und die Liste ist abgeschlossen — ein unbekannter type lässt die gesamte Antwort scheitern, statt als Nichts gerendert zu werden.

Typ Steuerelement value, das du sendestWas du beim Speichern zurückbekommst
string Einzeiliges Textfeld string string ("" wenn leer)
text Textbereich string string
boolean Umschalter boolean true / false
number Zahleneingabe number number, oder null wenn geleert
select Dropdown deiner options Optionswert string, oder null wenn nicht gesetzt
multiselect Checkliste deiner options Array von Optionswerten Array von Strings (kann leer sein)
channel Dropdown der Kanäle dieses Servers Kanal-Snowflake Snowflake-String, oder null
channels Checkliste der Kanäle dieses Servers Array von Snowflakes Array von Snowflake-Strings
role Dropdown der Gruppen dieses Servers Rollen-Snowflake Snowflake-String, oder null
roles Checkliste der Gruppen dieses Servers Array von Snowflakes Array von Snowflake-Strings
color Farbfeld #rrggbb #rrggbb, immer vorhanden
static Schreibgeschützte Textzeile string Wird nie übermittelt — der Schlüssel fehlt in values

Ein Farbfeld hat immer einen Wert, eine App, die keinen gespeichert hatte, bekommt beim ersten Speichern also #000000 zurück. Wenn du „keine Farbe“ brauchst, kombiniere das Farbfeld mit einem boolean.

4. Feldattribute

AttributGilt fürWirkung
key alle Erforderlich. [A-Za-z0-9][A-Za-z0-9._:-]{0,63}. Eindeutig über die gesamte Antwort, nicht nur pro Abschnitt.
label alle Fällt auf den Schlüssel zurück. Wird bei 100 Zeichen gekürzt.
help alle Hinweiszeile unter dem Steuerelement. Wird bei 200 gekürzt.
placeholder Texteingaben, number Wird bei 100 gekürzt.
options select, multiselect Für diese beiden erforderlich. { value, label, description }, maximal 25. Bei jedem anderen Typ wird es verworfen.
min , max , step number Formt die Eingabe. Nur beratend — prüfe den erhaltenen Wert erneut.
max_length string, text Begrenzt die Eingabe, bis zur Plattformobergrenze für diesen Typ.
channel_kinds channel, channels Schränkt den Picker ein. text, voice, forum, news, fileshare, header. Unbekannte Arten werden verworfen; ein leeres Ergebnis bedeutet alle Arten.
required alle Markiert die Beschriftung und ersetzt den Eintrag „Keine“ des Pickers durch „Auswählen…“. Rein darstellend — erzwinge es beim Speichern.
disabled alle Graut das Steuerelement aus. Wird trotzdem mit seinem aktuellen Wert übermittelt.
secret string, text Maskiert die Eingabe und leert den aktuellen Wert auf dem Rückweg. Bei jedem anderen Typ scheitert die Antwort.
show_if alle { key, equals }. Blendet das Feld aus, bis ein anderes Feld derselben Antwort diesen Wert hat.

show_if ist ein Gleichheitstest und sonst nichts — keine Ausdrücke, keine Operatoren. Der referenzierte Schlüssel muss zu einem einwertigen Feld derselben Antwort gehören und darf nicht der eigene Schlüssel des Felds sein; eine Bedingung, die daran scheitert, wird verworfen und das Feld erscheint bedingungslos. equals darf ein String, Boolean, eine Zahl oder null sein.

Ein ausgeblendetes Feld wird trotzdem übermittelt. Es hat einen Wert, den deine App gesendet hat, und ihn wegzulassen würde bei deiner App so aussehen, als hätte die betreibende Person ihn geleert.

5. Kanal- und Rollen-Picker

Diese sind der Teil, den man verstehen sollte, weil sie die übliche Anordnung umkehren. Deine App benennt eine Art; GameVox liefert die Auswahl.

Ein Kanalfeld trägt keine IDs. Du sendest { "type": "channel" } und optional einen channel_kinds-Filter. GameVox hängt die Kanalliste dieses Servers an, der Client rendert daraus einen Picker, und es kann nur eine Option übermittelt werden, die GameVox ausgegeben hat.

Jeder Katalogeintrag ist mit derselben Snowflake verschlüsselt, die deine App bereits sieht — für diesen Kanal oder diese Gruppe. Es gibt keinen ID-Übersetzungsschritt, den man falsch machen könnte: Die betreibende Person wählt eine Beschriftung, deine App erhält eine ID in ihrem eigenen Namensraum, und ein Wert, der etwas außerhalb dieses Servers benennt, passt einfach zu keiner Option.

Ein channels-Feld, gerendert als Checkliste der servereigenen Kanäle, jeweils mit vorangestelltem Art-Symbol.
Alles, was die App für dieses Feld gesendet hat, war { "key": "ignored", "type": "channels", "channel_kinds": ["text", "news", "forum", "fileshare"] }. Die Liste, die Art-Symbole und die IDs stammen von GameVox.
SituationWas die betreibende Person sieht
Noch nichts gespeichert Ein ausgewählter Eintrag „Keine“. Ein required-Feld zeigt stattdessen „Auswählen…“, sodass beim Speichern kein Wert festgeschrieben werden kann, den niemand gewählt hat.
Gespeicherte ID existiert nicht mehr (Kanal gelöscht) Bleibt als „Aktuelle Auswahl (nicht mehr gelistet)“ erhalten und ausgewählt. Speichern löscht sie nicht stillschweigend.
Gespeicherte IDs in einem Mehrfach-Picker, die nicht mehr gelistet sind Werden aus demselben Grund neben den aktuellen gerendert und angehakt.
Katalog nicht verfügbar (selbst gehostete Instanz offline) Deaktivierter Picker mit dem Text „Derzeit nicht verfügbar“. Der gespeicherte Wert wird beim Speichern zurückgespiegelt, sodass das Öffnen des Panels während einer Störung keine Einstellung löschen kann.
Server hat mehr als 500 Kanäle oder Gruppen Die Liste wird bei 500 abgeschnitten, statt ein Megabyte an Optionen zu übertragen.

Der Rollenkatalog schließt zwei Dinge aus: die Eigentümergruppe des Servers, die keiner App als zuweisbare Rolle angeboten werden sollte, und die App-Gruppen, die die Berechtigungen installierter Apps tragen.

6. Szenarien

Szenario 1 — Das kleinste sinnvolle Panel

Ein Schalter, keine Abschnitte. Ein flaches fields-Array ist in jeder Schemaversion gültig und wird als eine einzelne unbenannte Gruppe gerendert.

{
  "nonce": nonce,
  "fields": [
    { "key": "greetings", "label": "Neue Mitglieder begrüßen", "type": "boolean", "value": true }
  ]
}

Szenario 2 — In Abschnitte gruppieren

{
  "nonce": nonce,
  "version": 2,
  "sections": [
    {
      "key": "general",
      "label": "Allgemein",
      "description": "Wie sich der Bot auf diesem Server verhält.",
      "fields": [
        { "key": "prefix", "label": "Befehlspräfix", "type": "string",
          "value": "!", "max_length": 4, "help": "Wird für klassische Textbefehle genutzt." }
      ]
    },
    {
      "key": "logging",
      "label": "Audit-Protokollierung",
      "fields": [
        { "key": "log.enabled", "label": "Moderationsaktionen protokollieren", "type": "boolean", "value": false }
      ]
    }
  ]
}

Der key eines Abschnitts ist optional, muss aber eindeutig sein, wenn er vorhanden ist. label und description sind beide optional; ein Abschnitt ohne Felder wird nicht gerendert.

Szenario 3 — Nach einem Kanal fragen

Keine IDs, kein Abrufen der Kanalliste der Guild. Beschränke den Picker auf die Arten, die eine Nachricht aufnehmen können.

{
  "key": "log.channel",
  "label": "Log-Kanal",
  "type": "channel",
  "value": stored.logChannelId ?? null,
  "channel_kinds": ["text", "news"],
  "help": "Wo Moderationsaktionen festgehalten werden."
}

Nutze "channel_kinds": ["header"], wenn du eine Kategorie statt eines Postziels willst — Header sind die Kategorien von GameVox.

Szenario 4 — Felder bedingt einblenden

Die übliche Form: ein Boolean, das den Rest seiner Funktion freischaltet.

"fields": [
  { "key": "welcome.enabled", "label": "Willkommensnachricht senden",
    "type": "boolean", "value": true },

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

  { "key": "welcome.message", "label": "Nachricht", "type": "text",
    "value": stored.welcomeMessage ?? "",
    "max_length": 1800,
    "placeholder": "Willkommen {user} auf {server}!",
    "help": "{user}, {server} und {membercount} werden beim Senden ersetzt.",
    "show_if": { "key": "welcome.enabled", "equals": true } }
]

Bedingungen können auch auf ein select abstellen — so baust du einen Modus-Umschalter: "show_if": { "key": "mode", "equals": "advanced" }.

Szenario 5 — Deine eigenen Auswahlmöglichkeiten

select und multiselect sind die beiden Typen, die eigene Optionen mitbringen. Beide brauchen mindestens eine Option und erlauben höchstens 25.

{
  "key": "automod.action",
  "label": "Wenn ein Filter greift",
  "type": "select",
  "value": stored.action,
  "required": true,
  "options": [
    { "value": "delete",  "label": "Nachricht löschen" },
    { "value": "warn",    "label": "Verfassende Person verwarnen",  "description": "Löscht die Nachricht und schreibt dem Mitglied eine DM." },
    { "value": "timeout", "label": "Verfassende Person aussperren", "description": "10 Minuten." },
    { "value": "none",    "label": "Nichts tun" }
  ]
}
{
  "key": "automod.filters",
  "label": "Aktive Filter",
  "type": "multiselect",
  "value": stored.filters,
  "options": [
    { "value": "invites",   "label": "Discord-/GameVox-Einladungen" },
    { "value": "links",     "label": "Links" },
    { "value": "mentions",  "label": "Massenerwähnungen" },
    { "value": "caps",      "label": "Übermäßige Großschreibung" }
  ]
}

Die description einer Option erscheint in einem Dropdown hinter der Beschriftung und in einer Checkliste als Tooltip.

Szenario 6 — Rollen

Dieselbe Katalogregel wie bei Kanälen. GameVox listet die Gruppen dieses Servers nach Rang auf, mit ihren Farben.

"fields": [
  { "key": "autorole.role", "label": "Rolle beim Beitritt",
    "type": "role", "value": stored.autoRole ?? null },

  { "key": "moderator.roles", "label": "Rollen, die Moderationsbefehle nutzen dürfen",
    "type": "roles", "value": stored.modRoles,
    "help": "Mitglieder mit einer dieser Rollen können /ban und /timeout ausführen." }
]

Szenario 7 — Zahlen mit Grenzen

{
  "key": "automod.threshold",
  "label": "Nachrichten, bevor ein Raid erkannt wird",
  "type": "number",
  "value": stored.threshold ?? 10,
  "min": 3,
  "max": 100,
  "step": 1,
  "help": "Gezählt über ein gleitendes 60-Sekunden-Fenster."
}

min, max und step formen nur das Steuerelement. Der Browser der betreibenden Person ist kein Validator, den du kontrollierst — begrenze den Wert also erneut, wenn er zurückkommt. Beachte auch: Das Leeren des Felds übermittelt null, nicht 0.

Szenario 8 — Geheimnisse

Markiere eine Zugangsinformation als secret, und GameVox leert ihren aktuellen Wert auf dem Rückweg. Er steckt weder im WebSocket-Frame noch im DOM noch in einem Screenshot des Panels — die betreibende Person sieht ein leeres maskiertes Feld mit dem Platzhalter „Unverändert“.

{
  "key": "integrations.apiKey",
  "label": "API-Schlüssel für Wetterdienst",
  "type": "string",
  "secret": true,
  "help": "Leer lassen, um den aktuellen Schlüssel zu behalten."
}

Daraus folgt der Vertrag: ein leer übermitteltes Secret bedeutet „unverändert lassen“, niemals „das hier löschen“. Wenn eine Zugangsinformation entfernt werden soll, gib dafür ein eigenes Boolean. secret bei etwas anderem als string oder text lässt die Antwort scheitern.

Szenario 9 — Schreibgeschützter Status

static rendert eine Textzeile und wird nie übermittelt. Nutze es für Zustände, die beim Konfigurieren wichtig sind, aber hier nicht bearbeitet werden können.

"fields": [
  { "key": "plan", "label": "Tarif", "type": "static", "value": "Pro — 4.000 Abfragen/Tag" },
  { "key": "usage", "label": "Heute genutzt", "type": "static", "value": "1.284 Abfragen" },
  { "key": "lastSync", "label": "Letzte Synchronisierung", "type": "static", "value": "2026-09-02 14:31 UTC" }
]

Szenario 10 — Ein Speichern verarbeiten

Die Speicheranfrage enthält values, verschlüsselt nach den Feldschlüsseln, die du gesendet hast. Persistiere sie und antworte dann mit einer Bestätigung.

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

const guild = client.guilds.cache.get(req.server_id);
if (!guild) return reply(nonce, { error: "Dieser Bot ist nicht mehr auf diesem Server." });

const patch = {};

// boolean: immer ein echter Boolean
patch.welcomeEnabled = v["welcome.enabled"] === true;

// channel: ein Snowflake-String oder null — prüfe ihn erneut gegen DIESE Guild
const chan = v["welcome.channel"];
patch.welcomeChannel =
  (typeof chan === "string" && guild.channels.cache.has(chan)) ? chan : null;

// number: Zahl oder null, wenn das Feld geleert wurde
const n = v["automod.threshold"];
patch.threshold = typeof n === "number" ? Math.min(100, Math.max(3, Math.round(n))) : 10;

// secret: leer bedeutet unverändert
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: "Einstellungen gespeichert." });

Deine message wird zur Bestätigungsmeldung. Das Formular bleibt genau so, wie es war, sodass weiter bearbeitet werden kann.

Szenario 11 — Ein Speichern ablehnen

Gib error zurück, und statt einer Bestätigung erscheint dein Text. Nutze es für alles, was du nicht akzeptieren kannst: ein Wert, der deine eigene Validierung nicht besteht, ein Tariflimit, eine Zugangsinformation, die nicht mehr funktioniert.

// `required` ist rein darstellend — erzwinge es hier.
const action = v["automod.action"];
if (!["delete", "warn", "timeout", "none"].includes(action)) {
  return reply(nonce, guildId, {
    error: "Wähle, was AutoMod tun soll, wenn ein Filter greift.",
  });
}

// Alles, was nur deine Seite wissen kann.
if (patch.apiKey && !(await upstream.verifyKey(patch.apiKey))) {
  return reply(nonce, guildId, {
    error: "Dieser API-Schlüssel wurde vom Wetterdienst abgelehnt.",
  });
}

Dasselbe gilt für describe: Ist deine Datenbank nicht erreichbar, antworte mit einem error, statt gar nicht zu antworten. „Diese App konnte ihre Einstellungen gerade nicht lesen“ ist für die betreibende Person ein weit besseres Ergebnis als ein zwölf Sekunden langer Ladekreis.

Szenario 12 — Ältere Server bedienen

Die Anfrage führt schema_version mit, du musst also nie tasten. Liefere das reichhaltigste Formular, das der Server tatsächlich rendert.

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

if (version >= 2) {
  await reply(nonce, { version: 2, sections: describeV2(guildId) });
} else {
  // v1: flache Liste, vier Typen — string, boolean, number, select
  await reply(nonce, { fields: describeV1(guildId) });
}

Auch umgekehrt gilt das. Eine v2-Antwort, die einen älteren Client erreicht, wird trotzdem gerendert: GameVox sendet neben sections ein flach gemachtes fields-Array, und ein Client, der die neueren Typen nicht kennt, zeichnet sie als Textfelder. Eingeschränkt, aber nichts wird verborgen.

Szenario 13 — Selbst gehostete Server

Funktioniert unverändert. Auf einem selbst gehosteten Server werden die Kanal- und Rollenkataloge von der Instanz der Kundschaft statt aus Cloud-Tabellen geholt, was für deine App unsichtbar ist — dieselben Feldtypen, dieselben Snowflakes, dieselbe Antwort.

Der eine beobachtbare Unterschied: Ist die Instanz nicht erreichbar, kommen die Picker leer an und erscheinen als „Derzeit nicht verfügbar“. Die gespeicherten Werte deiner App werden beim Speichern zurückgespiegelt, es geht also nichts verloren. Behandle einen leeren Picker nicht als „die betreibende Person hat das geleert“.

7. Was GameVox prüft — und was nicht

GameVox prüft die Form eines Speichervorgangs und ausdrücklich nicht seine Bedeutung. Es behält das gerenderte Formular nicht — nichts von deiner Konfiguration zu speichern ist ja gerade der Sinn — sodass zum Zeitpunkt eines Speicherns auf unserer Seite nichts mehr weiß, welcher Schlüssel ein Kanal-Picker und welcher ein freies Textfeld war.

GameVox garantiertDu musst trotzdem prüfen
Schlüssel entsprechen dem Zeichensatz und sind nicht __proto__, constructor oder prototype Dass der Schlüssel einer ist, den du tatsächlich gesendet hast
Werte sind null, Boolean, Zahl, String oder ein Array von Strings — nie ein verschachteltes Objekt Dass der Typ zu dem Feld passt, das du deklariert hast
Strings sind begrenzt (2.000 Zeichen) und Arrays fassen höchstens 25 Einträge Deine eigenen, engeren Grenzen
Jede Picker-ID wurde von GameVox aus dem Katalog dieses Servers ausgegeben Dass die ID in dieser Guild noch auflösbar ist — Kataloge sind eine Momentaufnahme, und ein Kanal kann zwischen Rendern und Speichern gelöscht werden
Die betreibende Person ist Server-Eigentümerin oder hat „Server verwalten“, und die App ist hier installiert Jede eigene Autorisierung (eine Tarifstufe, ein verknüpftes Konto)

Das ist keine Lücke, die das Panel einführt — eine betreibende Person kann ohnehin schon beliebige Daten an das eigene Dashboard einer App POSTen. Es ist der Vertrag, und genau deshalb geben dir die Picker-Typen IDs, die GameVox erzeugt hat, statt einer App zu überlassen, was eine ID bedeutet.

8. Grenzwerte

Hier gibt es zwei Verhalten, und der Unterschied zählt: Anzahlen und Struktur lassen die gesamte Antwort scheitern, sodass du es merkst; Textobergrenzen kürzen still, sodass die betreibende Person nie ein zerbrochenes Layout sieht.

GrenzwertWertBei Überschreitung
Abschnitte pro Antwort 12 Abgelehnt
Felder pro Antwort (insgesamt, nicht pro Abschnitt) 60 Abgelehnt
Optionen pro select / multiselect 1–25 Abgelehnt
Ausgewählte Einträge bei Mehrfachwerten 25 Abgelehnt
Feld- / Abschnittsschlüssel 64 Zeichen Abgelehnt
Optionswert 64 Zeichen Abgelehnt
Antwort-Body 256 KB 413
Beschriftung, Platzhalter 100 Zeichen Gekürzt
Hinweistext 200 Zeichen Gekürzt
Abschnittsbeschreibung 300 Zeichen Gekürzt
message 200 Zeichen Gekürzt
error 300 Zeichen Gekürzt
Wert eines Felds der string-Familie 1.000 Zeichen Gekürzt
Wert eines text- / static-Felds 2.000 Zeichen Gekürzt
Kanäle oder Rollen in einem Katalog 500 Abgeschnitten

Eine Ablehnung wird der betreibenden Person als „Diese App hat Einstellungen gesendet, die GameVox nicht lesen konnte: <Grund>“ gemeldet, unter Nennung der überschrittenen Grenze und des betroffenen Felds. Doppelte Schlüssel, ein unbekannter Typ, ein select ohne Optionen und das gleichzeitige Senden von sections und fields werden auf dieselbe Weise abgelehnt.

Wird still korrigiert statt abgelehnt

  • Ein aktueller value, der nicht zu seinem Typ passt, wird leer. Ein veralteter Wert sollte ein Feld leeren, nicht das ganze Panel lahmlegen.
  • options bei einem Typ, der sie nicht nutzt, werden verworfen.
  • min / max / step bei einem nicht numerischen Feld werden verworfen.
  • Unbekannte channel_kinds-Einträge werden verworfen.
  • Ein show_if, das einen unbekannten Schlüssel, sich selbst oder ein mehrwertiges Feld benennt, wird verworfen, und das Feld erscheint bedingungslos.

9. Zeiten und Fehlerfälle

VerhaltenWertWas passiert
Antwortfenster 12 s Der betreibenden Person wird angezeigt: „Diese App hat nicht geantwortet. Sie unterstützt möglicherweise keine In-App-Einstellungen.“
Lebensdauer der Nonce 15 s Eine verspätete Antwort wird verworfen, statt ins Leere zugestellt zu werden. Eine Antwort mit abgelaufener Nonce gibt 404 zurück.
Abklingzeit 2 s pro Aktion, pro App, pro Server „Gib der App einen Moment und versuch es dann erneut.“ describe und save haben getrennte Fenster, das Öffnen des Panels blockiert also kein unmittelbares Speichern.
App offline Wird vorab abgelehnt: „Diese App ist offline und kann derzeit nicht konfiguriert werden.“ Es wird kein Dispatch versucht.
Falsche App antwortet 403. Eine Nonce ist an die Anwendung gebunden, für die sie ausgegeben wurde.
Doppelte Antwort Die erste Antwort gewinnt; die zweite ist ein No-Op.

Jede Anfrage wird bei deiner App zu einem Gateway-Dispatch, die Abklingzeit existiert also, damit eine betreibende Person GameVox nicht nutzen kann, um den Bot eines Dritten zu hämmern. Kalkuliere deinen describe-Handler entsprechend: Er läuft beim Öffnen der Seite, ein Datenbankzugriff ist erwartbar, aber zwölf Sekunden sind die Grenze.

10. Vollständiges Beispiel (discord.js)

Einsatzfertig, abgesehen von deiner eigenen Speicherschicht. Das ist dieselbe Form, die auch die Referenzimplementierung nutzt.

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) {
    // Auch im Fehlerfall antworten — ein stilles Verwerfen liest sich als kaputte App.
    await reply(nonce, guildId, {
      error: "Der Bot konnte seine Einstellungen für diesen Server nicht lesen.",
    }).catch(() => undefined);
  }
}

async function describe(guildId) {
  const cfg = await store.get(guildId);
  return [
    {
      key: "general",
      label: "Allgemein",
      description: "Wie sich der Bot auf diesem Server verhält.",
      fields: [
        { key: "prefix", label: "Befehlspräfix", type: "string",
          value: cfg.prefix, max_length: 4 },
        { key: "ignored", label: "Ignorierte Kanäle", type: "channels",
          value: cfg.ignored, channel_kinds: POSTABLE,
          help: "In diesen Kanälen werden Befehle nicht beantwortet." },
      ],
    },
    {
      key: "welcome",
      label: "Willkommensnachrichten",
      description: "Wird gepostet, wenn jemand beitritt.",
      fields: [
        { key: "welcome.enabled", label: "Willkommensnachricht senden",
          type: "boolean", value: cfg.welcome.enabled === true },
        { key: "welcome.channel", label: "Kanal", type: "channel",
          value: cfg.welcome.channel ?? null, channel_kinds: POSTABLE,
          show_if: { key: "welcome.enabled", equals: true } },
        { key: "welcome.message", label: "Nachricht", type: "text",
          value: cfg.welcome.message ?? "", max_length: 1800,
          placeholder: "Willkommen {user} auf {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 "Einstellungen gespeichert.";
}

11. Checkliste vor dem Ausliefern

  • Jeder Feldschlüssel ist stabil. Einen umzubenennen verwaist, was die betreibende Person bereits konfiguriert hat.
  • Jede ID aus einem Picker wird gegen die Guild geprüft, bevor sie geschrieben wird.
  • Ein leeres secret wird als unverändert behandelt, nie als geleert.
  • Der describe-Pfad antwortet deutlich unter 12 Sekunden, inklusive Datenbankzugriff.
  • Jeder Fehlerpfad antwortet trotzdem — mit error, nicht mit Schweigen.
  • required und min/max werden beim Speichern erneut erzwungen.
  • Ein leerer Picker bedeutet, dass der Katalog nicht verfügbar war, nicht dass das Feld geleert wurde.
  • Der Handler ist auf Discord ein No-Op, ein Build läuft also auf beiden Plattformen.

Wer das Panel öffnen darf

Die Server-Eigentümerin oder ein Mitglied mit Server verwalten — dieselbe Hürde, die auch das Installieren und Deinstallieren von Apps regelt. Auf einem selbst gehosteten Server wird die Prüfung gegen die Instanz der Kundschaft durchgeführt, sodass eine dortige Administration nicht mangels Cloud-Berechtigungszeile abgewiesen wird.

GameVox lehnt die Anfrage rundweg ab, wenn deine App auf diesem Server nicht installiert ist, sodass eine Panel-Anfrage nur eine App erreichen kann, die die betreibende Person bereits autorisiert hat.

← Umstieg von Discord  ·  Zurück zur Doku