///|
/// Execution strategy for a typed application command.
pub(all) enum InteractionHandler[A] {
Immediate(async (ImmediateCtx, A) -> CommandReply)
Deferred(ephemeral~ : Bool, async (DeferredCtx, A) -> Unit)
Raw(async (@framework.CommandCtx) -> Unit)
}
///|
priv struct SlashRoute {
path : Array[String]
ack : AckPlan
dispatch : async (
AppCtx,
ComponentWaiter?,
(AppWarning) -> Unit,
@framework.CommandCtx,
) -> Unit
}
///|
fn[A] InteractionHandler::ack_plan(self : InteractionHandler[A]) -> AckPlan {
match self {
Deferred(ephemeral~, _) => DeferMessage(ephemeral~)
Immediate(_) | Raw(_) => Respond
}
}
///|
priv struct SuggestRoute {
path : Array[String]
option_name : String
handler : @interaction.SuggestHandler
}
///|
/// A type-erased slash subcommand or subcommand group.
pub struct SlashChild {
priv definition_ : @model.CommandOption
priv routes_ : Array[SlashRoute]
priv node_paths_ : Array[Array[String]]
priv nested_group_paths_ : Array[Array[String]]
priv suggest_routes_ : Array[SuggestRoute]
priv is_group_ : Bool
}
///|
/// An application-command definition with a context-aware decoder.
pub struct Command[A] {
priv definition : @interaction.CommandSpec
priv decode : (@framework.CommandCtx) -> A raise
priv handler : InteractionHandler[A]
priv routes : Array[SlashRoute]?
priv node_paths : Array[Array[String]]
priv nested_group_paths : Array[Array[String]]
priv suggest_routes : Array[SuggestRoute]
priv checks : Array[CommandCheck]
priv cooldown : CooldownConfig?
}
///|
fn[A] suggest_routes(
path : Array[String],
args : @interaction.Args[A],
) -> Array[SuggestRoute] {
args
.suggestions()
.map(handler => { path: path.copy(), option_name: handler.name(), handler, })
}
///|
/// Decode slash arguments. A failed `Arg` validation is input the user can
/// correct; a missing or mistyped option means the payload does not match the
/// registered command, which the user cannot fix.
fn[A] decode_command_args(
args : @interaction.Args[A],
options : @interaction.CommandOptions,
) -> A raise HandlerError {
args.decode(options) catch {
@interaction.ArgsDecodeError::Validation(name~, message~) =>
raise InvalidInput(name=Some(name), message~)
source => raise malformed_unless_handler_error(source)
}
}
///|
/// Define a typed slash command.
///
/// ```mbt check
/// test "inspect a slash-command registration spec" {
/// let command = @app.slash(
/// name="echo",
/// description="Echo text",
/// args=@interaction.Args::of(
/// @interaction.arg_string(name="text", description="Text to echo"),
/// ),
/// handler=Raw(_ => ()),
/// )
/// json_inspect(command.spec().to_json(), content={
/// "name": "echo",
/// "description": "Echo text",
/// "options": [
/// {
/// "type": 3,
/// "name": "text",
/// "description": "Text to echo",
/// "required": true,
/// },
/// ],
/// })
/// }
/// ```
pub fn[A] slash(
name~ : String,
description~ : String,
args~ : @interaction.Args[A],
handler~ : InteractionHandler[A],
default_member_permissions? : @model.Permissions,
nsfw? : Bool,
integration_types? : Array[@model.ApplicationIntegrationType],
contexts? : Array[@model.InteractionContextType],
name_localizations? : Map[String, String],
description_localizations? : Map[String, String],
) -> Command[A] {
{
definition: @interaction.CommandSpec::slash(
name,
description,
options=args.definitions(),
default_member_permissions?,
nsfw?,
integration_types?,
contexts?,
name_localizations?,
description_localizations?,
),
decode: raw => decode_command_args(args, raw.options),
handler,
routes: None,
node_paths: [],
nested_group_paths: [],
suggest_routes: suggest_routes([], args),
checks: [],
cooldown: None,
}
}
///|
fn target_key(raw : @framework.CommandCtx) -> String raise HandlerError {
guard raw.data.target_id is Some(target_id) else {
raise Malformed(reason="context command is missing target_id")
}
target_id.to_string()
}
///|
fn decode_target_user(
raw : @framework.CommandCtx,
) -> @framework.TargetUser raise HandlerError {
let key = target_key(raw)
guard raw.data.resolved is Some(_) else {
raise Malformed(reason="user target is missing resolved data")
}
guard raw.target_user() is Some(target) else {
raise Malformed(reason="resolved user target \{key} was not found")
}
target
}
///|
fn decode_target_message(
raw : @framework.CommandCtx,
) -> @model.Message raise HandlerError {
let key = target_key(raw)
guard raw.data.resolved is Some(_) else {
raise Malformed(reason="message target is missing resolved data")
}
guard raw.target_message() is Some(message) else {
raise Malformed(reason="resolved message target \{key} was not found")
}
message
}
///|
/// Define a USER context-menu command.
///
/// ```mbt check
/// test "inspect context-menu command kinds" {
/// let user = @app.user_command(name="Inspect user", handler=Raw(_ => ()))
/// let message = @app.message_command(
/// name="Inspect message",
/// handler=Raw(_ => ()),
/// )
/// json_inspect(user.spec().to_json(), content={
/// "name": "Inspect user",
/// "description": "",
/// "type": 2,
/// })
/// json_inspect(message.spec().to_json(), content={
/// "name": "Inspect message",
/// "description": "",
/// "type": 3,
/// })
/// }
/// ```
pub fn user_command(
name~ : String,
handler~ : InteractionHandler[@framework.TargetUser],
default_member_permissions? : @model.Permissions,
nsfw? : Bool,
integration_types? : Array[@model.ApplicationIntegrationType],
contexts? : Array[@model.InteractionContextType],
name_localizations? : Map[String, String],
) -> Command[@framework.TargetUser] {
{
definition: @interaction.CommandSpec::user(
name,
default_member_permissions?,
nsfw?,
integration_types?,
contexts?,
name_localizations?,
),
decode: raw => decode_target_user(raw),
handler,
routes: None,
node_paths: [],
nested_group_paths: [],
suggest_routes: [],
checks: [],
cooldown: None,
}
}
///|
/// Define a MESSAGE context-menu command.
pub fn message_command(
name~ : String,
handler~ : InteractionHandler[@model.Message],
default_member_permissions? : @model.Permissions,
nsfw? : Bool,
integration_types? : Array[@model.ApplicationIntegrationType],
contexts? : Array[@model.InteractionContextType],
name_localizations? : Map[String, String],
) -> Command[@model.Message] {
{
definition: @interaction.CommandSpec::message(
name,
default_member_permissions?,
nsfw?,
integration_types?,
contexts?,
name_localizations?,
),
decode: raw => decode_target_message(raw),
handler,
routes: None,
node_paths: [],
nested_group_paths: [],
suggest_routes: [],
checks: [],
cooldown: None,
}
}
///|
fn prefixed_path(prefix : String, path : Array[String]) -> Array[String] {
let result = [prefix]
for part in path {
result.push(part)
}
result
}
///|
fn path_key(path : Array[String]) -> String {
path.join("\u{1f}")
}
///|
/// Define a typed slash subcommand.
pub fn[A] subcommand(
name~ : String,
description~ : String,
args~ : @interaction.Args[A],
handler~ : InteractionHandler[A],
name_localizations? : Map[String, String],
description_localizations? : Map[String, String],
) -> SlashChild {
let decode = (raw : @framework.CommandCtx) => {
decode_command_args(args, raw.options)
}
{
definition_: @interaction.sub_command(
name,
description,
options=args.definitions(),
name_localizations?,
description_localizations?,
),
routes_: [
{
path: [name],
ack: handler.ack_plan(),
dispatch: (bot, waiter, warn, raw) => {
dispatch_command_handler(
raw => decode(raw),
handler,
bot,
waiter,
warn,
raw,
)
},
},
],
node_paths_: [[name]],
nested_group_paths_: [],
suggest_routes_: suggest_routes([name], args),
is_group_: false,
}
}
///|
/// Define a slash subcommand group. Nested groups are retained for validation
/// and rejected by `AppBuilder::build`, never by panic or abort here.
pub fn subcommand_group(
name~ : String,
description~ : String,
children~ : Array[SlashChild],
name_localizations? : Map[String, String],
description_localizations? : Map[String, String],
) -> SlashChild {
let definitions = children.map(child => child.definition_)
let routes = []
let node_paths = [[name]]
let nested_group_paths = []
let suggest_routes = []
for child in children {
for route in child.routes_ {
routes.push({ ..route, path: prefixed_path(name, route.path), })
}
for path in child.node_paths_ {
node_paths.push(prefixed_path(name, path))
}
if child.is_group_ {
nested_group_paths.push([name, child.definition_.name])
}
for path in child.nested_group_paths_ {
nested_group_paths.push(prefixed_path(name, path))
}
for route in child.suggest_routes_ {
suggest_routes.push({ ..route, path: prefixed_path(name, route.path), })
}
}
{
definition_: @interaction.sub_command_group(
name,
description,
definitions,
name_localizations?,
description_localizations?,
),
routes_: routes,
node_paths_: node_paths,
nested_group_paths_: nested_group_paths,
suggest_routes_: suggest_routes,
is_group_: true,
}
}
///|
/// Define a slash command whose children dispatch by submitted option path.
pub fn slash_group(
name~ : String,
description~ : String,
children~ : Array[SlashChild],
default_member_permissions? : @model.Permissions,
nsfw? : Bool,
integration_types? : Array[@model.ApplicationIntegrationType],
contexts? : Array[@model.InteractionContextType],
name_localizations? : Map[String, String],
description_localizations? : Map[String, String],
) -> Command[Unit] {
let routes = []
let node_paths = []
let nested_group_paths = []
let suggest_routes = []
for child in children {
routes.append(child.routes_)
node_paths.append(child.node_paths_)
nested_group_paths.append(child.nested_group_paths_)
suggest_routes.append(child.suggest_routes_)
}
{
definition: @interaction.CommandSpec::slash(
name,
description,
options=children.map(child => child.definition_),
default_member_permissions?,
nsfw?,
integration_types?,
contexts?,
name_localizations?,
description_localizations?,
),
decode: _ => (),
handler: Raw(_ => ()),
routes: Some(routes),
node_paths,
nested_group_paths,
suggest_routes,
checks: [],
cooldown: None,
}
}
///|
/// Add a pre-execution check. Checks run in registration order after middleware
/// and before cooldown, argument decoding, and handler dispatch, inside
/// Discord's three-second initial-response window. A `Deferred` command is
/// acknowledged automatically when 1000 ms of that budget remain; immediate
/// and raw commands are not, so keep their checks fast or cache the results.
/// Returning false is equivalent to `HandlerError::CheckFailed`. Returns a new
/// command; `self` keeps its own checks.
pub fn[A] Command::check(self : Command[A], check : CommandCheck) -> Command[A] {
{ ..self, checks: [..self.checks, check], }
}
///|
/// Apply a fixed-window cooldown to this command. The window starts when all
/// checks pass, before argument decoding and handler execution. Returns a new
/// command; `self` keeps its own cooldown.
pub fn[A] Command::cooldown(
self : Command[A],
seconds~ : Int32,
bucket~ : CooldownBucket = User,
) -> Command[A] {
{ ..self, cooldown: Some({ seconds, bucket, }), }
}
///|
/// The command registration specification.
pub fn[A] Command::spec(self : Command[A]) -> @interaction.CommandSpec {
self.definition
}
///|
async fn[A] dispatch_command_handler(
decode : (@framework.CommandCtx) -> A raise,
handler : InteractionHandler[A],
bot : AppCtx,
waiter : ComponentWaiter?,
warn : (AppWarning) -> Unit,
raw : @framework.CommandCtx,
) -> Unit {
let value = decode(raw)
match handler {
Immediate(run) => {
let response = run({ raw_: raw, bot_: bot, }, value)
response.send(raw)
}
Deferred(ephemeral~, run) => {
ensure_acknowledged(command_admission(raw), DeferMessage(ephemeral~))
run({ raw_: raw, bot_: bot, waiter_: waiter, warn_: warn, }, value)
}
Raw(run) => run(raw)
}
}
///|
fn find_slash_route(
routes : Array[SlashRoute],
path : Array[String],
) -> SlashRoute? {
let key = path_key(path)
for route in routes {
if path_key(route.path) == key {
return Some(route)
}
}
None
}
///|
/// The acknowledgement plan of the route an invocation selects.
///
/// An unknown path plans `Respond` on purpose: the plan is taken before
/// admission, so nothing auto-defers, and the `Malformed` error that
/// `dispatch_typed` raises later reaches the error policy while the callback
/// is still pending, which lets it answer with an "out of date" initial reply.
fn[A] Command::ack_plan(
self : Command[A],
raw : @framework.CommandCtx,
) -> AckPlan {
match self.routes {
None => self.handler.ack_plan()
Some(routes) =>
match find_slash_route(routes, raw.options.path()) {
Some(route) => route.ack
None => Respond
}
}
}
///|
async fn[A] Command::dispatch_typed(
self : Command[A],
bot : AppCtx,
waiter : ComponentWaiter?,
warn : (AppWarning) -> Unit,
raw : @framework.CommandCtx,
) -> Unit {
match self.routes {
None =>
dispatch_command_handler(
self.decode,
self.handler,
bot,
waiter,
warn,
raw,
)
Some(routes) => {
let path = raw.options.path()
guard find_slash_route(routes, path) is Some(route) else {
raise HandlerError::Malformed(
reason="unknown subcommand path: \{if path.is_empty() { "" } else { path.join("/") }}",
)
}
(route.dispatch)(bot, waiter, warn, raw)
}
}
}
///|
fn find_suggest_route(
routes : Array[SuggestRoute],
path : Array[String],
option : String,
) -> SuggestRoute? {
let key = path_key(path)
for route in routes {
if path_key(route.path) == key && route.option_name == option {
return Some(route)
}
}
None
}
///|
async fn dispatch_suggest(
routes : Array[SuggestRoute],
warn : (AppWarning) -> Unit,
raw : @framework.AutocompleteCtx,
) -> Unit {
let path = raw.options.path()
guard raw.options.focused_input() is Some(input) else {
raise HandlerError::Malformed(
reason="autocomplete payload has no focused option",
)
}
guard find_suggest_route(routes, path, input.name()) is Some(route) else {
let display_path = if path.is_empty() { "" } else { path.join("/") }
raise HandlerError::Malformed(
reason="no autocomplete route for \{display_path}:\{input.name()}",
)
}
let ctx = @interaction.SuggestCtx::{
interaction: raw.interaction,
options: raw.options,
guild_id: raw.interaction.guild_id,
locale: raw.interaction.locale,
}
let choices = route.handler.suggest(ctx, input)
// `suggest` sends the first 25; say so when it drops the rest.
if choices.length() > 25 {
let command_path = [raw.data.name, ..path].join("/")
warn(
AutocompleteTruncated(
command_path~,
option=input.name(),
count=choices.length(),
),
)
}
raw.suggest(choices)
}
///|
/// A type-erased command consumed by an App executor.
pub struct RegisteredCommand {
priv name_ : String
priv definition : @interaction.CommandSpec
priv node_paths_ : Array[Array[String]]
priv nested_group_paths_ : Array[Array[String]]
priv suggest_routes_ : Array[SuggestRoute]
priv checks_ : Array[CommandCheck]
priv cooldown_ : CooldownConfig?
priv ack_ : (@framework.CommandCtx) -> AckPlan
priv dispatch : async (
AppCtx,
ComponentWaiter?,
(AppWarning) -> Unit,
@framework.CommandCtx,
) -> Unit
}
///|
/// The top-level command name used for routing.
pub fn RegisteredCommand::name(self : RegisteredCommand) -> String {
self.name_
}
///|
/// Erase the decoded argument type while retaining registration and dispatch.
pub fn[A] Command::erase(self : Command[A]) -> RegisteredCommand {
{
name_: self.definition.name,
definition: self.definition,
node_paths_: self.node_paths,
nested_group_paths_: self.nested_group_paths,
suggest_routes_: self.suggest_routes,
checks_: self.checks,
cooldown_: self.cooldown,
ack_: raw => self.ack_plan(raw),
dispatch: (bot, waiter, warn, raw) => {
self.dispatch_typed(bot, waiter, warn, raw)
},
}
}
///|
/// A copy that shares no mutable data with the command it came from: the
/// registration spec's arrays and maps are public and may still be held by
/// the caller, so a built `App` keeps its own.
fn RegisteredCommand::snapshot(self : RegisteredCommand) -> RegisteredCommand {
{
..self,
definition: copy_spec(self.definition),
checks_: self.checks_.copy(),
}
}
///|
fn copy_spec(spec : @interaction.CommandSpec) -> @interaction.CommandSpec {
{
..spec,
name_localizations: spec.name_localizations.map(Map::copy),
description_localizations: spec.description_localizations.map(Map::copy),
options: spec.options.map(copy_option),
integration_types: spec.integration_types.map(Array::copy),
contexts: spec.contexts.map(Array::copy),
}
}
///|
fn copy_option(option : @model.CommandOption) -> @model.CommandOption {
{
..option,
name_localizations: option.name_localizations.map(copy_nullable_map),
description_localizations: option.description_localizations.map(
copy_nullable_map,
),
choices: option.choices.map(choices => {
choices.map(choice => {
..choice,
name_localizations: choice.name_localizations.map(copy_nullable_map),
})
}),
options: option.options.map(options => options.map(copy_option)),
channel_types: option.channel_types.map(Array::copy),
file_types: option.file_types.map(Array::copy),
}
}
///|
fn copy_nullable_map(
map : @model.Nullable[Map[String, String]],
) -> @model.Nullable[Map[String, String]] {
match map {
Null => Null
Value(map) => Value(map.copy())
}
}
///|
/// The `(type, name)` identity Discord uses for a command, as one string.
fn RegisteredCommand::key(self : RegisteredCommand) -> String {
"\{self.definition.typ.to_int()}:\{self.name_}"
}