アプリ内設定
GameVox はアプリの設定フォームをクライアント内に表示できます。アプリがゲートウェイ経由でフォームを記述し、運営者がサーバー設定 ▸ 連携 ▸ 設定で編集すると、その値がそのままボットに返ります。GameVox は何も保存しません。設定の所有者はあなたのアプリだけです。
これに相当する機能は Discord にありません。GameVox のアプリプラットフォームで意図的に互換にしていない唯一の部分です。互換にすべき相手が向こう側に存在しないからです。
select、boolean、channels のピッカーが、説明付きのセクションにまとめられています。ボットはチャンネル ID を送っておらず、一覧は GameVox が用意したものです。サーバー名とチャンネル名は伏せています。なぜこの仕組みがあるのか
Discord では、設定項目が少しでもあるボットはウェブダッシュボードを用意することになります。ドメイン、OAuth2 ログイン、セッション管理、ユーザーのギルド一覧を読み直す権限チェック、CSRF 対策、そしてそれらのホスティング — たいていは運営者がログチャンネルを選び、機能を 3 つ切り替えるためだけにです。GameVox は運営者が誰か、どのサーバーを設定しているか、その権限があるかをすでに把握しているので、その画面をアプリに直接提供します。
| Discord | GameVox | |
|---|---|---|
| 運営者がアプリを設定する場所 | 自分でホストするウェブダッシュボード | サーバー設定 ▸ 連携 ▸ 設定 |
| 作るもの | ウェブアプリ、OAuth2 ログイン、セッション、権限チェック、ホスティング | ゲートウェイのハンドラー 1 つと REST 呼び出し 1 つ |
| 運営者を認証するのは誰か | あなた(OAuth2 経由) | GameVox(アプリに問い合わせる前に済ませます) |
| 設定を保存するのは誰か | あなた | あなた。GameVox は何も保持しません。 |
| チャンネルとロールのピッカー | ギルドのチャンネルとロールを取得して自分で描画する | 種類を指定するだけ。選択肢は GameVox が用意する |
| トグルを 1 つ出すためのコスト | ダッシュボード 1 式 | 20 行ほど |
これは何かを置き換えるものではありません。スラッシュコマンドはこれまで通り使えますし、すでにダッシュボードがあるなら残して構いません。両者は排他的ではなく、実際によく変更される 5 つの設定にはパネルを、それ以外にはダッシュボードを、という使い分けをするアプリも多いはずです。
仕組み
運営者が設定タブを開く
│
▼
GameVox ──── APP_SETTINGS_REQUEST (action: "describe") ────▶ あなたのボット
│
あなたの ─── POST /applications/@me/settings-response ──────────┘
{ nonce, version: 2, sections: [ ... ] }
│
▼
GameVox がフォームを描画し、運営者が編集して保存をクリック
│
▼
GameVox ──── APP_SETTINGS_REQUEST (action: "save", values) ─▶ あなたのボット
│
あなたの ─── POST /applications/@me/settings-response ──────────┘
{ nonce, message: "保存しました。" } アクションは 2 つ、リクエストの形は 1 つ、応答のエンドポイントも 1 つです。describe は「このサーバー向けにアプリが何を提供するか」を尋ね、save は運営者が変更した内容を返します。どちらも同じ方法で応答します。
1. リクエストを受け取る
GameVox は、アプリの接続を保持しているセッションに向けて APP_SETTINGS_REQUEST をゲートウェイ経由で配信します。Intent による制御はありません。この配信はアプリ宛に直接送られ、Intent マスクでフィルタされないため、どの Intent で identify していても届きます。
{
"op": 0,
"t": "APP_SETTINGS_REQUEST",
"d": {
"nonce": "5f3c1b0e-8a4d-4b2f-9c11-2f7a6d0b4e93",
"server_id": "1387452901234567890",
"action": "describe",
"schema_version": 2
}
} | フィールド | 型 | 備考 |
|---|---|---|
nonce | string | 中身は不透明です。そのまま返してください。待っている運営者と応答を結びつける唯一の手掛かりです。応答できるのは 15 秒間です。 |
server_id | snowflake | ギルドです。他のすべての配信と同じ ID 空間を使います。GUILD_CREATE で届いた ID と同じなので、既存のギルドキャッシュでそのまま解決できます。 |
action | string | "describe" または "save"。 |
schema_version | integer | このサーバーが描画できる最も新しいスキーマです。現在は 2。古いサーバーへの対応をご覧ください。 |
values | object | save のときだけ含まれます。キーはあなたが送ったフィールドキーです。 |
server_id は、運営者自身の認証済みリクエストをもとに GameVox が付与します。応答から読み戻すことはないため、あるサーバーについての問い合わせに別のサーバーの設定で答えることはできません。
ライブラリで配信を読む
Discord に存在しないイベントのハンドラーを備えたボットライブラリはないため、パケットはイベントハンドラーに届く前に破棄されます。どのライブラリも、まさにこうした場合のために生の配信ストリームを公開しています。
| ライブラリ | フック | 有効化の方法 |
|---|---|---|
| discord.js (v14) | client.on('raw', packet) | 既定で有効。パケットが処理される前に、すべての配信について発火します。 |
| discord.py (v2) | on_socket_raw_receive(msg) | クライアントに enable_debug_events=True を渡します。 |
| Eris | client.on('rawWS', packet) | 既定で有効。 |
| JDA | RawGatewayEvent | JDABuilder.setRawEventsEnabled(true) |
| DSharpPlus | DiscordClient.UnknownEvent | 既定で有効。EventName と生の Json が渡されます。 |
| serenity | RawEventHandler::raw_event | ClientBuilder::raw_event_handler |
本物の Discord ではこのハンドラーは発火しないため、同じビルドを分岐なしで両方のプラットフォームで動かせます。
2. 応答する
ゲートウェイではなく REST で応答します。どのライブラリも HTTP クライアントを備えている一方、アプリケーションコードから任意のオペコードをソケットに書けるものはほとんどありません。応答をこの形にしているのはそのためです。
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": "フォームの上に表示される任意のメモ。",
"sections": [
{
"key": "welcome",
"label": "ようこそメッセージ",
"description": "誰かが参加したときに投稿されます。",
"fields": [
{ "key": "welcome.enabled", "label": "ようこそメッセージを送る",
"type": "boolean", "value": true }
]
}
]
} 応答に成功すると 204 No Content が返ります。ギルド ID を付けて ?server_id= を追加できますが、これはアプリのアクティビティログで呼び出しを紐づけるためだけに使われます。
| キー | 型 | 備考 |
|---|---|---|
nonce | string | 必須。リクエストのものをそのまま使います。 |
version | integer | 応答に使うスキーマです。sections を送るときは 2 を指定してください。 |
sections | array | スキーマ v2 のフォームです。fields とは併用できません。 |
fields | array | スキーマ v1 のフラットなフォームです。見出しのない 1 セクションとして描画されます。 |
message | string | describe ではフォーム上部のメモ、save では確認トーストになります。200 文字で切り詰められます。 |
error | string | フォームの代わりに運営者へ表示されます。300 文字で切り詰められます。 |
失敗した場合も必ず応答してください。 黙って握りつぶすと、GameVox の 12 秒のタイムアウトまで運営者はスピナーを見続けることになり、「一度失敗した」ではなく「このアプリは壊れている」と受け取られます。
3. フィールドの種類
種類は 12 個で、この一覧は固定です。未知の type は、何も描画されないのではなく応答全体が拒否されます。
| 型 | コントロール | 送信する value | 保存時に返ってくるもの |
|---|---|---|---|
string | 1 行のテキストボックス | string | string(空のときは "") |
text | テキストエリア | string | string |
boolean | トグルスイッチ | boolean | true / false |
number | 数値入力 | number | number、クリアされた場合は null |
select | options のドロップダウン | オプションの値 | string、未設定の場合は null |
multiselect | options のチェックリスト | オプション値の配列 | 文字列の配列(空の場合もあります) |
channel | このサーバーのチャンネルのドロップダウン | チャンネルの Snowflake | Snowflake 文字列、または null |
channels | このサーバーのチャンネルのチェックリスト | Snowflake の配列 | Snowflake 文字列の配列 |
role | このサーバーのグループのドロップダウン | ロールの Snowflake | Snowflake 文字列、または null |
roles | このサーバーのグループのチェックリスト | Snowflake の配列 | Snowflake 文字列の配列 |
color | カラースウォッチ | #rrggbb | #rrggbb。常に値があります |
static | 読み取り専用のテキスト行 | string | 送信されません — キーは values に含まれません |
カラー入力には常に値があるため、色を保存していなかったアプリは初回の保存で #000000 を受け取ります。「色なし」を表したい場合は、スウォッチと boolean を組み合わせてください。
4. フィールドの属性
| 属性 | 対象 | 効果 |
|---|---|---|
key | すべて | 必須。[A-Za-z0-9][A-Za-z0-9._:-]{0,63}。セクション単位ではなく応答全体で一意である必要があります。 |
label | すべて | 省略時はキーが使われます。100 文字で切り詰められます。 |
help | すべて | コントロールの下に出るヒント行です。200 文字で切り詰められます。 |
placeholder | テキスト入力、number | 100 文字で切り詰められます。 |
options | select、multiselect | この 2 つでは必須です。{ value, label, description } を最大 25 個。他の種類では無視されます。 |
min , max , step | number | 入力の形を整えます。あくまで補助なので、受け取った値は再確認してください。 |
max_length | string、text | 入力を制限します(その種類のプラットフォーム上限まで)。 |
channel_kinds | channel、channels | ピッカーを絞り込みます。text、voice、forum、news、fileshare、header。未知の種類は無視され、結果が空の場合はすべての種類を意味します。 |
required | すべて | ラベルに印を付け、ピッカーの「なし」の項目を「選択…」に置き換えます。表示上の扱いなので、保存時にご自身で必須チェックを行ってください。 |
disabled | すべて | コントロールをグレーアウトします。現在の値のまま送信はされます。 |
secret | string、text | 入力をマスクし、送出時に現在の値を空にします。他の種類では応答が拒否されます。 |
show_if | すべて | { key, equals }。同じ応答内の別フィールドがその値になるまで、このフィールドを隠します。 |
show_if は等価判定だけで、式も演算子もありません。参照するキーは同じ応答内の単一値フィールドである必要があり、そのフィールド自身のキーは指定できません。これらを満たさない条件は破棄され、フィールドは無条件で表示されます。equals には文字列、真偽値、数値、null を指定できます。
隠されたフィールドも送信されます。アプリが送った値を保持しているため、これを落とすとアプリ側からは運営者が値を消したように見えてしまいます。
5. チャンネルとロールのピッカー
ここは理解しておく価値があります。通常の役割分担が逆になっているからです。アプリは種類を指定するだけで、選択肢は GameVox が用意します。
チャンネルのフィールドに ID は含めません。{ "type": "channel" } と、必要なら channel_kinds のフィルターを送るだけです。GameVox がそのサーバー自身のチャンネル一覧を添え、クライアントがそこからピッカーを描画するため、運営者は GameVox が用意した選択肢しか送信できません。
カタログの各項目のキーは、そのチャンネルやグループについてアプリがすでに見ているのと同じ Snowflake です。取り違えるような ID 変換の工程はありません。運営者はラベルを選び、アプリは自分の名前空間の ID を受け取り、このサーバー外を指す値はどの選択肢にも一致しません。
{ "key": "ignored", "type": "channels", "channel_kinds": ["text", "news", "forum", "fileshare"] } だけです。一覧も、種類の記号も、ID も GameVox のものです。| 状況 | 運営者に見えるもの |
|---|---|
| まだ何も保存されていない | 「なし」の項目が選択された状態になります。required のフィールドでは代わりに「選択…」が表示されるため、誰も選んでいない値がそのまま保存されることはありません。 |
| 保存された ID がもう存在しない(チャンネルが削除された) | 「現在の選択(一覧にありません)」として選択されたまま残ります。保存しても黙って消えることはありません。 |
| 複数選択のピッカーで、保存された ID が一覧に見当たらない | 同じ理由で、現在の項目と並べて表示・チェックされます。 |
| カタログを取得できない(セルフホストのサーバーがオフライン) | ピッカーは無効化され「現在利用できません」と表示されます。保存時には保存済みの値がそのまま返るため、障害中にパネルを開いても設定が消えることはありません。 |
| サーバーのチャンネルまたはグループが 500 個を超えている | 数 MB 分の選択肢を送る代わりに、一覧は 500 件で打ち切られます。 |
ロールのカタログからは 2 つが除かれます。どのアプリにも割り当て可能なロールとして提示すべきでないサーバーオーナーのグループと、インストール済みアプリ自身の権限を持つアプリごとのグループです。
6. シナリオ
シナリオ 1 — 最小限の実用的なパネル
トグル 1 つ、セクションなし。フラットな fields 配列はどのスキーマバージョンでも有効で、見出しのない 1 グループとして描画されます。
{
"nonce": nonce,
"fields": [
{ "key": "greetings", "label": "新しいメンバーを歓迎する", "type": "boolean", "value": true }
]
} シナリオ 2 — セクションにまとめる
{
"nonce": nonce,
"version": 2,
"sections": [
{
"key": "general",
"label": "一般",
"description": "このサーバーでのボットの振る舞い。",
"fields": [
{ "key": "prefix", "label": "コマンドの接頭辞", "type": "string",
"value": "!", "max_length": 4, "help": "従来のテキストコマンドで使います。" }
]
},
{
"key": "logging",
"label": "監査ログ",
"fields": [
{ "key": "log.enabled", "label": "モデレーション操作を記録する", "type": "boolean", "value": false }
]
}
]
} セクションの key は任意ですが、指定する場合は一意である必要があります。label と description はどちらも任意で、フィールドのないセクションは描画されません。
シナリオ 3 — チャンネルを尋ねる
ID も、ギルドのチャンネル一覧の取得も不要です。メッセージを投稿できる種類にピッカーを絞ってください。
{
"key": "log.channel",
"label": "ログチャンネル",
"type": "channel",
"value": stored.logChannelId ?? null,
"channel_kinds": ["text", "news"],
"help": "モデレーション操作を記録する場所です。"
} 投稿先ではなくカテゴリを選ばせたい場合は "channel_kinds": ["header"] を使ってください。ヘッダーが GameVox のカテゴリにあたります。
シナリオ 4 — 条件付きでフィールドを表示する
よくある形です。1 つの真偽値が、その機能の残りを制御します。
"fields": [
{ "key": "welcome.enabled", "label": "ようこそメッセージを送る",
"type": "boolean", "value": true },
{ "key": "welcome.channel", "label": "チャンネル", "type": "channel",
"value": stored.welcomeChannel ?? null,
"channel_kinds": ["text", "news"],
"show_if": { "key": "welcome.enabled", "equals": true } },
{ "key": "welcome.message", "label": "メッセージ", "type": "text",
"value": stored.welcomeMessage ?? "",
"max_length": 1800,
"placeholder": "{user} さん、{server} へようこそ!",
"help": "{user}、{server}、{membercount} は送信時に置き換えられます。",
"show_if": { "key": "welcome.enabled", "equals": true } }
] 条件は select を対象にもできます。モードの切り替えはこう作ります: "show_if": { "key": "mode", "equals": "advanced" }。
シナリオ 5 — 独自の選択肢
select と multiselect は、自前の選択肢を持つ 2 つの種類です。どちらも最低 1 個、最大 25 個までです。
{
"key": "automod.action",
"label": "フィルターに一致したとき",
"type": "select",
"value": stored.action,
"required": true,
"options": [
{ "value": "delete", "label": "メッセージを削除" },
{ "value": "warn", "label": "投稿者に警告", "description": "メッセージを削除し、そのメンバーに DM を送ります。" },
{ "value": "timeout", "label": "投稿者をタイムアウト", "description": "10 分間。" },
{ "value": "none", "label": "何もしない" }
]
} {
"key": "automod.filters",
"label": "有効なフィルター",
"type": "multiselect",
"value": stored.filters,
"options": [
{ "value": "invites", "label": "Discord / GameVox の招待" },
{ "value": "links", "label": "リンク" },
{ "value": "mentions", "label": "大量メンション" },
{ "value": "caps", "label": "過剰な大文字" }
]
} オプションの description は、ドロップダウンではラベルの後に、チェックリストではツールチップとして表示されます。
シナリオ 6 — ロール
チャンネルと同じカタログの仕組みです。GameVox がこのサーバーのグループをランク順に、色付きで一覧します。
"fields": [
{ "key": "autorole.role", "label": "参加時に付与するロール",
"type": "role", "value": stored.autoRole ?? null },
{ "key": "moderator.roles", "label": "モデレーションコマンドを使えるロール",
"type": "roles", "value": stored.modRoles,
"help": "これらのいずれかを持つメンバーが /ban と /timeout を実行できます。" }
] シナリオ 7 — 範囲付きの数値
{
"key": "automod.threshold",
"label": "レイドと判定するまでのメッセージ数",
"type": "number",
"value": stored.threshold ?? 10,
"min": 3,
"max": 100,
"step": 1,
"help": "60 秒のスライディングウィンドウで数えます。"
} min、max、step はコントロールの見た目を整えるだけです。運営者のブラウザはあなたが管理するバリデーターではないので、戻ってきた値は改めて丸めてください。なお、入力欄を空にすると 0 ではなく null が送信されます。
シナリオ 8 — シークレット
資格情報に secret を付けると、GameVox は送出時に現在の値を空にします。WebSocket のフレームにも DOM にも、パネルのスクリーンショットにも含まれません。運営者にはプレースホルダーが「変更なし」となった空のマスク入力が見えます。
{
"key": "integrations.apiKey",
"label": "天気 API のキー",
"type": "string",
"secret": true,
"help": "空のままにすると現在のキーを維持します。"
} ここから導かれる約束事があります。空で送られたシークレットは「変更しない」であって、「消す」ではありません。資格情報を削除できるようにしたい場合は、そのための真偽値フィールドを別に用意してください。string と text 以外に secret を付けると応答は拒否されます。
シナリオ 9 — 読み取り専用のステータス
static はテキスト行を描画するだけで、送信されることはありません。設定作業では必要だが、ここでは編集できない情報に使ってください。
"fields": [
{ "key": "plan", "label": "プラン", "type": "static", "value": "Pro — 1 日 4,000 回の参照" },
{ "key": "usage", "label": "本日の使用量", "type": "static", "value": "1,284 回の参照" },
{ "key": "lastSync", "label": "最終同期", "type": "static", "value": "2026-09-02 14:31 UTC" }
] シナリオ 10 — 保存を処理する
保存リクエストには、あなたが送ったフィールドキーをキーとする values が含まれます。保存したうえで、確認メッセージを返してください。
// action === "save"
const v = req.values ?? {};
const guild = client.guilds.cache.get(req.server_id);
if (!guild) return reply(nonce, { error: "このボットはもうそのサーバーにいません。" });
const patch = {};
// boolean: 常に本物の真偽値です
patch.welcomeEnabled = v["welcome.enabled"] === true;
// channel: Snowflake 文字列または null。この ギルド に対して必ず再確認してください
const chan = v["welcome.channel"];
patch.welcomeChannel =
(typeof chan === "string" && guild.channels.cache.has(chan)) ? chan : null;
// number: 数値、または入力欄がクリアされた場合は null
const n = v["automod.threshold"];
patch.threshold = typeof n === "number" ? Math.min(100, Math.max(3, Math.round(n))) : 10;
// secret: 空は「変更なし」を意味します
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: "設定を保存しました。" }); あなたの message が運営者への確認トーストになります。フォームはそのままの状態で残るので、続けて編集できます。
シナリオ 11 — 保存を拒否する
error を返すと、確認メッセージの代わりにあなたのテキストが表示されます。受け入れられないものすべてに使ってください。独自バリデーションに通らない値、プランの上限、使えなくなった外部の資格情報などです。
// `required` は表示上の扱いです。ここで実際に強制してください。
const action = v["automod.action"];
if (!["delete", "warn", "timeout", "none"].includes(action)) {
return reply(nonce, guildId, {
error: "フィルターに一致したときの AutoMod の動作を選んでください。",
});
}
// あなたの側でしか判断できないこと。
if (patch.apiKey && !(await upstream.verifyKey(patch.apiKey))) {
return reply(nonce, guildId, {
error: "その API キーは天気サービス側で拒否されました。",
});
} describe でも同様です。データベースが落ちているなら、応答しないのではなく error で応答してください。「このアプリは今、設定を読み取れませんでした」の方が、12 秒間のスピナーよりはるかにましです。
シナリオ 12 — 古いサーバーへの対応
リクエストには schema_version が含まれるので、試行錯誤する必要はありません。そのサーバーが実際に描画できる、最も表現力の高いフォームを返してください。
const version = typeof req.schema_version === "number" ? req.schema_version : 1;
if (version >= 2) {
await reply(nonce, { version: 2, sections: describeV2(guildId) });
} else {
// v1: フラットなリスト、4 種類 — string, boolean, number, select
await reply(nonce, { fields: describeV1(guildId) });
} 逆方向も同じです。v2 の応答が古いクライアントに届いても描画はされます。GameVox が sections と並べてフラット化した fields 配列を送るため、新しい種類を知らないクライアントはそれらをテキストボックスとして描きます。表現は落ちますが、何かが隠れることはありません。
シナリオ 13 — セルフホストサーバー
そのまま動きます。セルフホストのサーバーでは、チャンネルとロールのカタログをクラウドのテーブルではなく顧客のサーバーから取得しますが、これはアプリからは見えません。フィールドの種類も、Snowflake も、応答も同じです。
観察できる違いは 1 つだけです。サーバーに到達できない場合、ピッカーは空で届き「現在利用できません」と表示されます。保存時にはアプリの保存済みの値がそのまま返るため、何も失われません。空のピッカーを「運営者が値を消した」と解釈しないでください。
7. GameVox が検証するもの、しないもの
GameVox は保存内容の形を検証し、その意味は明示的に検証しません。描画したフォームを保持していないためです(あなたの設定を一切保存しないことがこの仕組みの要点です)。保存が届く時点で、どのキーがチャンネルピッカーで、どれが自由入力だったのかを知っているものは私たちの側に何もありません。
| GameVox が保証すること | あなたが確認すべきこと |
|---|---|
キーが文字種の規則に従い、__proto__、constructor、prototype ではないこと | そのキーが、実際にあなたが送ったものであること |
値が null、真偽値、数値、文字列、または文字列の配列であること(入れ子のオブジェクトにはなりません) | 型が、あなたが宣言したフィールドと一致していること |
| 文字列に上限があり(2,000 文字)、配列の要素は最大 25 件であること | あなた自身のより厳しい制限 |
| すべてのピッカーの ID が、このサーバーのカタログから GameVox が発行したものであること | その ID がこのギルドでまだ有効であること(カタログはスナップショットなので、描画から保存までの間にチャンネルが削除されることがあります) |
| 運営者がサーバーのオーナーか「サーバーを管理」権限を持ち、アプリがここにインストールされていること | あなた自身の認可(プランのティア、連携済みアカウントなど) |
これはパネルが持ち込んだ穴ではありません。運営者はもともと、アプリ自身のダッシュボードにどんな内容でも POST できます。これは仕様であり、ピッカーの種類が、アプリに ID の意味を委ねるのではなく GameVox が発行した ID を渡す理由そのものです。
8. 上限
ここには 2 つの挙動があり、その違いが重要です。件数と構造の違反は応答全体を拒否するので気づけます。一方、テキストの上限は黙って切り詰めるので、運営者が崩れたレイアウトを目にすることはありません。
| 上限 | 値 | 超えた場合 |
|---|---|---|
| 1 応答あたりのセクション数 | 12 | 拒否 |
| 1 応答あたりのフィールド数(セクションごとではなく合計) | 60 | 拒否 |
select / multiselect のオプション数 | 1〜25 | 拒否 |
| 複数値で選択できる件数 | 25 | 拒否 |
| フィールド / セクションのキー | 64 文字 | 拒否 |
| オプションの値 | 64 文字 | 拒否 |
| 応答のボディ | 256 KB | 413 |
| ラベル、プレースホルダー | 100 文字 | 切り詰め |
| ヒントのテキスト | 200 文字 | 切り詰め |
| セクションの説明 | 300 文字 | 切り詰め |
message | 200 文字 | 切り詰め |
error | 300 文字 | 切り詰め |
string 系フィールドの値 | 1,000 文字 | 切り詰め |
text / static フィールドの値 | 2,000 文字 | 切り詰め |
| カタログ内のチャンネルまたはロール | 500 | 打ち切り |
拒否は運営者に 「このアプリは GameVox が読み取れない設定を送信しました: <理由>」 と表示され、超えた上限とその原因になったフィールドが示されます。キーの重複、未知の種類、オプションのない select、sections と fields の同時送信も同じ扱いです。
拒否せず黙って補正されるもの
- 型に合わない現在の
valueは空になります。古い値はフィールド 1 つを空にすべきで、パネル全体を落とすべきではありません。 - それを使わない種類に付いた
optionsは無視されます。 - 数値以外のフィールドに付いた
min/max/stepは無視されます。 - 未知の
channel_kindsの項目は無視されます。 - 未知のキーや自分自身、複数値のフィールドを指す
show_ifは無視され、そのフィールドは無条件で表示されます。
9. タイミングと失敗時の挙動
| 挙動 | 値 | 何が起きるか |
|---|---|---|
| 応答の猶予 | 12 秒 | 運営者には「このアプリは応答しませんでした。アプリ内設定に対応していない可能性があります。」と表示されます。 |
| nonce の有効期間 | 15 秒 | 遅れた応答は誰にも届けられず破棄されます。期限切れの nonce で応答すると 404 が返ります。 |
| クールダウン | アクション・アプリ・サーバーごとに 2 秒 | 「少し待ってからもう一度お試しください。」describe と save は別々のウィンドウなので、パネルを開いてもすぐの保存が妨げられることはありません。 |
| アプリがオフライン | — | 事前に拒否されます:「このアプリはオフラインのため、今は設定できません。」配信は試みられません。 |
| 別のアプリが応答した | — | 403。nonce は発行されたアプリケーションに紐づいています。 |
| 応答の重複 | — | 最初の応答が採用され、2 つ目は何もしません。 |
すべてのリクエストはあなたのアプリへのゲートウェイ配信になるため、クールダウンは、運営者が GameVox を使って第三者のボットを叩き続けられないようにするために存在します。describe ハンドラーはページを開くたびに動き、データベース参照も想定内ですが、12 秒が上限だと考えて設計してください。
10. 完全な例(discord.js)
ストレージ層以外はそのまま使えます。リファレンス実装と同じ形です。
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) {
// 失敗しても必ず応答する — 黙って落とすと壊れたアプリに見えます。
await reply(nonce, guildId, {
error: "このサーバーの設定を読み取れませんでした。",
}).catch(() => undefined);
}
}
async function describe(guildId) {
const cfg = await store.get(guildId);
return [
{
key: "general",
label: "一般",
description: "このサーバーでのボットの振る舞い。",
fields: [
{ key: "prefix", label: "コマンドの接頭辞", type: "string",
value: cfg.prefix, max_length: 4 },
{ key: "ignored", label: "除外するチャンネル", type: "channels",
value: cfg.ignored, channel_kinds: POSTABLE,
help: "これらのチャンネルではコマンドに応答しません。" },
],
},
{
key: "welcome",
label: "ようこそメッセージ",
description: "誰かが参加したときに投稿されます。",
fields: [
{ key: "welcome.enabled", label: "ようこそメッセージを送る",
type: "boolean", value: cfg.welcome.enabled === true },
{ key: "welcome.channel", label: "チャンネル", type: "channel",
value: cfg.welcome.channel ?? null, channel_kinds: POSTABLE,
show_if: { key: "welcome.enabled", equals: true } },
{ key: "welcome.message", label: "メッセージ", type: "text",
value: cfg.welcome.message ?? "", max_length: 1800,
placeholder: "{user} さん、{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 "設定を保存しました。";
} 11. 公開前のチェックリスト
- フィールドのキーが安定していること。名前を変えると、運営者がすでに設定した内容が孤立します。
- ピッカー由来の ID は、書き込む前に必ずギルドに対して再確認していること。
- 空の
secretは「変更なし」として扱い、「消去」とは解釈しないこと。 describeの処理が、データベース参照を含めても 12 秒を大きく下回ること。- どの失敗経路でも必ず応答すること — 沈黙ではなく
errorを返すこと。 requiredとmin/maxを保存時にも改めて強制すること。- 空のピッカーは、カタログを取得できなかったことを意味し、運営者が消したという意味ではないこと。
- ハンドラーが Discord 上では何もしないこと。1 つのビルドで両方に対応できます。
パネルを開けるのは誰か
サーバーのオーナー、またはサーバーを管理権限を持つメンバーです。アプリのインストールとアンインストールを制御するのと同じ条件です。セルフホストのサーバーでは顧客のサーバー側で判定するため、クラウド側の権限行がないことを理由に、そこの管理者が拒否されることはありません。
アプリがそのサーバーにインストールされていない場合、GameVox はリクエストをその場で拒否します。したがってパネルのリクエストは、運営者がすでに認可したアプリにしか届きません。