///|
/// Everything one interaction can do with its messages: the single initial
/// callback through its response gate, and the webhook operations its token
/// allows afterwards. Command, component, and modal contexts expose the same
/// operations, so they all delegate here and cannot drift apart.
priv struct Responder {
gate : ResponseGate
client : @dhttp.Client
application_id : @model.ApplicationId
token : String
}
///|
/// Send a message-bearing initial callback of type `typ`.
async fn Responder::respond(
self : Responder,
typ : @model.InteractionResponseType,
content? : String,
clear_content~ : Bool = false,
embeds? : Array[@model.Embed],
components? : Array[@model.Component],
allowed_mentions? : @model.AllowedMentions,
files? : Array[@dhttp.FileUpload],
keep_attachments? : Array[@model.AttachmentRequest],
poll? : @model.PollCreateRequest,
ephemeral~ : Bool = false,
) -> Unit {
let data = @dhttp.interaction_message_data(
content?,
clear_content~,
embeds?,
components?,
allowed_mentions?,
files?,
keep_attachments?,
poll?,
ephemeral~,
)
self.gate.send({ typ, data: Some(data), }, files?)
}
///|
/// Acknowledge with a loading message to edit or follow up later.
async fn Responder::defer_message(self : Responder, ephemeral : Bool) -> Unit {
self.respond(DeferredChannelMessageWithSource, ephemeral~)
}
///|
/// Acknowledge a component or modal without changing its message.
async fn Responder::defer_update(self : Responder) -> Unit {
self.gate.send({ typ: DeferredUpdateMessage, data: None, })
}
///|
async fn Responder::show_modal(
self : Responder,
custom_id : String,
title : String,
components : Array[@model.Component],
) -> Unit {
@dhttp.validate_modal_limits(title, components)
self.gate.send({
typ: Modal,
data: Some(
@sdkjson.ObjBuilder::new()
.field("custom_id", custom_id)
.field("title", title)
.field("components", components)
.build(),
),
})
}
///|
/// Which path a reply takes once no callback is in flight.
priv enum ReplyRoute {
/// The initial callback is still unused: reply with it.
Initial
/// Edit the deferred loading placeholder. With `fallback_on_error`, a failed
/// edit falls back to a followup: an unconfirmed deferral may have no
/// original message to edit.
EditPlaceholder(fallback_on_error~ : Bool)
Followup
Expired
}
///|
fn reply_route(state : ResponseState, placeholder_open : Bool) -> ReplyRoute {
match state {
Pending => Initial
Expired => Expired
Sent(DeferredChannelMessageWithSource) if placeholder_open =>
EditPlaceholder(fallback_on_error=false)
Unconfirmed(DeferredChannelMessageWithSource) if placeholder_open =>
EditPlaceholder(fallback_on_error=true)
Sent(_) | Unconfirmed(_) => Followup
}
}
///|
/// Deliver a message through whichever path the gate's state allows:
/// the initial callback while pending, an edit of a still-loading deferred
/// placeholder, and a followup otherwise. Editing replaces the deprecated
/// behavior where the first followup after a deferral edits the original.
///
/// Verified live on 2026-09-28 with `examples/gate_probe`: the edit
/// replaces the loading message and keeps the defer's visibility; after an
/// edit or a `DeferredUpdateMessage` the reply is a separate followup; and
/// when an unconfirmed deferral's edit fails, the fallback followup still
/// replaces the loading message (Discord's deprecated followup-as-edit).
async fn Responder::reply(
self : Responder,
content : String?,
embeds : Array[@model.Embed]?,
components : Array[@model.Component]?,
files : Array[@dhttp.FileUpload]?,
allowed_mentions : @model.AllowedMentions?,
poll : @model.PollCreateRequest?,
ephemeral : Bool,
) -> Unit {
let gate = self.gate
gate.exclusive_reply(() => {
gate.wait_settled()
match reply_route(gate.state(), gate.placeholder_open()) {
Initial =>
self.respond(
ChannelMessageWithSource,
content?,
embeds?,
components?,
allowed_mentions?,
files?,
poll?,
ephemeral~,
)
Expired => raise ResponseGateError::Expired
EditPlaceholder(fallback_on_error~) => {
let edited = try {
self.client.edit_original_response(
self.application_id,
self.token,
content?,
embeds?,
components?,
allowed_mentions?,
files?,
poll?,
)
|> ignore
true
} catch {
error if @async.is_being_cancelled() => raise error
error if fallback_on_error => {
ignore(error)
false
}
error => raise error
}
if !edited {
self.send_followup(
content, embeds, components, files, allowed_mentions, poll, ephemeral,
)
|> ignore
}
// Only a delivered reply replaces the placeholder; after a failure the
// loading message may still be showing, so a later reply edits again.
gate.close_placeholder()
}
Followup => {
self.send_followup(
content, embeds, components, files, allowed_mentions, poll, ephemeral,
)
|> ignore
gate.close_placeholder()
}
}
})
}
///|
async fn Responder::send_followup(
self : Responder,
content : String?,
embeds : Array[@model.Embed]?,
components : Array[@model.Component]?,
files : Array[@dhttp.FileUpload]?,
allowed_mentions : @model.AllowedMentions?,
poll : @model.PollCreateRequest?,
ephemeral : Bool,
) -> @model.Message {
self.client.create_followup(
self.application_id,
self.token,
content?,
embeds?,
components?,
files?,
allowed_mentions?,
poll?,
flags=if ephemeral {
@model.MessageFlags::ephemeral()
} else {
@model.MessageFlags::from_bits(0)
},
)
}
///|
async fn Responder::followup(
self : Responder,
content : String?,
embeds : Array[@model.Embed]?,
components : Array[@model.Component]?,
files : Array[@dhttp.FileUpload]?,
allowed_mentions : @model.AllowedMentions?,
poll : @model.PollCreateRequest?,
ephemeral : Bool,
) -> @model.Message {
let message = self.send_followup(
content, embeds, components, files, allowed_mentions, poll, ephemeral,
)
self.gate.close_placeholder()
message
}
///|
async fn Responder::original(self : Responder) -> @model.Message {
self.client.get_original_response(self.application_id, self.token)
}
///|
async fn Responder::delete_original(self : Responder) -> Unit {
self.client.delete_original_response(self.application_id, self.token)
self.gate.close_placeholder()
}
///|
async fn Responder::edit_original(
self : Responder,
content : String?,
clear_content : Bool,
embeds : Array[@model.Embed]?,
components : Array[@model.Component]?,
allowed_mentions : @model.AllowedMentions?,
files : Array[@dhttp.FileUpload]?,
keep_attachments : Array[@model.AttachmentRequest]?,
poll : @model.PollCreateRequest?,
) -> @model.Message {
let message = self.client.edit_original_response(
self.application_id,
self.token,
content?,
clear_content~,
embeds?,
components?,
allowed_mentions?,
files?,
keep_attachments?,
poll?,
)
self.gate.close_placeholder()
message
}
///|
async fn Responder::get_followup(
self : Responder,
message_id : @model.MessageId,
) -> @model.Message {
self.client.get_followup(self.application_id, self.token, message_id)
}
///|
async fn Responder::edit_followup(
self : Responder,
message_id : @model.MessageId,
content : String?,
clear_content : Bool,
embeds : Array[@model.Embed]?,
components : Array[@model.Component]?,
allowed_mentions : @model.AllowedMentions?,
files : Array[@dhttp.FileUpload]?,
keep_attachments : Array[@model.AttachmentRequest]?,
) -> @model.Message {
self.client.edit_followup(
self.application_id,
self.token,
message_id,
content?,
clear_content~,
embeds?,
components?,
allowed_mentions?,
files?,
keep_attachments?,
)
}
///|
async fn Responder::delete_followup(
self : Responder,
message_id : @model.MessageId,
) -> Unit {
self.client.delete_followup(self.application_id, self.token, message_id)
}