///|
/// A registered command: its registration spec plus its handler.
priv struct CommandEntry {
spec : @interaction.CommandSpec
handler : async (CommandCtx) -> Unit
}
///|
fn command_key(typ : @model.ApplicationCommandType, name : String) -> String {
"\{typ.to_int()}:\{name}"
}
///|
/// One task waiting for a specific component interaction.
priv struct ComponentWaiter {
custom_id : String
queue : @aqueue.Queue[ComponentCtx]
}
///|
/// Routes incoming interactions to declared command / component / modal
/// handlers, and can register the declared commands with Discord.
///
/// Registration methods return `self` for chaining. Feed every
/// `InteractionCreate` gateway event to `process`.
pub struct Framework {
priv client : @dhttp.Client
priv application_id : @model.ApplicationId
priv commands : Map[String, CommandEntry]
priv autocompletes : Map[String, async (AutocompleteCtx) -> Unit]
priv components : Array[(String, async (ComponentCtx) -> Unit)]
priv modals : Array[(String, async (ModalCtx) -> Unit)]
priv waiters : Array[ComponentWaiter]
priv mut error_hook : (async (String, Error) -> Unit)?
}
///|
/// Create an empty interaction router bound to a REST client and application
/// id. Most bots configure routes through `@app.App` and let an executor
/// call `App::attach` instead of registering here directly.
pub fn Framework::Framework(
client : @dhttp.Client,
application_id : @model.ApplicationId,
) -> Framework {
{
client,
application_id,
commands: Map([]),
autocompletes: Map([]),
components: [],
modals: [],
waiters: [],
error_hook: None,
}
}
///|
/// Register a command: `spec` describes it to Discord, `handler` runs when
/// it is invoked. Routing is by command type and top-level command name;
/// subcommand routing happens inside the handler via `ctx.options.path()`.
pub fn Framework::command(
self : Framework,
spec : @interaction.CommandSpec,
handler : async (CommandCtx) -> Unit,
) -> Framework {
self.commands[command_key(spec.typ, spec.name)] = { spec, handler, }
self
}
///|
/// Register an autocomplete handler for a command name.
pub fn Framework::autocomplete(
self : Framework,
command_name : String,
handler : async (AutocompleteCtx) -> Unit,
) -> Framework {
self.autocompletes[command_name] = handler
self
}
///|
/// Register a component handler for custom ids starting with `prefix`.
/// Longer prefixes win; equally long prefixes retain registration order.
/// Exact-id waiters take precedence over all registered handlers.
pub fn Framework::component(
self : Framework,
prefix : String,
handler : async (ComponentCtx) -> Unit,
) -> Framework {
for index, entry in self.components {
if prefix.length() > entry.0.length() {
self.components.insert(index, (prefix, handler))
return self
}
}
self.components.push((prefix, handler))
self
}
///|
/// Register a modal-submit handler for custom ids starting with `prefix`.
/// Longer prefixes win; equally long prefixes retain registration order.
pub fn Framework::modal(
self : Framework,
prefix : String,
handler : async (ModalCtx) -> Unit,
) -> Framework {
for index, entry in self.modals {
if prefix.length() > entry.0.length() {
self.modals.insert(index, (prefix, handler))
return self
}
}
self.modals.push((prefix, handler))
self
}
///|
/// Install an error hook. When set, handler errors are passed to it
/// (with a `kind:name` label) instead of propagating out of `process`.
pub fn Framework::on_error(
self : Framework,
hook : async (String, Error) -> Unit,
) -> Framework {
self.error_hook = Some(hook)
self
}
///|
/// The registration payload for every declared command, as sent to the
/// bulk-overwrite endpoints.
pub fn Framework::command_specs(self : Framework) -> Json {
let specs = []
for _, entry in self.commands {
specs.push(entry.spec.to_json())
}
Json::array(specs)
}
///|
/// Replace all global commands with the declared ones.
pub async fn Framework::sync_global(
self : Framework,
) -> Array[@model.ApplicationCommand] {
self.client.bulk_overwrite_global_commands(
self.application_id,
self.command_specs(),
)
}
///|
/// Replace all commands of one guild with the declared ones. Guild
/// commands update instantly, which makes this the right call during
/// development.
pub async fn Framework::sync_guild(
self : Framework,
guild_id : @model.GuildId,
) -> Array[@model.ApplicationCommand] {
self.client.bulk_overwrite_guild_commands(
self.application_id,
guild_id,
self.command_specs(),
)
}
///|
async fn Framework::guard_errors(
self : Framework,
label : String,
body : async () -> Unit,
) -> Unit {
match self.error_hook {
Some(hook) =>
body() catch {
error if @async.is_being_cancelled() ||
@async.is_cancellation_error(error) => raise error
error => hook(label, error)
}
None => body()
}
}
///|
/// Route one interaction. Returns `true` when a handler (or waiter)
/// consumed it, `false` when nothing matched. Handler errors propagate
/// unless an `on_error` hook is installed.
pub async fn Framework::process(
self : Framework,
interaction : @model.Interaction,
) -> Bool {
let gate = ResponseGate::rest(self.client, interaction)
self.process_with(interaction, gate~)
}
///|
/// Route one interaction through a caller-provided initial-response gate.
pub async fn Framework::process_with(
self : Framework,
interaction : @model.Interaction,
gate~ : ResponseGate,
) -> Bool {
match interaction.typ {
Ping => {
gate.send({ typ: Pong, data: None, })
true
}
ApplicationCommand => {
guard interaction.data is Some(Command(data)) else { return false }
guard self.commands.get(command_key(data.typ, data.name)) is Some(entry) else {
return false
}
let scope = interaction_scope(interaction)
let ctx = CommandCtx::{
client: self.client,
application_id: self.application_id,
interaction,
data,
options: @interaction.CommandOptions::from_data(data),
scope_: scope,
gate,
}
self.guard_errors("command:\{data.name}", () => (entry.handler)(ctx))
true
}
ApplicationCommandAutocomplete => {
guard interaction.data is Some(Command(data)) else { return false }
guard self.autocompletes.get(data.name) is Some(handler) else {
return false
}
let ctx = AutocompleteCtx::{
client: self.client,
application_id: self.application_id,
interaction,
data,
options: @interaction.CommandOptions::from_data(data),
gate,
}
self.guard_errors("autocomplete:\{data.name}", () => handler(ctx))
true
}
MessageComponent => {
guard interaction.data is Some(Component(data)) else { return false }
// Validate the guarantees of ComponentCtx before waiters or registered
// routes can observe this interaction.
let scope = interaction_scope(interaction)
let message = component_message(interaction)
let ctx = ComponentCtx::{
client: self.client,
application_id: self.application_id,
interaction,
data,
scope_: scope,
message_: message,
gate,
}
for i, waiter in self.waiters {
if waiter.custom_id == data.custom_id {
self.waiters.remove(i) |> ignore
waiter.queue.put(ctx)
return true
}
}
for entry in self.components {
let (prefix, handler) = entry
if data.custom_id.has_prefix(prefix) {
self.guard_errors("component:\{data.custom_id}", () => handler(ctx))
return true
}
}
false
}
ModalSubmit => {
guard interaction.data is Some(Modal(data)) else { return false }
let scope = interaction_scope(interaction)
let origin = modal_origin(interaction)
for entry in self.modals {
let (prefix, handler) = entry
if data.custom_id.has_prefix(prefix) {
let ctx = ModalCtx::{
client: self.client,
application_id: self.application_id,
interaction,
data,
scope_: scope,
origin_: origin,
gate,
}
self.guard_errors("modal:\{data.custom_id}", () => handler(ctx))
return true
}
}
false
}
Unknown(_) => false
}
}
///|
/// Wait for the next component interaction whose custom id matches
/// exactly. Returns `None` on timeout. Waiters win over registered
/// component handlers, which makes multi-step flows (confirm buttons,
/// pagination) straightforward inside one handler.
pub async fn Framework::wait_for_component(
self : Framework,
custom_id~ : String,
timeout_ms? : Int,
) -> ComponentCtx? {
let waiter = ComponentWaiter::{ custom_id, queue: Queue(kind=Unbounded), }
self.waiters.push(waiter)
defer (for i, pending in self.waiters {
if physical_equal(pending, waiter) {
self.waiters.remove(i) |> ignore
break
}
})
match timeout_ms {
Some(ms) => @async.with_timeout_opt(ms, () => waiter.queue.get())
None => Some(waiter.queue.get())
}
}