개발자 포털
실험적 봇 및 앱 플랫폼은 활발히 개발 중입니다. 자체 호스팅 서버 지원은 2026-08-13에 출시되었으며 클라우드보다 기능 범위가 좁습니다. 지원 항목 보기.
← 문서

앱 내 설정

GameVox는 앱의 설정 폼을 클라이언트 안에서 렌더링할 수 있습니다. 앱이 게이트웨이로 폼을 기술하면 운영자가 서버 설정 ▸ 통합 ▸ 설정에서 편집하고, 편집된 값이 그대로 봇으로 돌아옵니다. GameVox는 아무것도 저장하지 않으며, 설정의 소유자는 오직 여러분의 앱입니다.

Discord에는 이에 대응하는 기능이 없습니다. GameVox 앱 플랫폼에서 의도적으로 호환하지 않는 유일한 부분인데, 맞출 대상이 반대편에 없기 때문입니다.

GameVox 클라이언트의 서버 설정 창. 설치된 앱의 설정 탭에 언어 드롭다운, 토글, 채널 체크리스트가 보입니다.
실제 패널이며, 봇 하나의 응답만으로 전부 렌더링되었습니다. select, boolean, channels 선택기가 설명이 있는 섹션으로 묶여 있습니다. 봇은 채널 id를 보내지 않았고, 목록은 GameVox가 채운 것입니다. 서버와 채널 이름은 가렸습니다.

이 기능이 있는 이유

Discord에서는 설정이 조금이라도 있는 봇이면 웹 대시보드를 함께 만들게 됩니다. 도메인, OAuth2 로그인, 세션 관리, 사용자의 길드 목록을 다시 읽는 권한 확인, CSRF 방어, 그리고 이 모든 것의 호스팅까지 — 대개는 운영자가 로그 채널을 고르고 기능 세 개를 켜고 끄게 하려는 것뿐입니다. GameVox는 운영자가 누구인지, 어떤 서버를 설정하는지, 그럴 권한이 있는지 이미 알고 있으므로 그 화면을 앱에 바로 제공합니다.

DiscordGameVox
운영자가 앱을 설정하는 곳 직접 호스팅하는 웹 대시보드 서버 설정 ▸ 통합 ▸ 설정
만들어야 하는 것 웹 앱, OAuth2 로그인, 세션, 권한 확인, 호스팅 게이트웨이 핸들러 하나와 REST 호출 하나
운영자를 인증하는 주체 여러분(OAuth2로) GameVox(앱에 묻기 전에 처리합니다)
설정을 저장하는 주체 여러분 여러분. GameVox는 아무것도 보관하지 않습니다.
채널·역할 선택기 길드의 채널과 역할을 가져와 직접 렌더링 종류만 지정하면 GameVox가 선택지를 제공
토글 하나를 노출하는 비용 대시보드 한 벌 스무 줄 남짓

이 기능이 무언가를 대체하지는 않습니다. 슬래시 명령어는 그대로 동작하고, 이미 대시보드가 있다면 유지해도 됩니다. 둘은 배타적이지 않으며, 실제로 자주 바뀌는 다섯 가지 설정에는 패널을, 나머지에는 대시보드를 쓰는 앱도 많을 것입니다.

동작 방식

운영자가 설정 탭을 엽니다
      │
      ▼
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: "저장했습니다." }

동작은 두 가지, 요청 형태는 하나, 응답 엔드포인트도 하나입니다. describe는 이 서버에서 앱이 무엇을 제공하는지 묻고, save는 운영자가 바꾼 내용을 돌려줍니다. 둘 다 같은 방식으로 응답합니다.

1. 요청 받기

GameVox는 앱의 연결을 유지하고 있는 세션으로 APP_SETTINGS_REQUEST를 게이트웨이를 통해 보냅니다. 인텐트로 제어되지 않습니다. 이 디스패치는 앱을 직접 대상으로 하며 인텐트 마스크로 걸러지지 않으므로, 어떤 인텐트로 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 클라이언트를 제공하는 반면, 애플리케이션 코드가 소켓에 임의의 opcode를 쓰게 허용하는 라이브러리는 드뭅니다. 응답을 이런 형태로 둔 이유입니다.

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의 평면 폼입니다. 제목 없는 한 개의 섹션으로 렌더링됩니다.
message string describe에서는 폼 위의 메모, save에서는 확인 토스트가 됩니다. 200자에서 잘립니다.
error string 폼 대신 운영자에게 표시됩니다. 300자에서 잘립니다.

실패해도 반드시 응답하세요. 조용히 무시하면 GameVox의 12초 타임아웃까지 운영자가 로딩 표시만 보게 되고, 이는 “한 번 실패했다”가 아니라 “이 앱은 고장 났다”로 읽힙니다.

3. 필드 유형

유형은 열두 가지이며 목록은 고정입니다. 알 수 없는 type은 아무것도 렌더링되지 않는 대신 응답 전체가 거부됩니다.

유형 컨트롤 보내는 value저장 시 돌려받는 값
string 한 줄 텍스트 상자 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 이 둘에는 필수입니다. { 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를 받으며, 이 서버 밖을 가리키는 값은 어떤 선택지와도 일치하지 않습니다.

channels 필드를 서버 자신의 채널 체크리스트로 렌더링한 모습. 각 항목 앞에 종류를 나타내는 기호가 붙어 있습니다.
이 필드에 대해 앱이 보낸 것은 { "key": "ignored", "type": "channels", "channel_kinds": ["text", "news", "forum", "fileshare"] }가 전부입니다. 목록도, 종류 기호도, id도 모두 GameVox의 것입니다.
상황운영자에게 보이는 것
아직 저장된 것이 없음 “없음” 항목이 선택된 상태로 표시됩니다. required 필드는 대신 “선택…”을 보여 주므로, 아무도 고르지 않은 값이 저장될 수 없습니다.
저장된 id가 더 이상 존재하지 않음(채널이 삭제됨) “현재 선택(목록에 없음)”으로 선택된 채 남습니다. 저장한다고 조용히 지워지지 않습니다.
다중 선택기에서 저장된 id가 목록에 없음 같은 이유로 현재 항목과 함께 표시되고 체크됩니다.
카탈로그를 가져올 수 없음(자체 호스팅 서버가 오프라인) 선택기가 비활성화되고 “지금은 사용할 수 없음”으로 표시됩니다. 저장 시 기존 값이 그대로 되돌아오므로, 장애 중에 패널을 열어도 설정이 지워지지 않습니다.
서버의 채널이나 그룹이 500개를 넘음 수 MB의 선택지를 보내는 대신 목록을 500개에서 자릅니다.

역할 카탈로그에서는 두 가지가 빠집니다. 어떤 앱에도 배정 가능한 역할로 제시해서는 안 되는 서버 소유자 그룹과, 설치된 앱 자신의 권한을 담고 있는 앱별 그룹입니다.

6. 시나리오

시나리오 1 — 가장 작은 실용적인 패널

토글 하나, 섹션 없음. 평면 fields 배열은 어떤 스키마 버전에서도 유효하며, 제목 없는 하나의 그룹으로 렌더링됩니다.

{
  "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는 선택 사항이지만, 지정한다면 고유해야 합니다. labeldescription은 모두 선택 사항이며, 필드가 없는 섹션은 렌더링되지 않습니다.

시나리오 3 — 채널 물어보기

id도, 길드 채널 목록 조회도 필요 없습니다. 메시지를 받을 수 있는 종류로 선택기를 좁히세요.

{
  "key": "log.channel",
  "label": "로그 채널",
  "type": "channel",
  "value": stored.logChannelId ?? null,
  "channel_kinds": ["text", "news"],
  "help": "조정 작업을 기록할 곳입니다."
}

게시할 곳이 아니라 카테고리를 고르게 하려면 "channel_kinds": ["header"]를 사용하세요. 헤더가 GameVox의 카테고리입니다.

시나리오 4 — 조건부로 필드 보이기

흔한 형태입니다. 불리언 하나가 그 기능의 나머지를 제어합니다.

"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 — 직접 만든 선택지

selectmultiselect는 자체 선택지를 가지는 두 유형입니다. 둘 다 최소 하나, 최대 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": "현재 키를 유지하려면 비워 두세요."
}

여기서 따라오는 약속이 있습니다. 비어 있는 시크릿은 “그대로 두기”이지 “지우기”가 아닙니다. 자격 정보를 지울 수 있게 하려면 그 용도의 불리언을 따로 제공하세요. stringtext 외의 유형에 secret을 쓰면 응답이 거부됩니다.

시나리오 9 — 읽기 전용 상태

static은 텍스트 한 줄을 렌더링하며 전송되지 않습니다. 설정할 때 필요하지만 여기서는 편집할 수 없는 정보에 사용하세요.

"fields": [
  { "key": "plan", "label": "요금제", "type": "static", "value": "Pro — 하루 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: 평면 목록, 네 가지 유형 — string, boolean, number, select
  await reply(nonce, { fields: describeV1(guildId) });
}

반대 방향도 성립합니다. v2 응답이 더 오래된 클라이언트에 도착해도 렌더링은 됩니다. GameVox가 sections와 함께 평탄화한 fields 배열을 보내므로, 새 유형을 모르는 클라이언트는 그것들을 텍스트 상자로 그립니다. 표현은 떨어지지만 감춰지는 것은 없습니다.

시나리오 13 — 자체 호스팅 서버

그대로 동작합니다. 자체 호스팅 서버에서는 채널과 역할 카탈로그를 클라우드 테이블이 아니라 고객의 서버에서 가져오지만, 앱에게는 보이지 않습니다. 필드 유형도, Snowflake도, 응답도 같습니다.

관찰할 수 있는 차이는 하나입니다. 서버에 도달할 수 없으면 선택기가 빈 채로 도착해 “지금은 사용할 수 없음”으로 표시됩니다. 저장할 때는 앱의 기존 값이 그대로 돌아오므로 잃는 것은 없습니다. 빈 선택기를 “운영자가 지웠다”로 해석하지 마세요.

7. GameVox가 검증하는 것과 하지 않는 것

GameVox는 저장의 형태를 검사하고, 그 의미는 명시적으로 검사하지 않습니다. 렌더링한 폼을 보관하지 않기 때문입니다(설정을 전혀 저장하지 않는 것이 이 구조의 핵심입니다). 저장이 도착하는 시점에는, 어떤 키가 채널 선택기였고 어떤 키가 자유 입력이었는지 저희 쪽에서 아는 것이 없습니다.

GameVox가 보장하는 것여전히 확인해야 하는 것
키가 문자 규칙을 따르고 __proto__, constructor, prototype이 아님 그 키가 실제로 여러분이 보낸 것인지
값이 null, 불리언, 숫자, 문자열, 또는 문자열 배열임(중첩 객체는 아님) 유형이 여러분이 선언한 필드와 맞는지
문자열에 상한이 있고(2,000자) 배열 요소가 최대 25개임 여러분 자신의 더 엄격한 제한
모든 선택기 id가 이 서버의 카탈로그에서 GameVox가 발급한 것임 그 id가 이 길드에서 여전히 유효한지(카탈로그는 스냅샷이라, 렌더링과 저장 사이에 채널이 삭제될 수 있습니다)
운영자가 서버 소유자이거나 “서버 관리” 권한을 가지고 있고, 앱이 여기에 설치되어 있음 여러분 쪽의 인가(요금제 등급, 연결된 계정 등)

이는 패널이 만들어 낸 허점이 아닙니다. 운영자는 원래도 앱 자신의 대시보드로 무엇이든 POST할 수 있습니다. 이것이 계약이며, 선택기 유형이 앱에 id의 의미를 맡기지 않고 GameVox가 발급한 id를 건네는 이유이기도 합니다.

8. 상한

여기에는 두 가지 동작이 있고 그 차이가 중요합니다. 개수와 구조 위반은 응답 전체를 거부하므로 알아챌 수 있고, 텍스트 상한은 조용히 잘라내므로 운영자가 깨진 화면을 볼 일이 없습니다.

상한초과 시
응답당 섹션 수 12 거부
응답당 필드 수(섹션별이 아니라 전체) 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, sectionsfields의 동시 전송도 같은 방식으로 거부됩니다.

거부하지 않고 조용히 바로잡는 것

  • 유형에 맞지 않는 현재 value는 비워집니다. 오래된 값은 필드 하나를 비워야지 패널 전체를 무너뜨려서는 안 됩니다.
  • 해당 유형이 쓰지 않는 options는 버려집니다.
  • 숫자가 아닌 필드에 붙은 min / max / step은 버려집니다.
  • 알 수 없는 channel_kinds 항목은 버려집니다.
  • 알 수 없는 키, 자기 자신, 다중 값 필드를 가리키는 show_if는 버려지고 필드는 조건 없이 표시됩니다.

9. 시간과 실패 상황

동작어떻게 되는지
응답 시간 12초 운영자에게 “이 앱이 응답하지 않았습니다. 앱 내 설정을 지원하지 않을 수 있습니다.”가 표시됩니다.
nonce 수명 15초 늦은 응답은 아무에게도 전달되지 않고 버려집니다. 만료된 nonce로 응답하면 404가 반환됩니다.
대기 시간 동작·앱·서버마다 2초 “앱에 잠시 시간을 준 뒤 다시 시도하세요.” describesave는 각각의 창을 쓰므로, 패널을 열었다고 해서 곧바로 저장하는 것이 막히지는 않습니다.
앱이 오프라인 미리 거부됩니다: “이 앱은 오프라인이라 지금은 설정할 수 없습니다.” 디스패치는 시도되지 않습니다.
다른 앱이 응답함 403. nonce는 발급된 애플리케이션에 묶여 있습니다.
중복 응답 첫 응답이 채택되고 두 번째는 아무 일도 하지 않습니다.

모든 요청은 앱으로 가는 게이트웨이 디스패치가 되므로, 대기 시간은 운영자가 GameVox를 이용해 제3자의 봇을 계속 두드리지 못하게 하려고 존재합니다. 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로.
  • requiredmin/max를 저장 시에도 다시 강제할 것.
  • 빈 선택기는 카탈로그를 가져오지 못했다는 뜻이지, 운영자가 지웠다는 뜻이 아님.
  • 핸들러가 Discord에서는 아무 일도 하지 않아, 하나의 빌드로 양쪽을 지원할 것.

패널을 열 수 있는 사람

서버 소유자, 또는 서버 관리 권한을 가진 구성원입니다. 앱 설치와 제거를 통제하는 것과 같은 기준입니다. 자체 호스팅 서버에서는 고객의 서버를 기준으로 확인하므로, 클라우드 쪽 권한 레코드가 없다는 이유로 그곳의 관리자가 거절되지는 않습니다.

앱이 그 서버에 설치되어 있지 않으면 GameVox가 요청을 즉시 거부하므로, 패널 요청은 운영자가 이미 인가한 앱에만 도달할 수 있습니다.

← Discord에서 마이그레이션  ·  문서로 돌아가기