///|
/// Defaults that Discord may add to or omit from response objects.
fn object_defaults(command_root : Bool) -> Array[(String, Json)] {
let defaults = [
("required", Json::boolean(false)),
("options", Json::array([])),
("choices", Json::array([])),
]
if command_root {
defaults.push(("type", Json::number(1.0)))
defaults.push(("nsfw", Json::boolean(false)))
defaults.push(("dm_permission", Json::boolean(true)))
defaults.push(("integration_types", Json::array([Json::number(0.0)])))
defaults.push(("contexts", Json::null()))
defaults.push(("default_member_permissions", Json::null()))
}
defaults
}
///|
/// Keys whose omitted value Discord derives from state the client cannot see.
/// An omitted `integration_types` becomes the app's supported installation
/// contexts at creation time (`[0]`, or `[0, 1]` once user installs are
/// enabled — observed live), so an undeclared one matches whatever Discord
/// echoes. A declared one is still compared, with an absent echo read as `[0]`.
fn server_derived_default(key : String) -> Bool {
key == "integration_types"
}
///|
fn object_default(key : String, command_root : Bool) -> Json? {
for entry in object_defaults(command_root) {
let (candidate, value) = entry
if candidate == key {
return Some(value)
}
}
None
}
///|
/// Command fields Discord stores as sets: it may echo them in any order
/// (observed live for `file_types`), so they compare as multisets. Options
/// and choices stay order-sensitive because their order is user-visible.
fn unordered_key(key : String) -> Bool {
key is ("file_types" | "channel_types" | "contexts" | "integration_types")
}
///|
fn same_elements(expected : Array[Json], actual : Array[Json]) -> Bool {
if expected.length() != actual.length() {
return false
}
let used = Array::make(actual.length(), false)
for value in expected {
let mut matched = false
for i, other in actual {
if !used[i] && declared_json_matches_at(value, other, false) {
used[i] = true
matched = true
break
}
}
if !matched {
return false
}
}
true
}
///|
fn declared_value_matches(
key : String,
declared : Json,
fetched : Json,
) -> Bool {
match (declared, fetched) {
(Array(expected), Array(actual)) if unordered_key(key) =>
same_elements(expected, actual)
_ => declared_json_matches_at(declared, fetched, false)
}
}
///|
/// Compare one declared JSON subtree with the corresponding Discord value.
/// Discord may add fields to response objects; only keys emitted by the
/// declaration and known declaration defaults participate in equality.
fn declared_json_matches_at(
declared : Json,
fetched : Json,
command_root : Bool,
) -> Bool {
match (declared, fetched) {
(Object(expected), Object(actual)) => {
for key, value in expected {
match actual.get(key) {
Some(other) =>
if !declared_value_matches(key, value, other) {
return false
}
None => {
guard object_default(key, command_root) is Some(fallback) else {
return false
}
if !declared_value_matches(key, value, fallback) {
return false
}
}
}
}
for entry in object_defaults(command_root) {
let (key, fallback) = entry
if !expected.contains(key) &&
!server_derived_default(key) &&
actual.get(key) is Some(value) &&
!declared_value_matches(key, fallback, value) {
return false
}
}
true
}
(Array(expected), Array(actual)) => {
if expected.length() != actual.length() {
return false
}
for i, value in expected {
if !declared_json_matches_at(value, actual[i], false) {
return false
}
}
true
}
_ => declared == fetched
}
}
///|
fn declared_json_matches(declared : Json, fetched : Json) -> Bool {
declared_json_matches_at(declared, fetched, true)
}
///|
fn command_key(typ : @model.ApplicationCommandType, name : String) -> String {
"\{typ.to_int()}:\{name}"
}
///|
/// What to do with undeclared remote commands. Entry points are always kept.
pub(all) enum UnownedCommands {
Delete
Keep
} derive(Debug, Eq)
///|
/// Changes in one scope. Slash commands use plain names; other commands use
/// `:`. `None` identifies the global scope.
pub(all) struct ScopeSyncReport {
guild_id : @model.GuildId?
created : Array[String]
updated : Array[String]
deleted : Array[String]
unchanged : Array[String]
preserved : Array[String]
overwritten : Bool
} derive(Debug)
///|
fn remote_command_identity(
command : Json,
) -> (@model.ApplicationCommandType, String) {
let typ = match command {
{ "type": Number(value, ..), .. } =>
@model.ApplicationCommandType::from_int(value.to_int())
_ => @model.ApplicationCommandType::ChatInput
}
let name = match command {
{ "name": String(value), .. } => value
_ => ""
}
(typ, name)
}
///|
fn command_name(typ : @model.ApplicationCommandType, name : String) -> String {
if typ.to_int() == 1 {
name
} else {
command_key(typ, name)
}
}
///|
fn preserved_command(command : Json) -> Json {
guard command is Object(fields) else { return command }
let payload : Map[String, Json] = Map([])
for key, value in fields {
if !(key
is ("application_id"
| "version"
| "guild_id"
| "name_localized"
| "description_localized")) {
payload[key] = value
}
}
Json::object(payload)
}
///|
/// Plan a bulk overwrite from raw list-endpoint objects without mutating them.
/// Entry points and, under `Keep`, other undeclared commands retain their ids
/// and unknown fields. A matching catalog needs no PUT; duplicate remote keys
/// force one. The payload follows declaration order, then preserved remote order.
///
/// ```mbt check
/// test {
/// let (payload, report) = @framework.plan_command_sync([], [], unowned=Keep)
/// assert_true(payload is None)
/// assert_false(report.overwritten)
/// }
/// ```
pub fn plan_command_sync(
declared : Array[@interaction.CommandSpec],
fetched : Array[Json],
unowned~ : UnownedCommands,
guild_id? : @model.GuildId,
) -> (Json?, ScopeSyncReport) {
let by_key : Map[String, Json] = Map([])
let mut duplicate = false
for command in fetched {
let (typ, name) = remote_command_identity(command)
let key = command_key(typ, name)
if by_key.contains(key) {
duplicate = true
}
by_key[key] = command
}
let created = []
let updated = []
let deleted = []
let unchanged = []
let preserved = []
let payload = []
let declared_keys : Set[String] = Set([])
for spec in declared {
let key = command_key(spec.typ, spec.name)
declared_keys.add(key)
let name = command_name(spec.typ, spec.name)
let json = spec.to_json()
payload.push(json)
match by_key.get(key) {
None => created.push(name)
Some(command) =>
if declared_json_matches(json, command) {
unchanged.push(name)
} else {
updated.push(name)
}
}
}
for command in fetched {
let (typ, name) = remote_command_identity(command)
if !declared_keys.contains(command_key(typ, name)) {
let name = command_name(typ, name)
if typ is PrimaryEntryPoint || unowned is Keep {
preserved.push(name)
payload.push(preserved_command(command))
} else {
deleted.push(name)
}
}
}
let overwritten = duplicate ||
!created.is_empty() ||
!updated.is_empty() ||
!deleted.is_empty()
(
if overwritten {
Some(Json::array(payload))
} else {
None
},
{ guild_id, created, updated, deleted, unchanged, preserved, overwritten, },
)
}
///|
/// Fetch raw commands with localizations, plan changes, and overwrite only
/// when needed. Failures propagate without returning a successful report.
pub async fn sync_command_scope(
client : @dhttp.Client,
application_id : @model.ApplicationId,
declared : Array[@interaction.CommandSpec],
unowned? : UnownedCommands = Delete,
guild_id? : @model.GuildId,
) -> ScopeSyncReport {
let fetched = match guild_id {
None =>
client.get_global_commands_raw(application_id, with_localizations=true)
Some(guild_id) =>
client.get_guild_commands_raw(
application_id,
guild_id,
with_localizations=true,
)
}
let (payload, report) = plan_command_sync(
declared,
fetched,
unowned~,
guild_id?,
)
if payload is Some(body) {
let route = match guild_id {
None => @dhttp.Route::BulkOverwriteGlobalCommands(application_id~)
Some(guild_id) =>
@dhttp.Route::BulkOverwriteGuildCommands(application_id~, guild_id~)
}
client.request(route, body~) |> ignore
}
report
}