앱 채널
앱 채널은 서버의 채널 목록에 있는 채널로, 그 내용은 앱이 온라인인 동안 제공하는 여러 화면입니다. 레이드 플래너, 랭크 래더, 티켓 창구, 서버 상태 페이지 같은 것을 만들 수 있습니다. GameVox는 채널의 위치, 볼 수 있는 사람, 할 수 있는 일을 저장합니다. 내용의 소유자는 여러분의 앱이며, 모든 화면을 요청이 올 때마다 렌더링합니다.
개요
앱은 여러 화면을 가진 하나의 공간입니다. 각 화면은 라우트이며, 앱이 정의하는 문자열입니다(home, raids, raids/42, roster?sort=ilvl). 채널을 연 구성원은 시작 라우트(보통 home)에 도착하고, 내비게이션 메뉴, 화면 안의 링크와 버튼, 뒤로·앞으로 버튼으로 이동합니다.
구성원에게 보이는 것
- 채널 목록에 앱 아이콘(또는 서버가 고른 아이콘)과 함께 채널이 표시되며, 앱이 오프라인인 동안에는 작은 점이 붙습니다.
- 여러분이 돌려준 화면 주위에 내비게이션 메뉴, 뒤로·앞으로, 홈 버튼, 새로 고침이 표시됩니다.
- 화면은 여러분의 JSON을 바탕으로 각 클라이언트가 자체 테마(라이트와 다크)로 네이티브하게 그립니다.
- 봇이 오프라인인 동안에는 마지막으로 푸시한 공개 스냅샷이 마지막 업데이트로 표시되고, 스냅샷이 없으면 오프라인 안내가 표시됩니다.
클라이언트: 데스크톱 앱(Windows, macOS, Linux), 웹 앱, iOS, Android. Xbox 앱은 앱 채널을 표시하지 않습니다. 앱 채널을 모르는 오래된 클라이언트에는 채널 목록에 앱 채널이 아예 전달되지 않으므로, 누구도 빈 텍스트 채널을 보지 않습니다.
슬래시 명령어·컴포넌트와의 차이
- 슬래시 명령어와 메시지 컴포넌트는 텍스트 채널의 메시지 안에 있고, 인터랙션 토큰과 함께
INTERACTION_CREATE로 앱에 도착합니다. 앱 채널에는 메시지가 전혀 없으며, 게시하려고 하면 거부됩니다. - 뷰 요청은 별도의 게이트웨이 이벤트
APP_VIEW_REQUEST로 도착하며, 일회용 nonce를 담은 REST 호출 한 번으로 응답합니다. 인터랙션 토큰도, 지연 응답도, 후속 응답도 없습니다. HTTP 인터랙션 엔드포인트는 쓰이지 않으므로 앱에는 살아 있는 게이트웨이 연결이 필요합니다. - 모든 화면은 보는 사람마다 따로입니다. GameVox는 구성원이 화면을 열거나 사용할 때마다 요청하며, 화면을 저장하지 않습니다(저장되는 것은 선택 사항인 공개 스냅샷뿐입니다).
- 화면 안에서는 앱이 어빌리티라는 자체 권한을 정의합니다. 서버 관리자가 그룹별로 부여하며, GameVox는 요청마다 보는 사람이 가진 어빌리티를 알려 줍니다.
- 이것은 Discord에 대응하는 기능이 없는 GameVox 확장입니다. Discord 라이브러리로 작성한 봇은 그대로 동작하며, 이 이벤트를 받지 않을 뿐입니다.
전체 흐름
구성원이 채널을 열거나, 이동하거나, 버튼을 누릅니다
│
▼
GameVox ──── APP_VIEW_REQUEST { nonce, kind, route, user, abilities } ────▶ 여러분의 봇
▲
│
여러분의 봇 ──── POST /applications/@me/app-view-response { nonce, view } ────▶ GameVox
│
▼
GameVox가 뷰를 검사하고 정리한 뒤 클라이언트가 그립니다 GameVox는 열 때마다, 액션마다 보는 사람의 채널 보기 권한을 확인하고 어빌리티를 계산한 다음에야 앱에 묻습니다. 응답은 그 한 사람에게만 전달됩니다.
수명 주기
- 서버가 평소 방식(설치 링크나 OAuth2 승인 페이지)으로 앱을 설치합니다.
- 봇은 시작할 때마다 매니페스트를 게시합니다. 매니페스트가 없는 앱은 앱 채널을 가질 수 없습니다.
- 서버 관리자가 채널 만들기 ▸ 앱을 고릅니다. 목록에는 매니페스트를 게시한 설치된 앱이 이름, 설명, 아이콘과 함께 표시됩니다. 채널 이름은 앱에서 채워지고, 선택 사항인 시작 화면 메뉴에는 내비게이션 항목이 나열됩니다(기본값: home). 채널을 만들려면 평소의 채널 생성 권한이 필요합니다. 봇이 직접 만들 수도 있습니다(아래 참고).
- 채널 설정에서 관리자는 채널 이름 변경, 아이콘 변경(앱 아이콘, 이모지, 업로드한 이미지), 시작 화면 변경, 그리고 권한 설정(채널을 볼 수 있는 사람과 각 어빌리티를 가진 그룹)을 할 수 있습니다.
- 채널을 볼 수 있는 구성원이 채널을 열면, 앱은 그들이 보는 모든 화면에 대해
APP_VIEW_REQUEST에 응답합니다. - 앱이 서버에서 제거되어도 앱 채널은 남아 있고(다시 설치하면 돌아옵니다) “이 앱은 서버에서 제거되었습니다”가 표시되며, 관리 권한이 있는 사람에게는 삭제 버튼이 보입니다. 애플리케이션이 삭제되었거나 매니페스트를 한 번도 게시하지 않았다면 채널에 “이 앱은 더 이상 사용할 수 없습니다”가 표시됩니다.
앱 채널도 다른 채널과 마찬가지로 서버의 채널 수 한도에 포함됩니다. 보통은 앱마다 채널 하나지만, 같은 앱의 두 번째 채널도 허용됩니다(예: 레이드 플래너로 열리는 레이드 팀 채널). 같은 앱이며 내비게이션도 같습니다. 모든 요청에 channel_id가 담기므로 채널마다 데이터를 따로 둘 수 있습니다.
봇에서 앱 채널 만들기
평소의 채널 생성 라우트에 type을 100으로 하고, 선택 사항인 gamevox_app 객체를 담아 호출합니다.
POST /api/v10/guilds/1491230000000000001/channels
Authorization: Bot <token>
Content-Type: application/json
{
"type": 100,
"name": "raid-planner",
"position": 7,
"gamevox_app": { "start_route": "raids", "icon": { "builtin": "calendar" } }
}
HTTP/1.1 201 Created
{
"id": "1491240000000000002",
"type": 100,
"gamevox_type": "app",
"name": "raid-planner",
"guild_id": "1491230000000000001",
"position": 7,
"gamevox_app": { "application_id": "1491200000000000000", "start_route": "raids" }
} - 자신의 애플리케이션에만 가능하며, 그 서버에 설치되어 있고 매니페스트를 게시한 상태여야 합니다. 봇에는 다른 채널과 마찬가지로 채널 생성 권한이 필요합니다.
gamevox_app.start_route는 라우트입니다(비어 있거나 없으면 매니페스트의home).gamevox_app.icon은emoji,builtin글리프, 또는 매니페스트에 이미 있는url중 하나입니다. 봇은 채널 이미지를 업로드할 수 없으며, 관리자만 할 수 있습니다.position이 없으면 채널은 목록 끝에 놓입니다. 이름은 1~100자입니다.- 채널을 볼 수 있는 사람과 각 어빌리티를 가진 사람은 서버 관리자가 채널 설정에서 정합니다.
앱 채널은 GameVox 확장이 붙은 일반 채널 객체이며, REST 응답과 게이트웨이 채널 이벤트에서 모두 같습니다.
type은 100이며, 의도적으로 Discord의 범위 밖에 두었습니다. 이 숫자를 모르는 라이브러리는 일반 채널로 취급할 수 있으므로, 확실하게 구분하려면gamevox_type: "app"을 쓰세요.GET /channels/{id},GET /guilds/{id}/channels,PATCH,DELETE /channels/{id}는 다른 채널과 똑같이 동작합니다.PATCH는gamevox_app: {"start_route", "icon"}도 받지만, 자신의 애플리케이션 채널에서만 가능하며 채널 편집 권한이 필요합니다.
매니페스트
앱마다 매니페스트 하나를 게시합니다. 이름, 아이콘, 홈 라우트, 내비게이션 메뉴, 어빌리티가 담깁니다. 시작할 때마다 PUT /applications/@me/app-manifest로 게시하세요. 저장된 것을 덮어쓰며, 여러 번 반복해도 안전합니다.
PUT /api/v10/applications/@me/app-manifest
Authorization: Bot <token>
Content-Type: application/json
{
"name": "Raid Planner",
"description": "Plan raids and sign up, right in a channel.",
"icon": { "builtin": "calendar" },
"home": "home",
"nav": [
{ "label": "Home", "route": "home", "icon": { "builtin": "home" } },
{ "label": "Raids", "route": "raids", "icon": { "builtin": "calendar" } },
{ "label": "Officers", "ability": "manage", "children": [
{ "label": "Roster", "route": "roster", "ability": "manage" }
] }
],
"abilities": [
{ "key": "signup", "label": "Sign up for raids", "default": "everyone" },
{ "key": "manage", "label": "Manage raids",
"description": "Create, edit and cancel raids", "default": "managers" }
]
} | 필드 | 비고 |
|---|---|
name | 필수, 최대 50자. name_localizations를 가질 수 있습니다. |
description | 최대 300자. description_localizations를 가질 수 있습니다. |
icon | 아이콘(아래 참고). |
home | 처음 열리는 라우트. 기본값 home. |
nav | 메뉴. 최상위 항목 최대 12개, 전체 최대 30개. |
abilities | 앱이 정의하는 권한 최대 25개. |
kind | layout(기본값)이어야 합니다. 다른 종류는 예약되어 있어 거부됩니다. |
storage | 선택 사항. 파일 업로드를 받는 앱에만 해당합니다(앱 파일 참고). 저장소를 선언하지 않은 앱은 업로드를 받지 않습니다. |
내비게이션 항목은 {label, route, icon?, ability?, children?}입니다. 라벨은 최대 40자입니다. children이 있는 항목은 그룹이며, 자체 route를 생략할 수 있고 그룹은 한 단계까지만 중첩됩니다. ability가 있는 항목은 그 어빌리티가 없는 사람에게 숨겨지며, 그 어빌리티는 abilities에 선언되어 있어야 합니다. 라우트는 ^[A-Za-z0-9._~/?=&%:-]{1,200}$와 일치해야 합니다.
어빌리티는 {key, label, description?, default}입니다. 키는 ^[a-z0-9_]{1,32}$와 일치해야 하고, 라벨은 최대 40자, 설명은 최대 200자이며, default는 everyone(채널을 볼 수 있는 모든 그룹), managers(매니저 그룹, 보는 사람별 컨텍스트 참고), nobody 중 하나입니다. 관리자는 앱 채널마다 그룹별로 기본값을 재정의할 수 있습니다.
아이콘은 다음 중 정확히 하나입니다. {"emoji": "…"}(이모지 하나, Twemoji로 그림), {"builtin": "…"}(GameVox 글리프: calendar, trophy, list, chart, shield, sword, map, users, clock, star, flag, book, gift, bolt, home, game), 또는 {"image_data": "<base64>"}(정사각형 PNG 또는 WebP, 16~2048픽셀, 최대 128 KB). GameVox는 image_data를 검사해 저장하고, 저장된 매니페스트에서 {"url": …}를 돌려줍니다. 직접 호스팅하는 URL은 구성원에게 절대 표시되지 않으므로 거부됩니다. GameVox에서 읽은 url 아이콘은 다시 보내도 됩니다.
응답은 200이며 저장된 매니페스트(기본값이 채워지고 이미지가 URL로 바뀐 것)가 담깁니다. 잘못이 있으면 필드를 짚어 주는 400이 반환됩니다(예: nav[2].route is not a valid route). 같은 매니페스트를 다시 게시해도 바뀌는 것은 없습니다. 매니페스트가 바뀌면 열려 있는 여러분의 모든 앱 채널이 새로 고쳐지므로, 아무도 다시 불러오지 않아도 새 메뉴가 나타납니다. GET /applications/@me/app-manifest는 저장된 내용을 반환하며, 한 번도 게시하지 않았다면 404를 반환합니다.
매니페스트 현지화
매니페스트에서 사람이 읽는 모든 텍스트는 Discord 슬래시 명령어가 name_localizations를 가지는 것처럼 번역을 가질 수 있습니다. 번역하는 텍스트 옆에 맵을 두세요. 일반 필드는 기본값으로 남으며, 맵에 보는 사람의 언어 항목이 없을 때 멤버에게 보이는 것이 이것입니다.
{
"name": "Raid Planner",
"name_localizations": { "de": "Raidplaner", "pt-BR": "Planejador de raids", "ja": "レイドプランナー" },
"nav": [
{ "label": "Roster", "route": "roster",
"label_localizations": { "de": "Aufstellung", "fr": "Effectif", "es-ES": "Plantilla" } }
],
"abilities": [
{ "key": "manage", "label": "Manage raids", "default": "managers",
"label_localizations": { "de": "Raids verwalten" },
"description": "Create, edit and cancel raids",
"description_localizations": { "de": "Raids erstellen, bearbeiten und absagen" } }
]
} - 매니페스트의
name_localizations와description_localizations(텍스트는 각각 최대 50자와 300자), 내비게이션 항목의label_localizations(최대 40), 어빌리티의label_localizations와description_localizations(각각 최대 40과 200). - 키는 Discord 로케일 코드입니다(
de,fr,pt-BR,es-ES,zh-CN,ja, 그리고 Discord 목록의 나머지). Discord에 코드가 없는 언어(ar,he,fa,ms,sk,et,lv,sr,sl,tl,ur,bn,ta,sw,ca)는 GameVox 언어 태그를 사용합니다. 키는 대소문자를 구분하고, 맵 하나에는 로케일을 최대 64개까지 넣을 수 있으며, 알 수 없는 키는 필드 이름을 알려 주는400이 됩니다. - 빈 값,
null, 기본 텍스트와 같은 값은 버려지므로null로 로케일을 지울 수 있습니다. 텍스트의 한도를 넘는 값은400입니다. 예:nav[2].label_localizations[de] is longer than 40 characters. - 어빌리티 키, 라우트, 아이콘은 현지화되지 않습니다. 뷰는 매니페스트에 속하지 않습니다. 요청의
locale을 사용해 보는 사람의 언어로 각 뷰를 렌더링하세요.
맵을 뺀 매니페스트는 기존대로 64 KB 한도를 유지합니다. 맵에는 그와 별도로 192 KB의 예산이 있어서 44개 언어의 매니페스트도 들어갑니다(문서 전체는 최대 256 KB). 맵이 없는 매니페스트는 전과 같이 64 KB까지입니다.
GameVox는 멤버마다 맵을 해석해 클라이언트에는 일반 문자열만 보냅니다. 언어는 멤버의 클라이언트가 마지막으로 보고한 것이며, 슬래시 명령어 설명의 언어를 정하는 것과 같습니다. 채널 헤더와 채널 만들기 목록의 앱 이름과 설명, 메뉴, 권한 편집기의 어빌리티 이름과 설명, 알림의 앱 이름에 적용됩니다. GET /applications/@me/app-manifest는 저장된 매니페스트를 맵과 함께 반환합니다.
요청과 응답
구성원이 채널을 열거나, 이동하거나, 새로 고치거나, 버튼을 누르면 봇은 APP_VIEW_REQUEST라는 게이트웨이 디스패치(opcode 0)를 받습니다. 인텐트는 필요 없습니다. 여러분 애플리케이션의 살아 있는 게이트웨이 세션에만 전달됩니다.
{
"op": 0,
"s": 812,
"t": "APP_VIEW_REQUEST",
"d": {
"nonce": "2f6c1a9e-4b7d-4c1e-9a53-0d8e7f6a5b41",
"kind": "action",
"guild_id": "1491230000000000001",
"channel_id": "1491240000000000002",
"route": "raids/42",
"params": { "raid": 42 },
"action": "signup",
"values": { "role": "healer", "note": "Late by 10 min" },
"user": {
"id": "1491250000000000003",
"username": "ben",
"display_name": "ben",
"avatar_url": "https://..."
},
"abilities": ["signup"],
"is_manager": false,
"locale": "en-US",
"platform": "desktop",
"self_hosted": false,
"plan": {
"key": "free",
"limits": {
"max_file_bytes": 8388608, "max_files_per_field": 4, "max_total_bytes": 524288000,
"max_uploads_per_user_per_day": 50, "kinds": [], "retention_days": 0, "max_video_bytes": 0
}
}
}
} | 필드 | 비고 |
|---|---|
nonce | 일회용입니다. 응답에 담아 돌려보내세요. |
kind | open(구성원이 route를 열었거나, 그곳으로 이동했거나, 새로 고친 경우) 또는 action(버튼을 눌렀거나 폼을 제출한 경우). |
guild_id, channel_id | 보고 있는 서버와 앱 채널(Snowflake). 채널별 데이터는 channel_id를 키로 쓰세요. |
route | 보여 줄 라우트. 비어 있는 경우는 없습니다. 클라이언트가 아무것도 보내지 않으면 채널의 시작 라우트, 그것도 없으면 home입니다. |
params | 여기로 오게 한 링크나 버튼의 params(일반 데이터, 최대 2 KB). 항상 객체입니다. |
action, values | kind: "action"일 때만. 액션 이름과 제출된 폼 값(일반 버튼이면 빈 객체). |
user | 보는 사람: id(Snowflake), username, display_name(현재는 사용자 이름과 같음), avatar_url(비어 있을 수 있음). |
abilities | 현재 보는 사람이 이 채널에서 가진 어빌리티입니다. 직접 검사해 적용해 주세요. |
is_manager | 서버 소유자, 관리자 그룹, 또는 “서버 편집”이나 “통합 관리” 권한이 있는 그룹이면 true. |
locale | 보는 사람의 클라이언트 언어(예: en-US나 de). 비어 있을 수 있습니다. 그 사람의 언어로 응답하는 데 쓰세요. |
platform | desktop, web, ios, android 중 하나. |
self_hosted | 채널이 자체 호스팅 서버에 있으면 true. |
plan | 이 서버에서 앱의 요금제: {key, limits}(요금제 참고). |
응답하기
POST /applications/@me/app-view-response를 한 번 호출해 응답합니다. 인증은 다른 모든 봇 호출과 같습니다(Authorization: Bot <token>).
POST /api/v10/applications/@me/app-view-response
Authorization: Bot <token>
Content-Type: application/json
{
"nonce": "2f6c1a9e-4b7d-4c1e-9a53-0d8e7f6a5b41",
"toast": "Signed up as healer",
"view": { "version": 1, "title": "Mythic raid", "route": "raids/42", "blocks": [ ... ] }
}
HTTP/1.1 204 No Content view는 보는 사람의 화면을 바꿉니다. 토스트만 필요한 액션이라면 빼세요. 보는 사람에게는 현재 화면이 그대로 남습니다.toast는 토스트로 표시되는 짧은 한 줄(최대 200자)입니다.view와 함께 보낼 수 있습니다.error(최대 300자)는 화면 대신 보는 사람에게 메시지를 보여 줍니다.error가 있으면view와toast는 무시됩니다.open_url은 평소의 외부 링크 확인을 거친 뒤httpsURL을 열도록 클라이언트에 요청합니다. 다른 스킴은 버려집니다.- 성공하면
204입니다. 뷰는 그 뒤에 검사되고 정리되므로(정리기가 하는 일 참고),204가 모든 필드가 살아남았다는 뜻은 아닙니다.
타이밍
- 보는 사람은 10초 동안 기다립니다. 그 뒤에는 “앱이 응답하지 않았습니다. 다시 시도하세요.”가 표시되고, 늦은 응답은 버려집니다. 넉넉히 그 안에 응답하세요. GameVox 봇 라이브러리는 요청마다 8초의 기한을 둡니다.
- nonce는 15초 동안, 한 번만 받아들여집니다. 두 번째 응답이나 만료 후의 응답에는
404가 반환됩니다. - 애플리케이션이 여러 게이트웨이 세션(샤드나 재시작 중 겹침)을 가지고 있다면 같은 요청을 둘 이상이 받을 수 있습니다. 한 번만 응답하세요. 첫 번째 응답만 인정됩니다.
- 게이트웨이 세션이 연결되어 하트비트를 보내는 동안 봇은 온라인으로 간주됩니다. 오프라인인 동안 GameVox는 봇에 묻지 않습니다. 열면 스냅샷이 표시되고, 액션을 하면 앱이 오프라인이라고 구성원에게 알려 줍니다.
뷰 스키마
뷰는 JSON 레이아웃 문서로, kind는 layout, version은 1입니다. 각 클라이언트가 네이티브하게 그립니다. 알 수 없는 필드는 무시되고 알 수 없는 블록 유형은 버려지므로, 아무것도 깨뜨리지 않고 새 선택 필드를 추가할 수 있습니다.
{
"version": 1,
"title": "Raids",
"subtitle": "Next 7 days",
"route": "raids",
"nav_active": "raids",
"tabs": [
{ "label": "Upcoming", "route": "raids" },
{ "label": "Past", "route": "raids?past=1" }
],
"actions": [
{ "label": "New raid", "route": "raids/new", "style": "primary", "ability": "manage" }
],
"refresh_seconds": 60,
"blocks": [
{ "type": "stats", "items": [
{ "label": "Signed up", "value": 18, "trend": "up" },
{ "label": "Open slots", "value": 2, "color": "warning" }
] },
{ "type": "countdown", "label": "Next pull", "until": "2026-11-02T19:00:00Z" },
{ "type": "table",
"columns": [
{ "key": "name", "label": "Raid", "sortable": true },
{ "key": "when", "label": "When", "kind": "time" },
{ "key": "lead", "label": "Lead", "kind": "user" },
{ "key": "fill", "label": "Filled", "kind": "progress" }
],
"rows": [
{ "id": "42",
"cells": { "name": "Mythic", "when": "2026-11-02T19:00:00Z",
"lead": "1491250000000000003", "fill": { "value": 18, "max": 20 } },
"action": { "route": "raids/42" } }
] },
{ "type": "text", "markdown": "Bring flasks. Questions to <@1491250000000000003>." }
]
} | 필드 | 비고 |
|---|---|
version | 1 또는 생략. 다른 버전이면 “이 화면을 보려면 더 새로운 버전의 GameVox가 필요합니다.”가 표시됩니다. |
kind | layout 또는 생략. 규칙은 같습니다. |
title | 최대 100자. 헤더에 표시됩니다. |
subtitle | 최대 200자. |
route | 이 화면이 보여 주는 라우트. 요청된 것과 달라도 됩니다(리디렉션). 클라이언트는 이를 기록에 남깁니다. 생략하면 GameVox가 요청된 라우트를 채웁니다. |
nav_active | 강조할 내비게이션 항목. |
breadcrumbs | {label, route?} 최대 5개. 라벨은 최대 100자. |
tabs | 하위 내비게이션 탭 {label, route, badge?} 최대 8개. 라우트가 일치하는 탭이 선택됩니다. 라벨은 최대 40자, 배지는 최대 20자. |
actions | 헤더 버튼 최대 4개(버튼 참고). |
refresh_seconds | 화면이 보이고 창에 포커스가 있는 동안 클라이언트가 이 간격으로 화면을 다시 엽니다. 15~3600초. |
blocks | 내용. 목록 하나에 최대 60개 블록, 뷰 전체에 최대 600개 블록. |
블록
모든 블록에는 type과 선택 사항인 id(최대 64자)가 있습니다. 제한은 비고에 적혀 있으며, 제한을 넘는 부분은 잘리거나 버려집니다.
| 유형 | 필드 | 비고 |
|---|---|---|
heading | text, level | 제목. 텍스트는 최대 200자, 수준은 1~3(기본값 2). |
text | markdown | 마크다운 문단, 최대 4,000자. |
callout | style: info | success | warning | error, markdown | 마크다운이 담긴 강조 상자. 스타일 기본값은 info. |
divider | 가로줄. | |
section | title, description, blocks, collapsible, collapsed | 선택 사항인 제목(100)과 설명(200)이 있는 블록 묶음으로, 접을 수 있게 만들 수도 있습니다. 컨테이너는 두 단계까지 중첩됩니다. |
columns | columns: [{width 1-12, blocks}] | 12칸 그리드 위에 나란히 놓는 열(최대 4열). 좁은 화면에서는 세로로 쌓입니다. |
stats | items: [{label, value, delta, trend, color, icon}] | 통계 타일(최대 8개). 값은 최대 100자, 변화량은 최대 30자, 추세는 up, down, flat. |
key_value | items: [{label, value, user}] | 라벨과 값 쌍(최대 30개). 값은 최대 500자. 행을 user만으로 두면 구성원 칩이 그려집니다. |
table | columns: [{key, label, align, sortable, kind}], rows: [{id, cells, action}], page_size, emptykind: text | number | user | badge | progress | time | icon | image | 표. 최대 10열, 200행이며 정렬과 페이지 나누기는 클라이언트가 합니다(page_size 1~100, 기본값 25). 셀 값은 열의 kind를 따르며 셀은 최대 500자입니다. id가 없는 행에는 자동으로 붙습니다. |
list | items: [{id, title, subtitle, user, icon, image, badges, meta, action, buttons}] | 목록(최대 100개 항목). 제목은 최대 200자, 항목마다 배지 최대 5개와 버튼 최대 5개. |
progress | label, value, max, color | 진행률 막대. max의 기본값은 100이며, 값은 0과 max 사이로 유지됩니다. |
countdown | label, until | RFC 3339 시각까지의 실시간 카운트다운. until이 없으면 버려집니다. |
timeline | items: [{time, title, markdown, icon, color}] | 타임라인(최대 50개 항목). 항목의 마크다운은 최대 500자. |
calendar | mode: month | week | agenda, start, events: [{id, title, start, end, color, action}] | 캘린더(최대 200개 이벤트). 좁은 화면에서는 기본적으로 일정 목록 보기가 됩니다. 이전·다음 버튼은 액션 calendar_nav를 보냅니다(액션 참고). |
chart | kind: bar | line, series: [{name, color, points: [{x, y}]}], x_label, y_label | 막대 또는 꺾은선 차트. 최대 5개 계열, 계열마다 100개 점. x는 숫자나 라벨. |
board | columns: [{id, title, cards: [{id, title, subtitle, user, image, badges, action}]}] | 열과 카드로 된 보드. 최대 6열, 열마다 카드 50장. 끌어서 놓기는 없습니다. |
image | url | file_id, alt, aspect | 이미지. https URL 또는 앱 파일. 대체 텍스트는 최대 200자, aspect는 "16:9"나 1.5 같은 값. |
video | file_id, poster_file_id, title | 동영상 앱 파일(요금제가 동영상을 허용하는 앱만, 앱 파일 참고). |
buttons | items: [Button] | 버튼 한 줄(최대 5개). |
form | id, fields: [Field], submit: Button | 폼(최대 15개 필드). 폼 참고. |
empty | title, text, icon | 빈 상태: 제목(100), 텍스트(200), 아이콘. |
블록 전반에 쓰이는 값
- 색상은
#rrggbb또는 토큰(accent,success,warning,danger,muted)입니다. 배지는{text, color?}(텍스트 최대 30자) 또는 그냥 문자열입니다. - 시각은 RFC 3339이며 보는 사람의 시간대로 표시됩니다. 캘린더의
start와end는YYYY-MM-DD날짜도 받습니다. - 구성원(
user)은 구성원 Snowflake입니다. 클라이언트가 아바타와 표시 이름을 그리고, 클릭하면 프로필이 열립니다. - 마크다운(
text,callout, 타임라인 항목)은 GameVox 채팅 마크다운입니다. HTML은 절대 렌더링되지 않습니다.<@id>,<@&id>,<#id>같은 멘션은 이름으로 그려지며 누구에게도 알림을 보내지 않습니다. 그 밖의 모든 텍스트 필드는 일반 텍스트입니다. - 이미지는 자격 정보가 없는
httpsURL이어야 하며 최대 2000자입니다. GameVox에는 이미지 프록시가 없으므로, GameVox 저장소에 없는 이미지는 유지되지만"external": true표시가 붙고, 클라이언트는 채팅의 서드파티 이미지처럼 다룹니다(리퍼러 없음, 쿠키 없음, 클릭해야 보일 수 있음). 앱 파일을 쓰면 이를 피할 수 있습니다.
새니타이저가 하는 일
뷰는 모든 구성원의 클라이언트에서 그려지는 서드파티 콘텐츠이므로, GameVox는 어떤 클라이언트가 보기 전에 허용 목록을 바탕으로 뷰를 다시 만듭니다.
- 알 수 없는 블록 유형과 알 수 없는 필드는 버려집니다.
- 텍스트에서 잘못된 UTF-8, 제어 문자, 양방향 재정의 문자가 제거되고, 상한에서 잘립니다.
- 목록은 최대 개수에서 잘립니다. 목록 하나에서 60개, 뷰 전체에서 600개를 넘는 블록은 버려집니다.
- 형식에 맞지 않는 색상, 라우트, 시각, id, 구성원 참조는 비워집니다.
- 필요한 것이 빠진 항목은 버려집니다. 제목 없는 목록 항목, 시각 없는 타임라인 항목, 라벨이나 대상이 없는 버튼,
id나submit.action이 없는 폼 등입니다. - 버튼과 행의
params는 일반 데이터(문자열, 숫자, 불리언, 목록, 객체. 중첩 5단계까지, 각각 50개 항목까지, 문자열 최대 500자, 전체 2 KB)여야 하며, 그렇지 않으면 버튼째 버려집니다. - 뷰 전체가 거부되는 경우는 JSON 객체가 아니거나, 256 KB(스냅샷은 128 KB)보다 크거나,
version이나kind가 다를 때뿐입니다. 이때 보는 사람에게는 “이 앱이 GameVox에서 표시할 수 없는 화면을 보냈습니다.”가 표시됩니다.
스냅샷 엔드포인트는 바뀐 내용을 나열한 warnings로 응답합니다. 실시간 응답은 그렇지 않습니다. Go 봇 라이브러리의 View.Validate()는 보내기 전에 같은 변경 사항을 나열해 줍니다.
액션과 인터랙션
버튼
버튼은 이동하거나(route가 있음) 앱에 액션을 보냅니다(action이 있음).
{
"label": "Cancel raid",
"action": "cancel",
"params": { "raid": 42 },
"style": "danger",
"ability": "manage",
"confirm": { "title": "Cancel this raid?", "text": "Everyone who signed up is told.", "confirm_label": "Cancel raid" }
} label: 필수, 최대 40자.action:^[A-Za-z0-9._:-]{1,64}$와 일치하는 이름. 누르면 이 이름과 버튼의params가 담긴kind: "action"이 도착합니다.route: 대신 이동합니다. 클라이언트가 그 라우트를 열고, 버튼의params가 담긴kind: "open"이 도착합니다. 둘 다 있으면route가 우선합니다.params: 최대 2 KB의 객체로, 작성한 그대로 돌려받습니다.style(primary,secondary,success,danger),icon,disabled.ability: 그 어빌리티가 없는 사람에게 버튼을 숨깁니다.confirm:{title, text, confirm_label}(80, 300, 40자). 액션을 보내기 전에 구성원에게 확인을 받습니다.
표의 행, 목록 항목, 보드 카드, 캘린더 이벤트는 같은 두 형태의 action 대상을 받습니다. {"action": "…", "params": …} 또는 {"route": "…", "params": …}입니다. 클라이언트는 누른 컨트롤에 처리 중 상태를 보여 주며, 같은 누름을 두 번 보내지 않습니다.
폼
form 블록에는 id(뷰 안에서 고유), 최대 15개의 fields, 그리고 submit 버튼이 있으며, 제출 버튼에는 action이 반드시 있어야 합니다. 제출하면 제출 버튼의 액션과 params, 그리고 필드 키를 키로 하는 필드 값이 values에 담긴 kind: "action"이 도착합니다.
{
"type": "form",
"id": "signup",
"fields": [
{ "key": "role", "type": "select", "label": "Role", "required": true,
"options": [
{ "value": "tank", "label": "Tank" },
{ "value": "healer", "label": "Healer" },
{ "value": "dps", "label": "Damage" }
] },
{ "key": "note", "type": "string", "label": "Note", "max_length": 100 }
],
"submit": { "label": "Sign up", "action": "signup", "params": { "raid": 42 }, "ability": "signup" }
} key:^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$와 일치하며 폼 안에서 고유.type:string,text,number,boolean,select,multiselect,channel,channels,role,roles,color,static,date,datetime,user(구성원 선택기) 또는file(앱 파일 참고).label(100),help(200),placeholder(100),required,disabled,min,max,step,max_length(최대 2,000), 그리고 미리 채울value.select와multiselect의options:{value, label?, description?}최대 25개. 라벨이 없으면 값이 표시됩니다.channel_kinds는 채널 선택기를 좁힙니다. 채널·역할 선택기에는 그 서버의 채널과 역할이 나열됩니다. GameVox가 채워 주므로 id를 보낼 필요가 없습니다.
받는 값: 문자열(최대 2,000자), 숫자, 불리언, 또는 최대 25개의 문자열 목록이며, 값은 최대 30개, 전체 32 KB까지입니다. 채널·역할 선택기는 Snowflake를, 구성원 선택기는 구성원의 Snowflake를, 파일 필드는 파일 객체 목록을 줍니다. GameVox는 형태만 확인합니다. required, min, max, 옵션 목록은 클라이언트가 강제하는 것이므로 모든 값을 직접 확인해 주세요.
캘린더 페이지 넘기기: calendar 블록의 이전·다음 버튼은 params {"start": "YYYY-MM-DD", "mode": "month|week|agenda", "id": "<block id>"}와 함께 액션 calendar_nav를 보냅니다. 그 범위를 보여 주는 캘린더가 담긴 뷰로 응답해 주세요.
화면을 최신으로 유지하기
- 뷰의
refresh_seconds(15~3600)는 화면이 보이는 동안 타이머에 맞춰 다시 엽니다. {"routes": [...]}를 담은POST /channels/{channel_id}/app-view/invalidate는 채널을 보고 있는 모두에게 화면이 오래되었다고 알립니다.routes를 빼면 모든 사람을 새로 고치고, 라우트를 최대 20개 나열하면 그 라우트를 보는 사람만 새로 고칩니다(raids는raids?past=1과도 일치합니다). 클라이언트는 짧은 무작위 지연(1~3초) 뒤에 다시 열기 때문에, 한꺼번에 몰려도 비용은 이벤트마다가 아니라 보는 사람마다 요청 한 번입니다. 채널마다 초당 5회까지는 바로 처리되고, 나머지는 1초 뒤의 한 번으로 합쳐집니다. 응답:204.{"view": {…}}를 담은PUT /channels/{channel_id}/app-view/snapshot은 채널을 볼 수 있는 모두에게 똑같은 공개 화면을 최대 128 KB까지 저장합니다. 봇이 오프라인인 동안 마지막 업데이트로 표시됩니다. 지우려면{"view": null}을 보내 주세요(응답{"cleared": true}). 응답은{"warnings": [...]}입니다. 채널마다 10초에 6번까지 푸시할 수 있습니다. 스냅샷에는 개인적인 것을 절대 넣지 말아 주세요.- 채널을 무효화하거나 스냅샷을 설정할 수 있는 것은 그 채널이 속한 애플리케이션뿐입니다.
POST /api/v10/channels/1491240000000000002/app-view/invalidate
{ "routes": ["raids", "raids/42"] }
HTTP/1.1 204 No Content
PUT /api/v10/channels/1491240000000000002/app-view/snapshot
{ "view": { "version": 1, "title": "Raids", "blocks": [ ... ] } }
HTTP/1.1 200 OK
{ "warnings": [] } 보는 사람별 컨텍스트
모든 요청은 한 사람을 기술하므로 화면을 개인화할 수 있습니다. “내 캐릭터”, “내 참가 신청”, 매니저만 보는 버튼 같은 것입니다.
- 신원:
user.id는 구성원의 Snowflake로, 봇이 게이트웨이 이벤트와 REST에서 보는 것과 같은 id입니다. 구성원별 데이터는 이 값으로 관리해 주세요. - 어빌리티: 각 어빌리티에 대해, 그 사람의 그룹 중 채널을 볼 수 있는 그룹 하나라도 그것을 허용하는 재정의를 가지고 있거나, 재정의가 없고 매니페스트 기본값이 그 그룹을 포함하면(
everyone: 그런 모든 그룹,managers: 매니저 그룹만,nobody: 없음) 그 사람은 어빌리티를 가집니다. 서버 소유자는 모든 어빌리티를 가집니다. 그룹이 없는 구성원은 서버의 기본 그룹으로 처리됩니다. - 매니저(
is_manager와managers기본값)는 서버 소유자, 관리자 그룹, 그리고 “서버 편집”이나 “통합 관리” 권한이 있는 그룹입니다. - 내비게이션은 보는 사람에 맞게 걸러져, 없는 어빌리티가 필요한 항목은 숨겨집니다. 이것은 배려일 뿐입니다. 버튼을 숨기는 것은 보안이 아니며, 변조된 클라이언트는 어떤 액션, 라우트, params든 보낼 수 있습니다.
- 언어:
locale은 보는 사람의 클라이언트 언어입니다. 모두를 위한 화면(스냅샷)은 이를 알 수 없으므로, 그때는 서버의 언어를 사용해 주세요. - 보내는 내용 속 구성원:
user필드, 구성원 표 셀, 구성원 선택기 값, 멘션에는 구성원 Snowflake를 써 주세요. GameVox가 클라이언트용으로 변환하고 다시 되돌립니다.params는 절대 건드리지 않습니다.
앱 파일, 요금제, 앱 알림
앱 파일
앱은 유형이 file인 폼 필드로 업로드(예: 점수 스크린샷)를 받고, 저장된 파일을 화면에 보여 줄 수 있습니다. 파일은 비공개 GameVox 저장소에 있으며, 채팅 첨부 파일처럼 검사되고, 언제나 수명이 짧은 서명된 URL로만 제공됩니다.
- 저장소 선언: 매니페스트에
"storage": {"kinds": ["image"], "max_file_bytes"?, "max_total_bytes"?, "max_video_bytes"?}를 넣습니다. 이것이 없으면 앱은 업로드를 받지 않습니다. 실제 한도는 요금제의 한도이며, 선언한 값이 더 낮으면 그 값으로 낮아집니다. - 파일 필드 추가:
{"type": "file", "key", "label", "accept": ["image"], "max_files": 1 to 10, "required"?}. 클라이언트는 저장소로 바로 업로드하고, 검사가 끝날 때까지 폼을 보낼 수 없습니다. - 값 읽기: 필드 값은
{id, url, name, mime, size, width, height}의 목록입니다.url의 서명은 한 시간 동안 유효합니다. 핸들러 안에서 파일을 가져오거나, 나중에 해당 파일에GET을 호출해 새 URL을 받아 주세요. - 쓰는 파일은 보관해 주세요. 업로드는 보관되기 전까지
pending이며, pending 파일은 24시간 뒤에 삭제됩니다. 파일을 담은 액션에error없이 응답하면 자동으로 보관됩니다.POST /applications/@me/app-files/{id}/keep로 직접 보관할 수도 있습니다. 보관된 파일은 삭제하거나, 요금제의 보존 기간이 끝나거나, 중재자가 차단하거나, 앱이 서버에서 제거된 지 30일이 지날 때까지 남습니다. - 파일 표시에는 URL 대신
{"file_id": "<id>"}를 씁니다. 이미지 블록의file_id, 목록 항목이나 보드 카드의image, 종류가image인 열의 표 셀입니다. GameVox가 보는 사람용 서명된 URL로 바꿔 넣습니다. 파일을 뷰에 넣는 것이 그 사람에게 파일을 볼 수 있게 하는 방법입니다. 다른 앱이나 다른 서버의 id는 버려지며, 뷰 하나에는 파일 id를 최대 100개까지 담을 수 있습니다. - 스냅샷에는 공개로 표시한 파일만 나타납니다.
{"public_in_snapshot": true}와 함께PATCH /applications/@me/app-files/{id}를 호출하세요. - 종류: 이미지(PNG, JPEG, GIF, WebP)는 지금 사용할 수 있습니다. 동영상(H.264 MP4)은 GameVox가 그 서버에서 앱에 동영상 허용량을 부여한 경우에만 동작합니다. WebM은 검사할 수 없어 거부됩니다.
document는 예약되어 있어 매니페스트에 적을 수는 있지만, 아직 받아들이는 문서 유형은 없습니다.
// manifest
"storage": { "kinds": ["image"], "max_file_bytes": 4194304 }
// a field in a form
{ "key": "shot", "type": "file", "label": "Screenshot", "accept": ["image"], "max_files": 2 }
// what the action brings you
"values": {
"shot": [ { "id": "8c1e2f4a-0b6d-4e7f-9a21-3c5d7e9f1a2b", "url": "https://...signed...",
"name": "score.png", "mime": "image/png", "size": 183224, "width": 1920, "height": 1080 } ]
}
// showing it later
{ "type": "image", "file_id": "8c1e2f4a-0b6d-4e7f-9a21-3c5d7e9f1a2b", "alt": "Score screen" } REST(모두 /applications/@me/ 아래, 봇 토큰, 길드·채널·업로더 id는 Snowflake, 파일 id는 UUID 문자열): GET app-files?guild_id=&state=&page=는 {files, page, has_more}로 응답합니다(페이지당 50개). GET, PATCH, DELETE app-files/{id}. POST app-files/{id}/keep. GET app-usage?guild_id=는 {plan, limits, used_bytes, files}로 응답합니다. 다른 앱의 파일은 404이고, 아직 업로드되지 않았거나 삭제되었거나 차단된 파일을 keep하면 409입니다.
요금제
모든 요청에는 plan: {key, limits}가 담깁니다. 매니페스트의 storage 상한을 적용한 뒤, 그 서버에서 앱이 가지는 한도입니다. 자체 추가 기능을 제한하는 데 쓰세요(다른 회사의 데이터로 만든 앱은 유료 기능에 관한 그 회사의 규칙을 따라야 합니다). 현재는 GameVox 직원이 서버에 다른 요금제를 설정하지 않는 한 모든 앱이 free 요금제로 동작하며, 아직 요금제를 구매할 방법은 없습니다.
| 필드 | 비고 |
|---|---|
max_file_bytes | 최대 이미지 크기: 8 MB. |
max_files_per_field | 파일 필드당 파일 수: 4. |
max_total_bytes | 서버별 앱당 저장 공간: 500 MB. 대기 중인 파일과 보관된 파일이 모두 포함됩니다. |
max_uploads_per_user_per_day | 구성원당 하루 업로드 수: 50. |
kinds | 허용되는 종류: ["image"], 그다음 매니페스트로 좁혀집니다(저장소를 선언하지 않으면 비어 있음). |
retention_days | 보관된 파일은 이 일수가 지나면 삭제됩니다. 0이면 직접 삭제할 때까지 보관합니다. |
max_video_bytes | 최대 동영상 크기. 0은 동영상을 허용하지 않음을 뜻합니다. |
앱 알림
봇은 다이렉트 메시지를 보낼 수 없습니다. 대신 앱은 POST /applications/@me/app-notifications로 구성원에게 알림을 보낼 수 있습니다(티켓 답변, 레이드 알림 등). 구성원은 앱 아이콘과 함께 알림 벨에서, 토스트로, 그리고 앱 알림을 허용했다면 모바일 푸시로 받습니다. 누르면 앱 채널이 route에서 열립니다.
POST /api/v10/applications/@me/app-notifications
Authorization: Bot <token>
Content-Type: application/json
{
"guild_id": "1491230000000000001",
"user_ids": ["1491250000000000003", "1491250000000000009"],
"title": "Raid starts in 30 minutes",
"body": "Mythic, 19:00. You signed up as healer.",
"route": "raids/42",
"dedupe_key": "raid-42-reminder"
}
HTTP/1.1 200 OK
{
"delivered": ["1491250000000000003"],
"skipped": [ { "user_id": "1491250000000000009", "reason": "muted" } ]
} guild_id(필수),user_ids(구성원 Snowflake 1~25개),title(필수, 최대 80자),body(최대 300자, 비어 있어도 됨),route,channel_id(그 서버에 있는 여러분 앱의 앱 채널),dedupe_key(최대 64자). 텍스트가 너무 길면 한도를 짚어 주는400이 반환됩니다. 텍스트가 잘리는 일은 없습니다.- 제목과 본문은 일반 텍스트입니다. 제어 문자와 양방향 문자는 제거됩니다.
- 누르면
channel_id를 지정했다면 그 채널이, 아니면 그 구성원이 볼 수 있는 여러분 앱의 첫 번째 앱 채널이 열립니다. 서버에 앱 채널이 없는 앱의 알림은 눌러도 아무것도 열리지 않습니다. - 24시간 안에 같은 구성원에게 같은
dedupe_key로 보내면 새로 추가되지 않고 이전 알림을 대체합니다(다시 읽지 않음으로 표시됩니다). - 응답에는
delivered구성원 id와,reason이 붙은skipped구성원 id가 나열됩니다. 이유는not_member(GameVox가 모르는 id 포함),muted(구성원이 그 서버에서 여러분의 앱을 음소거함),cannot_view,rate_limited,error(나중에 다시 시도) 중 하나입니다. 건너뛴 구성원은 아무것도 소모하지 않습니다. - 한도: 구성원별로 앱당 시간당 10개, 하루 30개, 서버별로 앱당 시간당 500개(거부된 시도도 포함), 봇당 10초에 호출 20번. 앱이 그 서버에 설치되어 있지 않으면
403. - 알림은 30일 동안, 구성원당 최대 200개까지 보관됩니다.
자체 호스팅 서버
앱 채널은 자체 호스팅 서버에서도 같은 이벤트와 엔드포인트로 동작합니다. 봇은 여전히 클라우드에만 연결합니다. 차이점은 다음과 같습니다.
APP_VIEW_REQUEST에서self_hosted가true입니다.- 고객의 서버가 채널을 볼 수 있는 사람을 확인하고 자체 그룹으로 보는 사람의 어빌리티를 계산한 뒤, 요청을 여러분에게 보내 달라고 클라우드에 요청합니다. 자체 호스팅 서버의 다른 모든 권한과 마찬가지로, GameVox는 이 판단을 고객의 서버에 맡깁니다.
- 자체 호스팅 채널의 스냅샷, 무효화, 채널 생성·수정·삭제는 다른 자체 호스팅 호출처럼 고객의 서버로 전달됩니다. 서버가 연결되어 있지 않으면
503, 제시간에 응답하지 않으면504입니다. - 바뀐 매니페스트는 바로가 아니라 1분 정도 안에 고객의 서버에 반영됩니다(매니페스트를 60초 동안 캐시하기 때문입니다).
- 고객의 서버가 클라우드에 닿지 못하면 구성원에게는 스냅샷(안의 파일은 제외)이나 오프라인 안내가 보입니다.
- 앱 파일은 클라우드와 똑같이 동작하며, 고객의 서버가 아니라 GameVox 저장소에 저장됩니다. 동영상에는 업데이트된 고객 서버가 필요합니다.
- 앱 알림의 경우 클라우드가 고객의 서버에 앱 채널을 볼 수 있는 사람을 묻습니다. 오래되었거나 연결할 수 없는 서버에는 묻지 않으며, 이때는 구성원 여부만 확인하고 지정한
channel_id는 확인 없이 그대로 전달됩니다. - 매니페스트 번역은 셀프 호스팅 서버에서도 동작하며, 각 멤버의 연결이 알려 주는 언어로 표시됩니다. 아직 업데이트되지 않은 서버는 기본 텍스트를 보여 줍니다.
봇 개발자를 위한 보안 참고 사항
- 모든 액션에서 어빌리티를 검사해 적용해 주세요. 무엇이든 바꾸기 전에
abilities(와 자체 규칙)를 확인해 주세요. 클라이언트가 버튼을 숨기는 것은 배려일 뿐입니다. - route, params, action, values는 사용자 입력으로 다루세요. 이 값들은 구성원의 클라이언트에서 옵니다. 변조된 클라이언트는 어떤 라우트, params, 액션 이름이든 보낼 수 있고, 폼의 필수 필드와 제한도 건너뛸 수 있습니다. GameVox는 형태만 확인합니다.
- GameVox가 주는 id를 신뢰해 주세요(
guild_id,channel_id,user.id,abilities). params 안의 id는 신뢰하지 말아 주세요. “레이드 42”에 대해 작업하려면 레이드 42가 이 채널에 속하는지 확인해 주세요. - 스냅샷은 공개 상태로 유지해 주세요. 채널을 볼 수 있는 모두가 봅니다. 개인 데이터는 절대 넣지 말아 주시고, 모두에게 보여도 되는 파일만 공개로 표시해 주세요.
- 서명된 파일 URL을 유출하지 말아 주세요. 가지고 있는 누구에게나 한 시간 동안 동작합니다. 뷰에는
file_id를 넣고, 보는 사람별 서명은 GameVox에 맡겨 주세요. - nonce는 외부에 내보내지 말아 주세요. 각 nonce는 보는 사람 한 명의 요청에 응답하기 위한 것이며, GameVox는 그것이 여러분 애플리케이션의 것인지 확인합니다.
- GameVox에서 호스팅하는 이미지를 우선 사용해 주세요. 서드파티 이미지 URL은 외부로 표시되어 클릭해야 보일 수 있으며, 화면이 표시될 때 이미지 호스트에 그 사실이 알려집니다.
- GameVox 쪽에서도 여러분의 뷰를 절대 신뢰하지 않습니다. 뷰는 허용 목록을 바탕으로 다시 만들어지고, HTML은 렌더링되지 않으며, 멘션은 알림을 보내지 않고,
open_url은https만 허용되며 항상 외부 링크 확인을 거칩니다.
오류와 제한
REST 응답
400: 본문이 잘못되었습니다. 매니페스트와 스냅샷 오류는 필드를 짚어 줍니다. nonce가 없는 응답도 400입니다.403: nonce나 채널이 다른 애플리케이션의 것이거나, 앱이 그 서버에 설치되어 있지 않습니다.404: 알 수 없거나 만료된 nonce. 알 수 없는 채널, 또는 앱 채널이 아닌 채널(스냅샷, 무효화). 알 수 없는 파일. 게시된 매니페스트 없음(GET).409: 파일이 이 작업을 허용하는 상태가 아닙니다.413: 본문이 한도를 넘었습니다. 매니페스트 64 KB(번역용으로 192 KB 추가), 뷰 응답 256 KB, 스냅샷 128 KB, 알림 요청 64 KB.429: 요청 한도를 넘었습니다. 일반적인 요청 한도 헤더가 함께 포함됩니다.
요청 한도
- 매니페스트: 애플리케이션당 분당 10회 게시(GET은 분당 30회).
- 뷰 응답: 봇당 초당 200회.
- 스냅샷: 채널당 10초에 6번 푸시.
- 무효화: 채널당 초당 5회까지 바로 처리됩니다. 그 이상은 거부되지 않고 합쳐집니다.
- 앱 파일: 라우트당 10초에 호출 30번. 사용량은 분당 30회.
- 앱 알림: 봇당 10초에 호출 20번, 그리고 위의 구성원별·서버별 한도.
- 보는 사람당: 10초에 열기 20번과 액션 10번. 애플리케이션당(모든 사람 합산): 10초에 요청 600번. 이를 넘으면 보는 사람에게 속도를 늦추라거나 앱이 바쁘다고 알리며, 봇에는 묻지 않습니다.
보는 사람에게 보이는 것
- “앱이 응답하지 않았습니다. 다시 시도해 주세요.” 10초 안에 응답이 없을 때.
- “지금은 앱이 오프라인입니다.” 봇이 오프라인일 때의 액션.
- “이 앱이 GameVox에서 표시할 수 없는 화면을 보냈습니다.” 뷰 전체가 거부되었을 때.
- “이 화면을 보려면 더 새로운 버전의 GameVox가 필요합니다.”
version이나kind가 다른 뷰. - 여러분이
error를 보냈다면 그 텍스트.
최소한의 엔드투엔드 예제
화면 하나와 폼 하나로 된 레이드 참가 신청 앱을 원시 HTTP로 만듭니다. id는 줄여 적었습니다.
1. 매니페스트를 게시합니다(봇이 시작할 때):
PUT /api/v10/applications/@me/app-manifest
Authorization: Bot <token>
{ "name": "Raid sign-up", "icon": { "builtin": "calendar" }, "home": "home",
"nav": [ { "label": "Home", "route": "home" } ],
"abilities": [ { "key": "signup", "label": "Sign up", "default": "everyone" } ] }
HTTP/1.1 200 OK 2. 채널을 준비합니다. 관리자가 채널 만들기 ▸ 앱으로 앱을 추가하거나, 봇이 직접 만듭니다:
POST /api/v10/guilds/1491230000000000001/channels
{ "type": 100, "name": "raid-signup" }
HTTP/1.1 201 Created
{ "id": "1491240000000000002", "type": 100, "gamevox_type": "app", ... } 3. 구성원이 채널을 엽니다. 봇이 받는 내용:
{ "op": 0, "t": "APP_VIEW_REQUEST", "d": {
"nonce": "a1...", "kind": "open", "guild_id": "1491230000000000001",
"channel_id": "1491240000000000002", "route": "home", "params": {},
"user": { "id": "1491250000000000003", "username": "ben", ... },
"abilities": ["signup"], "is_manager": false, "locale": "en-US", "platform": "web", ... } } 4. 화면으로 응답합니다(10초 안에):
POST /api/v10/applications/@me/app-view-response
Authorization: Bot <token>
{ "nonce": "a1...",
"view": { "version": 1, "title": "Raid sign-up", "route": "home", "blocks": [
{ "type": "text", "markdown": "You are not signed up yet." },
{ "type": "form", "id": "signup",
"fields": [ { "key": "role", "type": "select", "label": "Role", "required": true,
"options": [ { "value": "tank" }, { "value": "healer" }, { "value": "dps" } ] } ],
"submit": { "label": "Sign up", "action": "signup", "ability": "signup" } }
] } }
HTTP/1.1 204 No Content 5. 구성원이 폼을 제출합니다. 값이 담긴 액션을 받습니다:
{ "op": 0, "t": "APP_VIEW_REQUEST", "d": {
"nonce": "b2...", "kind": "action", "route": "home", "action": "signup",
"params": {}, "values": { "role": "healer" },
"channel_id": "1491240000000000002", "user": { "id": "1491250000000000003", ... },
"abilities": ["signup"], ... } } 6. 확인하고, 저장하고, 응답합니다. 토스트와 갱신된 화면으로 응답한 뒤, 화면을 보고 있는 다른 사람들에게 새로 고치라고 알립니다:
POST /api/v10/applications/@me/app-view-response
{ "nonce": "b2...", "toast": "Signed up as healer",
"view": { "version": 1, "title": "Raid sign-up", "route": "home", "blocks": [
{ "type": "text", "markdown": "You are signed up as **healer**." } ] } }
HTTP/1.1 204 No Content
POST /api/v10/channels/1491240000000000002/app-view/invalidate
{ "routes": ["home"] }
HTTP/1.1 204 No Content 선택: 스냅샷(봇이 오프라인일 때용). 공개되므로 누가 신청했는지가 아니라 인원수를 보여 줍니다:
PUT /api/v10/channels/1491240000000000002/app-view/snapshot
{ "view": { "version": 1, "title": "Raid sign-up", "blocks": [
{ "type": "stats", "items": [ { "label": "Signed up", "value": 18 } ] } ] } }
HTTP/1.1 200 OK
{ "warnings": [] } Discord 라이브러리에서는 원시 게이트웨이 훅으로 이벤트를 읽습니다(discord.py의 on_socket_raw_receive, discord.js의 raw 이벤트. 앱 내 설정의 표 참고). 3~6단계의 discord.py 버전:
# discord.py: answer APP_VIEW_REQUEST from the raw gateway hook
# (create the client with enable_debug_events=True)
import json
API = "https://app-api.gamevox.com/api/v10"
ROLES = ("tank", "healer", "dps")
signups = {} # (channel_id, user_id) -> role
def screen(req, note):
return {"version": 1, "title": "Raid sign-up", "route": "home", "blocks": [
{"type": "text", "markdown": note},
{"type": "form", "id": "signup",
"fields": [{"key": "role", "type": "select", "label": "Role", "required": True,
"options": [{"value": r} for r in ROLES]}],
"submit": {"label": "Sign up", "action": "signup", "ability": "signup"}}]}
@client.event
async def on_socket_raw_receive(msg):
packet = json.loads(msg)
if packet.get("t") != "APP_VIEW_REQUEST":
return
req = packet["d"]
key = (req["channel_id"], req["user"]["id"])
reply = {"nonce": req["nonce"]}
if req["kind"] == "action" and req["action"] == "signup":
role = req["values"].get("role")
if "signup" not in req["abilities"]: # enforce, never trust the client
reply["error"] = "You can't sign up in this channel."
elif role not in ROLES: # values are user input
reply["error"] = "Pick a role."
else:
signups[key] = role
reply["toast"] = f"Signed up as {role}"
if "error" not in reply:
mine = signups.get(key)
reply["view"] = screen(req, f"You are signed up as **{mine}**." if mine else "You are not signed up yet.")
await session.post(f"{API}/applications/@me/app-view-response",
headers={"Authorization": f"Bot {TOKEN}"}, json=reply) GameVox 자체 봇은 server/shared-gamebot에 있는 Go 봇 라이브러리를 사용합니다. 매니페스트 게시, 기한 안의 요청 응답, 뷰 검증, 무효화 묶어 보내기를 대신 처리해 줍니다.
아직 지원하지 않는 것
web종류(레이아웃 대신 샌드박스 페이지)는 예약되어 있습니다. 이를 쓰는 매니페스트와 뷰는 거부됩니다.- 문서 업로드와 요금제 구매는 아직 할 수 없습니다.
- 보드에서 끌어서 놓기.