composure 0.2.4

Discord bots over HTTP Interactions, no gateway: slash commands, components, modals and JSX-like views, on Cloudflare Workers (WASM) or any server
Documentation
use std::sync::Arc;

use crate::outline::outlined;
use crate::trace::{Instrument, Span};
use crate::types::{
    ApplicationCommandInteraction, Interaction, InteractionResponse, MessageComponentInteraction,
    ModalSubmitInteraction,
};

use super::{__unshare, CommandSetDefinition, HandlerError, Route, ScopedDefinition};

/// Answers Interactions; the one contract every Adapter talks to. `S` is the Adapter's State.
///
/// An `#[app]` implements this by routing to what it lists. Implement it directly for full
/// control. Every method has a default: unknown command, a "not handled"
/// ephemeral message, or no autocomplete choices.
///
/// Futures are `Send` so Adapters can run each Interaction on any thread (ADR 0012); `async fn`
/// in impls works as long as what it holds across `.await` is `Send`.
pub trait InteractionHandler<S> {
    /// The response a handler's error is answered with: Adapters call it when dispatch fails,
    /// as Background Work does when deferred work fails. [`HandlerError::response`] by default;
    /// an `#[app]` uses its tree's `errors = ..` [`ErrorRenderer`](super::ErrorRenderer).
    fn render_error(&self, error: &HandlerError) -> InteractionResponse {
        error.response()
    }

    /// Answers a Command (chat input, user, or message); defaults to an unknown-command error
    fn command(
        &self,
        command: ApplicationCommandInteraction,
        _state: &S,
    ) -> impl Future<Output = Result<InteractionResponse, HandlerError>> + Send {
        async move { Err(HandlerError::unknown_command(&command.data.name)) }
    }

    /// Answers a button click or select; defaults to a "not handled" ephemeral message
    fn component(
        &self,
        _component: MessageComponentInteraction,
        _state: &S,
    ) -> impl Future<Output = Result<InteractionResponse, HandlerError>> + Send {
        async { __unhandled_component() }
    }

    /// Autocomplete for command options. Defaults to no choices.
    fn autocomplete(
        &self,
        _autocomplete: ApplicationCommandInteraction,
        _state: &S,
    ) -> impl Future<Output = Result<InteractionResponse, HandlerError>> + Send {
        async { __unhandled_autocomplete() }
    }

    /// Answers a modal submit; defaults to a "not handled" ephemeral message
    fn modal_submit(
        &self,
        _modal: ModalSubmitInteraction,
        _state: &S,
    ) -> impl Future<Output = Result<InteractionResponse, HandlerError>> + Send {
        async { __unhandled_modal() }
    }
}

// The defaults' answers, for `#[app]`s without a fallback: answering by reference instead of
// through `NoHandler` keeps the Interactions' `Clone` (what `__unshare` needs) out of the binary.

#[doc(hidden)]
pub fn __unhandled_component() -> Result<InteractionResponse, HandlerError> {
    Ok(not_handled("This bot does not handle components."))
}

#[doc(hidden)]
pub fn __unhandled_autocomplete() -> Result<InteractionResponse, HandlerError> {
    Ok(InteractionResponse::respond_with_autocomplete_choices(
        Vec::new(),
    ))
}

#[doc(hidden)]
pub fn __unhandled_modal() -> Result<InteractionResponse, HandlerError> {
    Ok(not_handled("This bot does not handle modal submissions."))
}

fn not_handled(message: &str) -> InteractionResponse {
    InteractionResponse::respond_with_ephemeral_message(message.to_string())
}

/// Handles nothing: every Interaction gets the [`InteractionHandler`] defaults
#[derive(Debug, Clone, Copy, Default)]
pub struct NoHandler;

impl<S> InteractionHandler<S> for NoHandler {}

/// Routes an Interaction to the handler. Ping is answered with Pong. Render an `Err` with the
/// handler's [`InteractionHandler::render_error`]. A Components V2 message that also sets
/// `content`, `embeds` or a poll is an `Err`, since Discord would reject it.
pub async fn dispatch<S, H: InteractionHandler<S>>(
    interaction: Interaction,
    handler: &H,
    state: &S,
) -> Result<InteractionResponse, HandlerError> {
    let span = span(&interaction);
    async move {
        debug!("interaction received");
        let routed = route(interaction, handler, state).await;
        let checked = routed.and_then(|response| {
            super::response::check_components_v2(&response)?;
            Ok(response)
        });
        let response = match checked {
            Ok(response) => response,
            Err(error) => {
                error.trace("interaction failed");
                return Err(error);
            }
        };
        debug!(response = response_kind(&response), "interaction answered");
        Ok(response)
    }
    .instrument(span)
    .await
}

/// The response's Discord type, as logged by [`dispatch`]
#[cfg(feature = "tracing")]
fn response_kind(response: &InteractionResponse) -> &'static str {
    match response {
        InteractionResponse::Pong => "pong",
        InteractionResponse::ChannelMessageWithSource(_) => "message",
        InteractionResponse::DeferredChannelMessageWithSource(_) => "deferred_message",
        InteractionResponse::DeferredUpdateMessage => "deferred_update",
        InteractionResponse::UpdateMessage(_) => "update",
        InteractionResponse::ApplicationCommandAutocompleteResult(_) => "autocomplete",
        InteractionResponse::Modal(_) => "modal",
    }
}

/// The `interaction` span: what was invoked, where, by whom. Components and modals record their
/// `custom_id` prefix only; the rest is state.
#[cfg(feature = "tracing")]
fn span(interaction: &Interaction) -> Span {
    let (kind, common, name) = match interaction {
        Interaction::Ping(ping) => ("ping", &ping.common, None),
        Interaction::ApplicationCommand(command) => {
            ("command", &command.common, Some(command.data.name.as_str()))
        }
        Interaction::ApplicationCommandAutocomplete(autocomplete) => (
            "autocomplete",
            &autocomplete.common,
            Some(autocomplete.data.name.as_str()),
        ),
        Interaction::MessageComponent(component) => (
            "component",
            &component.common,
            Some(super::custom_id::split(&component.data.custom_id).0),
        ),
        Interaction::ModalSubmit(modal) => (
            "modal_submit",
            &modal.common,
            Some(super::custom_id::split(&modal.data.custom_id).0),
        ),
    };
    info_span!(
        "interaction",
        interaction_id = %common.id,
        interaction_type = kind,
        name,
        guild_id = common.guild_id.as_ref().map(tracing::field::display),
        channel_id = common.channel_id.as_ref().map(tracing::field::display),
        user_id = common.invoking_user().map(|user| tracing::field::display(&user.id)),
    )
}

#[cfg(not(feature = "tracing"))]
fn span(_interaction: &Interaction) -> Span {
    Span
}

async fn route<S, H: InteractionHandler<S>>(
    interaction: Interaction,
    handler: &H,
    state: &S,
) -> Result<InteractionResponse, HandlerError> {
    // Each kind outlined: an isolate's first request compiles only its own kind's routing
    match interaction {
        Interaction::Ping(_) => Ok(InteractionResponse::Pong),
        Interaction::ApplicationCommand(command) => outlined(handler.command(command, state)).await,
        Interaction::MessageComponent(component) => {
            outlined(handler.component(component, state)).await
        }
        Interaction::ApplicationCommandAutocomplete(autocomplete) => {
            outlined(handler.autocomplete(autocomplete, state)).await
        }
        Interaction::ModalSubmit(modal) => outlined(handler.modal_submit(modal, state)).await,
    }
}

/// An App plus a handler for everything it does not handle: components and modals without a
/// matching handler, autocomplete without an Autocomplete Handler, and commands it does not
/// list. Built with the App's `with_fallback`.
#[derive(Debug, Clone, Copy)]
pub struct WithFallback<C, F> {
    commands: C,
    fallback: F,
}

impl<C, F> WithFallback<C, F> {
    /// `commands` (an App) first, `fallback` for the rest; usually built with the App's
    /// `with_fallback`
    pub fn new(commands: C, fallback: F) -> Self {
        Self { commands, fallback }
    }

    /// The App
    pub fn commands(&self) -> &C {
        &self.commands
    }
}

impl<S: Sync, C: Route<S> + Sync, F: InteractionHandler<S> + Sync> InteractionHandler<S>
    for WithFallback<C, F>
{
    fn render_error(&self, error: &HandlerError) -> InteractionResponse {
        super::route::__errors::<C>()(error)
    }

    async fn command(
        &self,
        command: ApplicationCommandInteraction,
        state: &S,
    ) -> Result<InteractionResponse, HandlerError> {
        let command = Arc::new(command);
        match C::dispatch(&command, state, super::route::__errors::<C>()).await {
            Some(result) => result,
            None => self.fallback.command(__unshare(command), state).await,
        }
    }

    async fn component(
        &self,
        component: MessageComponentInteraction,
        state: &S,
    ) -> Result<InteractionResponse, HandlerError> {
        let component = Arc::new(component);
        match C::dispatch_component(&component, state, super::route::__errors::<C>()).await {
            Some(result) => result,
            None => self.fallback.component(__unshare(component), state).await,
        }
    }

    async fn autocomplete(
        &self,
        autocomplete: ApplicationCommandInteraction,
        state: &S,
    ) -> Result<InteractionResponse, HandlerError> {
        let autocomplete = Arc::new(autocomplete);
        match C::dispatch_autocomplete(&autocomplete, state, super::route::__errors::<C>()).await {
            Some(result) => result,
            None => {
                self.fallback
                    .autocomplete(__unshare(autocomplete), state)
                    .await
            }
        }
    }

    async fn modal_submit(
        &self,
        modal: ModalSubmitInteraction,
        state: &S,
    ) -> Result<InteractionResponse, HandlerError> {
        let modal = Arc::new(modal);
        match C::dispatch_modal(&modal, state, super::route::__errors::<C>()).await {
            Some(result) => result,
            None => self.fallback.modal_submit(__unshare(modal), state).await,
        }
    }
}

impl<C: CommandSetDefinition, F> CommandSetDefinition for WithFallback<C, F> {
    fn definitions() -> Vec<ScopedDefinition> {
        C::definitions()
    }
}

#[cfg(test)]
mod tests {
    use futures::executor::block_on;
    use serde_json::json;

    use super::*;
    use crate::types::{Component, MessageCallbackData};

    /// Answers every Command with `data`
    struct Answer(MessageCallbackData);

    impl InteractionHandler<()> for Answer {
        async fn command(
            &self,
            _command: ApplicationCommandInteraction,
            _state: &(),
        ) -> Result<InteractionResponse, HandlerError> {
            Ok(InteractionResponse::ChannelMessageWithSource(
                self.0.clone(),
            ))
        }
    }

    fn command() -> Interaction {
        serde_json::from_value(json!({
            "id": "2", "application_id": "1", "type": 2, "token": "tok", "version": 1,
            "data": { "id": "4", "name": "x", "type": 1 },
        }))
        .unwrap()
    }

    fn v2() -> MessageCallbackData {
        MessageCallbackData::components_v2(vec![Component::text("hi")])
    }

    #[test]
    fn components_v2_with_content_embeds_or_poll_is_an_error() {
        let cases = [
            MessageCallbackData {
                content: Some("hi".into()),
                ..v2()
            },
            MessageCallbackData {
                embeds: Some(vec![crate::types::Embed::new()]),
                ..v2()
            },
        ];
        for data in cases {
            let error = block_on(dispatch(command(), &Answer(data), &())).unwrap_err();
            assert!(error.to_string().contains("Components V2"), "{error}");
        }
    }

    #[test]
    fn components_v2_with_empty_content_passes() {
        let data = MessageCallbackData {
            content: Some(String::new()),
            embeds: Some(Vec::new()),
            ..v2()
        };
        assert!(block_on(dispatch(command(), &Answer(data), &())).is_ok());
        let legacy = MessageCallbackData {
            content: Some("hi".into()),
            ..Default::default()
        };
        assert!(block_on(dispatch(command(), &Answer(legacy), &())).is_ok());
    }
}