///|
/// An error raised when an interaction callback cannot be accepted.
pub(all) suberror ResponseGateError {
AlreadyResponded
Expired
InvalidCallback(
kind~ : @model.InteractionType,
typ~ : @model.InteractionResponseType
)
} derive(Debug)
///|
/// What has happened to an interaction's single initial callback.
pub(all) enum ResponseState {
Pending
Sent(@model.InteractionResponseType)
/// The sink raised; Discord may or may not have accepted the callback.
Unconfirmed(@model.InteractionResponseType)
/// The executor gave up the callback window.
Expired
} derive(Debug, Eq)
///|
/// How a failed callback delivery left the interaction's callback window.
#non_exhaustive
pub(all) enum DeliveryFailure {
/// The callback was definitively refused, by Discord or by client-side
/// validation; the window is still unused.
Rejected
/// The callback never reached Discord, so Discord cannot have accepted
/// it; the window is still unused and the same callback may be sent again
/// while it is open.
Unsent
/// Discord no longer accepts any initial callback for this interaction.
Expired
/// Discord may or may not have accepted the callback.
Uncertain
} derive(Debug, Eq)
///|
/// A single-use interaction callback gate.
pub struct ResponseGate {
priv kind : @model.InteractionType
priv mut state : ResponseState
priv sink : async (@model.InteractionResponse, Array[@dhttp.FileUpload]?) -> Unit
priv classify : (Error) -> DeliveryFailure
priv deadline_at : Int64?
// When the executor received the interaction: construction time.
priv received_at : Int64
priv mut placeholder_open : Bool
priv mut in_flight : Bool
priv settled : @cond_var.Cond
priv reply_lock : @async.Mutex
priv clock : &@hclock.Clock
}
///|
/// A callback plus any multipart files captured by an in-memory gate.
pub(all) struct CapturedResponse {
response : @model.InteractionResponse
files : Array[@dhttp.FileUpload]?
}
///|
/// The receiving side of a capture gate.
pub struct ResponseCapture {
priv queue : @aqueue.Queue[CapturedResponse]
}
///|
/// The default initial-response budget in milliseconds, measured from the
/// moment an executor receives an interaction. Discord invalidates the
/// interaction token three seconds after sending it; the remaining 500 ms
/// absorb delivery latency.
pub const DEFAULT_INTERACTION_DEADLINE_MS : Int32 = 2500
///|
/// How long an interaction token stays valid after Discord sends the
/// interaction: every followup, original-response edit, and deletion must
/// happen within it.
pub const INTERACTION_TOKEN_LIFETIME_MS : Int32 = 900_000
///|
/// Construct a gate around an injected callback sink.
///
/// `classify` decides how a failed delivery leaves the callback window; it
/// defaults to `Uncertain`. Cancellation is always `Uncertain`, regardless of
/// the classifier. With `deadline_ms`, the window closes that many
/// milliseconds after construction: a later `send` expires the gate instead
/// of contacting Discord. `clock` times the deadline and the token lifetime.
pub fn ResponseGate::ResponseGate(
kind : @model.InteractionType,
sink : async (@model.InteractionResponse, Array[@dhttp.FileUpload]?) -> Unit,
classify~ : (Error) -> DeliveryFailure = _ => Uncertain,
deadline_ms? : Int32,
clock~ : &@hclock.Clock = @transport.AsyncClock::new(),
) -> ResponseGate {
let now = clock.now_unix_ms()
{
kind,
state: Pending,
sink,
classify,
deadline_at: deadline_ms.map(ms => now + ms.to_int64()),
received_at: now,
placeholder_open: false,
in_flight: false,
settled: @cond_var.Cond(),
reply_lock: @async.Mutex(),
clock,
}
}
///|
/// Classify a failed `POST /interactions/{id}/{token}/callback`.
///
/// Unknown Interaction (10062) closes the window. A transport failure or
/// timeout that proves nothing was sent (`DiscordHttpError::is_unsent`) is
/// `Unsent`; the client has already resent it as often as `max_retries`
/// allows. Already Acknowledged (40060), rate limits, server errors, and
/// other transport failures and timeouts are uncertain. Other client errors
/// are definitive refusals of that payload.
pub fn classify_rest_callback_failure(error : Error) -> DeliveryFailure {
match error {
@dhttp.DiscordHttpError::Validation(..) => Rejected
// `DiscordHttpError::is_unsent` for the failures it covers beyond
// `Validation`.
@dhttp.DiscordHttpError::Transport(phase=Pool | Dial, ..)
| @dhttp.DiscordHttpError::Timeout(phase=Pool | Dial, ..) => Unsent
@dhttp.DiscordHttpError::Api(status~, error~) =>
if error.code == 10062 {
Expired
} else if status >= 400 &&
status < 500 &&
status != 429 &&
error.code != 40060 {
Rejected
} else {
Uncertain
}
_ => Uncertain
}
}
///|
/// Construct a gate that delivers its callback through Discord's REST API.
/// The deadline is measured from this call, so construct the gate as soon as
/// the interaction arrives.
pub fn ResponseGate::rest(
client : @dhttp.Client,
interaction : @model.Interaction,
deadline_ms~ : Int32 = DEFAULT_INTERACTION_DEADLINE_MS,
clock~ : &@hclock.Clock = @transport.AsyncClock::new(),
) -> ResponseGate {
ResponseGate(
interaction.typ,
(response, files) => {
client.create_interaction_response(
interaction.id,
interaction.token,
response,
files?,
)
},
classify=classify_rest_callback_failure,
deadline_ms~,
clock~,
)
}
///|
/// Construct an in-memory gate and a receiver for the first callback value.
/// The deadline is measured from this call.
pub fn ResponseGate::capture(
interaction : @model.Interaction,
deadline_ms~ : Int32 = DEFAULT_INTERACTION_DEADLINE_MS,
clock~ : &@hclock.Clock = @transport.AsyncClock::new(),
) -> (ResponseGate, ResponseCapture) {
let queue : @aqueue.Queue[CapturedResponse] = Queue(kind=Blocking(1))
let capture = ResponseCapture::{ queue, }
let gate = ResponseGate(
interaction.typ,
(response, files) => queue.put({ response, files, }),
deadline_ms~,
clock~,
)
(gate, capture)
}
///|
fn callback_allowed(
kind : @model.InteractionType,
typ : @model.InteractionResponseType,
) -> Bool {
match kind {
Ping => typ == Pong
ApplicationCommand =>
typ == ChannelMessageWithSource ||
typ == DeferredChannelMessageWithSource ||
typ == Modal ||
typ == LaunchActivity
MessageComponent =>
typ == ChannelMessageWithSource ||
typ == DeferredChannelMessageWithSource ||
typ == DeferredUpdateMessage ||
typ == UpdateMessage ||
typ == Modal ||
typ == LaunchActivity
ApplicationCommandAutocomplete => typ == Autocomplete
ModalSubmit =>
typ == ChannelMessageWithSource ||
typ == DeferredChannelMessageWithSource ||
typ == DeferredUpdateMessage ||
typ == UpdateMessage
Unknown(_) | @model.InteractionType::.. => true
}
}
///|
/// Validate and deliver the one allowed initial interaction callback.
/// A call made while another callback is being delivered waits for that
/// delivery to settle, then observes its outcome.
pub async fn ResponseGate::send(
self : ResponseGate,
response : @model.InteractionResponse,
files? : Array[@dhttp.FileUpload],
) -> Unit {
self.wait_settled()
match self.state() {
Pending => ()
Expired => raise ResponseGateError::Expired
Sent(_) | Unconfirmed(_) => raise ResponseGateError::AlreadyResponded
}
if !callback_allowed(self.kind, response.typ) {
raise ResponseGateError::InvalidCallback(kind=self.kind, typ=response.typ)
}
self.state = Sent(response.typ)
// Mark the placeholder before the sink runs so a concurrent `reply` never
// mistakes an in-flight deferral for a plain message.
self.placeholder_open = response.typ == DeferredChannelMessageWithSource
self.in_flight = true
defer {
self.in_flight = false
self.settled.broadcast()
}
errdefer (if self.state is Sent(_) { self.state = Unconfirmed(response.typ) })
(self.sink)(response, files) catch {
error if @async.is_being_cancelled() => raise error
error => {
match (self.classify)(error) {
Rejected | Unsent => {
self.state = Pending
self.placeholder_open = false
}
Expired => {
self.state = Expired
self.placeholder_open = false
}
Uncertain => ()
}
raise error
}
}
}
///|
/// Run one follow-up reply at a time. A reply decides between editing the
/// placeholder and sending a followup, then performs the request; without
/// this, two concurrent replies could both see the placeholder open and the
/// second edit would overwrite the first reply.
async fn[T] ResponseGate::exclusive_reply(
self : ResponseGate,
body : async () -> T,
) -> T {
self.reply_lock.acquire()
defer self.reply_lock.release()
body()
}
///|
/// Wait until no initial callback is being delivered. Follow-up operations
/// call this so they never race an in-flight callback.
async fn ResponseGate::wait_settled(self : ResponseGate) -> Unit {
while self.in_flight {
self.settled.wait()
}
}
///|
/// Milliseconds from `now` until `deadline`: zero once it has passed, and at
/// most the largest `Int32`.
fn ms_until(deadline : Int64, now : Int64) -> Int32 {
let left = deadline - now
if left <= 0L {
0
} else if left >= 2147483647L {
2147483647
} else {
left.to_int()
}
}
///|
/// Milliseconds left before the callback window closes, clamped at zero, or
/// `None` when this gate has no deadline.
pub fn ResponseGate::remaining_ms(self : ResponseGate) -> Int32? {
self.deadline_at.map(deadline => ms_until(deadline, self.clock.now_unix_ms()))
}
///|
/// Milliseconds left before the interaction token expires, counted from this
/// gate's construction (when the executor received the interaction). Zero once
/// it has expired.
pub fn ResponseGate::token_remaining_ms(self : ResponseGate) -> Int32 {
ms_until(
self.received_at + INTERACTION_TOKEN_LIFETIME_MS.to_int64(),
self.clock.now_unix_ms(),
)
}
///|
/// Whether a deferred message's loading placeholder is still showing: the
/// callback was `DeferredChannelMessageWithSource` and the original response
/// has not been edited, deleted, or answered with a followup since.
pub fn ResponseGate::placeholder_open(self : ResponseGate) -> Bool {
self.placeholder_open
}
///|
/// Record that the original response no longer shows the deferred
/// placeholder. Response helpers call this after editing or deleting the
/// original response, or after sending a followup.
pub fn ResponseGate::close_placeholder(self : ResponseGate) -> Unit {
self.placeholder_open = false
}
///|
/// The current state of this interaction's initial callback. A pending
/// callback whose deadline has passed reads as `Expired`.
pub fn ResponseGate::state(self : ResponseGate) -> ResponseState {
if self.state == Pending && self.remaining_ms() is Some(0) {
self.state = Expired
}
self.state
}
///|
/// Close an unused callback window. Other states remain unchanged.
///
/// ```mbt check
/// test "expire an unused callback window" {
/// let gate = @framework.ResponseGate(ApplicationCommand, (_, _) => ())
/// assert_eq(gate.state(), Pending)
/// gate.expire()
/// assert_eq(gate.state(), Expired)
/// assert_false(gate.is_pending())
/// }
/// ```
pub fn ResponseGate::expire(self : ResponseGate) -> Unit {
if self.state == Pending {
self.state = Expired
}
}
///|
/// Whether the initial callback can still be sent.
pub fn ResponseGate::is_pending(self : ResponseGate) -> Bool {
self.state() == Pending
}
///|
/// Wait for and remove the captured callback.
pub async fn ResponseCapture::get(self : ResponseCapture) -> CapturedResponse {
self.queue.get()
}
///|
/// Remove the captured callback without waiting, if one is available.
pub fn ResponseCapture::try_get(
self : ResponseCapture,
) -> CapturedResponse? raise {
self.queue.try_get()
}