///|
/// The result of dispatching one HTTP-delivered interaction.
pub(all) enum InteractionOutcome {
Reply(
response~ : @model.InteractionResponse,
files~ : Array[@dhttp.FileUpload]?
)
NoRoute
NoResponse
TimedOut
} derive(Debug)
///|
/// A gateway-free interaction dispatcher backed by a caller-owned task group.
pub struct InteractionEndpoint {
priv framework : @framework.Framework
priv spawner : Spawner
priv warn : (String) -> Unit
}
///|
priv enum InteractionRace {
Captured(@framework.CapturedResponse)
Completed(Bool)
}
///|
/// Build a gateway-free dispatcher.
///
/// When `client` is omitted, the endpoint owns a client created from `token`
/// and closes it when `group` finishes. When `application_id` is omitted, this
/// method always resolves it through `GET /applications/@me`, including when
/// `sync` is false. Supplying both values avoids startup HTTP requests when
/// synchronization is disabled.
///
/// If `sync` is true, command synchronization follows the app's `CommandSync`
/// setting; `CommandSync::Disabled` still performs no synchronization.
pub async fn App::serve(
self : App,
group : @async.TaskGroup[Unit],
client? : @dhttp.Client,
token? : String,
application_id? : @model.ApplicationId,
sync? : Bool = false,
) -> InteractionEndpoint {
self.validate()
let (client, owns_client) = match client {
Some(client) => (client, false)
None => {
guard token is Some(token) && !token.is_empty() else {
raise AppConfigError::EmptyToken
}
(Client(token), true)
}
}
if owns_client {
group.add_defer(() => client.close())
}
let application_id = match application_id {
Some(id) => id
None => client.get_current_application().id
}
if sync {
self.sync_commands(client, application_id)
}
let framework = @framework.Framework(client, application_id)
self.attach(framework, client~, application_id~) |> ignore
{ framework, spawner: self.spawner(group), warn: self.warn_, }
}
///|
/// Dispatch one interaction and wait only for its initial response deadline.
/// A timed-out handler remains attached to the app's task group and continues
/// in the background.
pub async fn InteractionEndpoint::handle_interaction(
self : InteractionEndpoint,
interaction : @model.Interaction,
deadline_ms? : Int = 2500,
) -> InteractionOutcome {
if interaction.typ == Ping {
return Reply(response={ typ: Pong, data: None, }, files=None)
}
let (gate, capture) = @framework.ResponseGate::capture(interaction)
let race : @aqueue.Queue[InteractionRace] = Queue(kind=Unbounded)
(self.spawner)(() => {
let routed = self.framework.process_with(interaction, gate~) catch {
error if @async.is_being_cancelled() ||
@async.is_cancellation_error(error) => raise error
error => {
(self.warn)("interaction processing failed: \{Repr(error)}")
true
}
}
if !gate.responded() {
race.put(Completed(routed))
}
})
@async.with_task_group(group => {
let capture_task = group.spawn(no_wait=true, allow_failure=true, () => {
race.put(Captured(capture.get()))
})
let result = match @async.with_timeout_opt(deadline_ms, () => race.get()) {
Some(Captured(captured)) =>
Reply(response=captured.response, files=captured.files)
Some(Completed(true)) => NoResponse
Some(Completed(false)) => NoRoute
None => TimedOut
}
capture_task.cancel()
result
})
}
///|
/// Decode and dispatch an interaction JSON body.
///
/// Returns callback JSON only for `Reply`. Multipart files cannot be expressed
/// by this thin JSON API; callers that accept uploads should use
/// `handle_interaction` and inspect `InteractionOutcome::Reply.files`.
pub async fn InteractionEndpoint::handle(
self : InteractionEndpoint,
body : Json,
deadline_ms? : Int = 2500,
) -> Json? {
let interaction : @model.Interaction = @json.from_json(body)
match self.handle_interaction(interaction, deadline_ms~) {
Reply(response~, files=_) => Some(response.to_json())
NoRoute | NoResponse | TimedOut => None
}
}