Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Composure

Composure is a Rust framework for Discord bots that answer Interactions over HTTP, with no gateway: served from Cloudflare Workers or any server. This guide walks through building one, chapter by chapter; every example in it is compiled as a test.

  1. Getting started: crates, features, a first bot, serving and syncing it
  2. Commands: #[command], Options, Choices, Groups, context menus, autocomplete, Scopes
  3. Responses: what handlers return; ephemeral messages, embeds, files, errors
  4. Components and modals: buttons, selects and forms with #[component], #[modal] and #[derive(Modal)], state in the custom_id
  5. Views: rsx! markup and #[view], with a reference of every tag and attribute
  6. REST and deferring: calling Discord’s API, follow-ups, Deferred Responses
  7. Webhook Events: app installs, entitlements, lobby messages
  8. Platforms: Adapters, State, platform-specific Commands, logging
  9. Testing: running handlers without Discord
  10. Local development, Deploying and Troubleshooting: running a bot, shipping it, and fixing it

The API documentation covers every type and function.

Terms like Command, Option, App, Route, Scope and Sync are capitalized when they mean Composure’s concept of them; see CONTEXT.md for the glossary, and docs/adr/ for why things are the way they are.

Getting started

From an empty crate to a bot answering /hello: define Commands, collect them in an App, serve the App with an Adapter, and sync its Commands with Discord.

How a bot works

Discord sends every Interaction (a slash Command, a button click, a modal submit, an autocomplete request) as a signed HTTP POST to the bot’s Interactions Endpoint URL. The bot verifies the signature, answers within 3 seconds, and can keep working afterwards through the REST API. There is no gateway connection to keep alive, so the bot can run where requests are cheap and start fast: a serverless platform like Cloudflare Workers, or any server.

Composure splits this in three:

  • Your bot, written against composure: Commands and handlers collected into an #[app]. Platform-agnostic.
  • An Adapter, which serves the App on one platform: it reads the request, validates the signature, dispatches to the App, and writes the response. composure_adapter_cloudflare and composure_adapter_tokio exist today.
  • Sync (composure_sync), run at deploy time, which registers the App’s Command Definitions with Discord.

Start from a template

To skip the setup, generate a bot with cargo-generate, then follow Local development to run it:

cargo generate --git https://github.com/BlueFrog130/composure-rs templates/cloudflare --name my-bot
cargo generate --git https://github.com/BlueFrog130/composure-rs templates/tokio --name my-bot

The rest of this chapter builds the same bot by hand.

Setting up

Create an application in the Developer Portal and note its application id, public key and (under Bot) token.

Add the crates. On Cloudflare Workers (a cdylib built with worker-build):

[dependencies]
composure = { version = "0.2", features = ["macros", "tier1"] }
composure_adapter_cloudflare = "0.2"
worker = "0.8"

On a server:

[dependencies]
composure = { version = "0.2", features = ["macros", "tier1"] }
composure_adapter_tokio = "0.2"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

macros brings the attribute macros; tier1 the REST client with the Endpoints most bots need. See feature flags for the rest; leaving REST out keeps a Worker’s bundle smaller. For tests, testing adds stand-ins for Discord and the platform (Testing).

A first Command

use composure::{app, command, command::Ctx};

/// Say hello
#[command]
fn hello(ctx: Ctx) -> String {
    let name = ctx.user().map_or("stranger", |user| user.display_name());
    format!("Hello, {name}!")
}

/// Everything the bot serves
#[app(handlers(hello))]
pub struct Bot;
fn main() {}

The function is the handler; the doc comment, the name and the parameters are the definition Discord shows. Commands covers Options, Groups and more.

Apps

#[app(handlers(..))] on a unit struct makes an App: the handler an Adapter serves and the set of Commands Sync registers. It lists, in any order and mix:

Apps nest, so each module of a bot can declare its own App next to its handlers, and the root App lists the modules’:

mod moderation {
    use composure::{app, command, types::User};

    /// Kick a member
    #[command]
    fn kick(#[option("Who")] user: &User) -> String {
        format!("Kicked {}", user.display_name())
    }

    #[app(handlers(kick))]
    pub struct Moderation;
}

mod fun {
    use composure::{app, command};

    /// Flip a coin
    #[command]
    fn flip() -> &'static str {
        "Heads"
    }

    #[app(handlers(flip))]
    pub struct Fun;
}

use composure::app;

#[app(handlers(moderation::Moderation, fun::Fun))]
pub struct Bot;
fn main() {}

#[app] also takes events = .. (Webhook Events) and errors = .., how handler errors are answered (Styling).

Mistakes that would only show up at runtime fail to compile instead: two Commands with the same name and type, two handlers with the same custom_id prefix, more Commands in a Scope than Discord allows, a sub-View with handlers that no App lists, two Apps setting events (or errors).

An App implements InteractionHandler (what Adapters serve), EventHandler, and CommandSetDefinition (what Sync registers). Interactions nothing in the App handles get a default answer, or go to a fallback set with Bot.with_fallback(handler) (Fallback).

Serving it

Cloudflare Workers

use composure_adapter_cloudflare::CloudflareInteractionBot;
use worker::{Context, Env, Request, Response, event};

#[event(fetch)]
async fn fetch(req: Request, env: Env, ctx: Context) -> worker::Result<Response> {
    CloudflareInteractionBot::new(req, env, ctx)
        .with_handler(Bot)
        .process()
        .await
}

Set the secrets with wrangler secret put DISCORD_PUBLIC_KEY (required) and DISCORD_TOKEN (for the bot client), or load the Credentials another way with .with_credentials(..); deploy, and put https://<worker>.workers.dev/ in the Developer Portal as the Interactions Endpoint URL.

A server, with tokio

use composure_adapter_tokio::TokioInteractionBot;

#[tokio::main]
async fn main() -> Result<(), composure_adapter_tokio::Error> {
    TokioInteractionBot::from_env()? // DISCORD_PUBLIC_KEY, DISCORD_TOKEN, DISCORD_APPLICATION_ID, PORT
        .with_handler(Bot)
        .serve() // 0.0.0.0:$PORT (8080), until Ctrl-C / SIGTERM
        .await
}

from_env is one way to get the Credentials: TokioInteractionBot::new(credentials) takes them from anywhere, the config crate included. It speaks plain HTTP: put it behind a TLS-terminating proxy or tunnel, and use that HTTPS URL as the Interactions Endpoint URL.

Discord checks the URL when you save it (a signed Ping, which the Adapter answers), so the bot must be running first. See Platforms for State, Bot Data (your own configuration), platform-specific Commands and logging.

Syncing Commands

Discord only shows Commands it has stored. composure_sync makes the stored ones match the App: from a host-only binary in your crate,

// src/bin/sync.rs
fn main() -> std::process::ExitCode {
    composure_sync::sync_from_env(&my_bot::Bot)
}
DISCORD_TOKEN=.. DISCORD_APPLICATION_ID=.. cargo run --bin sync

It prints a plan (Commands to create, update and delete, per target), and applies it after confirmation; --dry-run, --check (CI drift check), --yes (CI deploys) and --guild <id> (send everything to one test server) adjust that.

Commands with a Scope go to the guilds listed for it: COMPOSURE_SCOPE_DEV=123,456 for scope = "dev". Sync again whenever a Command’s definition changes; handlers can change freely without it.

On Workers, keep Sync out of the Worker build: put it in a [[bin]] that only builds on the host, as the repository’s examples/cloudflare does.

Next steps

  • Commands: Options, Choices, autocomplete, Groups, context menus
  • Responses: embeds, ephemeral messages, files, errors
  • Views: rich Components V2 messages and modals in markup
  • REST and deferring: calling Discord, answering slowly
  • The repository’s examples/ hold a complete bot served both ways.

Commands

A Command is defined once, as a function with #[command]: the function is the handler, and its signature is the Command Definition synced to Discord.

use composure::{command, types::User};

/// Give someone a cookie
#[command]
fn cookie(
    #[option("Who gets it")] user: &User,
    #[option("How many", min = 1, max = 12)] count: Option<u8>,
) -> String {
    format!("{} gets {} 🍪", user.display_name(), count.unwrap_or(1))
}
fn main() {}
  • The name is the function’s (cookie); the description its doc comment.
  • Each parameter with #[option("description", ..)] is an Option; its Rust type decides the Option’s type and how the value is read.
  • The return value answers the Interaction (see Responses).
  • The function stays an ordinary function you can call; the macro adds a same-named empty struct (cookie) that implements the Command for #[app] and #[group] to list.

Functions may be async. Everything is checked at compile time: names, description lengths, bounds against the parameter type, at most 25 Options, required Options before optional ones.

The context

An optional first parameter ctx: Ctx gives the handler the Interaction and the Adapter’s State:

use composure::{command, command::Ctx};

/// Say hello
#[command]
fn hello(ctx: Ctx) -> String {
    let name = match (ctx.member(), ctx.user()) {
        (Some(member), _) => member.display_name(), // server nickname in guilds
        (None, Some(user)) => user.display_name(),
        (None, None) => "stranger",
    };
    format!("Hello, {name}!")
}
fn main() {}
Ctx method
user()The invoking user, in guilds and DMs
member()The invoking member, in guilds only
guild_id(), channel_id()Where it was invoked
locale()The user’s language
entitlements(), is_entitled(sku)Premium SKUs the user or guild has
interaction(), data()Everything Discord sent
state()The Adapter’s State
client(), bot(), defer(..)REST and Background Work, with a Platform State (REST and deferring)

The State type parameter says which Adapters can serve the Command:

ParameterWorks with
ctx: Ctxany State
ctx: Ctx<impl Platform> (or fn f<S: Platform>(ctx: Ctx<S>))any State meeting the bound; prefer this for REST and deferring
ctx: Ctx<impl MyTrait>any State implementing your trait (see Platforms)
ctx: Ctx<Worker>, ctx: Ctx<Host<MyData>>that one Adapter’s State

An App can then only be served with a State every Command it lists accepts.

Options

Parameter typeOption typeSettings
&str, Stringstringmin_len, max_len (0-6000), autocomplete
i8, i16, i32, i64, u8, u16, u32integermin, max (inside the type’s range), autocomplete
f64numbermin, max, autocomplete
boolboolean
&User, Useruser
ResolvedMemberuser, with their guild membership (fails outside guilds)
&PartialChannel, PartialChannelchannelchannel_types(GuildText, ..)
&Role, Rolerole
UserOrRolementionable
&Attachment, Attachmentattachment
a #[derive(Choice)] enumstring or integer, restricted to its variants
Option<T>T, not requiredas T

Borrowed types (&str, &User, …) read straight from the Interaction without copying; owned ones clone. Integer types narrower than i64 bound the Option to their range automatically, and explicit min/max must stay inside it.

#[option] takes the description first, then any of:

Setting
name = "..."The Option’s name, instead of the parameter’s (1-32 lowercase letters, digits, -, _)
min = N, max = NBounds of integer and number Options
min_len = N, max_len = NLength bounds of string Options
channel_types(GuildText, GuildVoice, ..)ChannelTypes a channel Option offers
autocomplete = handlerSuggestions while typing, from an #[autocomplete] function
use composure::{command, types::{PartialChannel, Role}};

/// Post a role menu
#[command]
fn rolemenu(
    #[option("Where to post it", channel_types(GuildText, GuildAnnouncement))] channel: &PartialChannel,
    #[option("The role to offer")] role: &Role,
    #[option("Menu title", name = "title", max_len = 80)] heading: Option<&str>,
) -> String {
    format!("{} in <#{}> for {}", heading.unwrap_or("Roles"), channel.id, role.name)
}
fn main() {}

When Discord sends a value that cannot be read (a stale definition, a missing resolved user), the handler is not called and the user gets an error message; see Responses.

Command settings

#[command(..)] (and #[group(..)]) accept:

Setting
name = "..."The Command’s name, instead of the function’s
desc = "..."The description, instead of the doc comment (1-100 characters)
scope = "..."Sync to the guilds of this Scope instead of globally
permissions = ..Default member permissions: Permissions::BanMembers, Permissions::ManageMessages | Permissions::KickMembers (Permissions is in scope)
contexts(Guild, BotDm, PrivateChannel)Where it can be used (InteractionContextType)
integration_types(GuildInstall, UserInstall)Which installs offer it (ApplicationIntegrationType)
nsfwAge-restricted
user, messageA context menu Command
use composure::command;

/// Say hi from anywhere: servers, DMs, and group chats, installed to servers or to users
#[command(
    name = "hi",
    contexts(Guild, BotDm, PrivateChannel),
    integration_types(GuildInstall, UserInstall),
)]
fn say_hi() -> &'static str {
    "Hi!"
}
fn main() {}

Choices

#[derive(Choice)] on a fieldless enum restricts an Option to its variants. Discord shows them as a dropdown.

use composure::{Choice, command};

#[derive(Choice)]
pub enum Flavor {
    Vanilla,
    Chocolate,
    #[choice(name = "Mint chip", value = "mint")]
    MintChip,
}

/// Order ice cream
#[command(name = "ice-cream")]
fn ice_cream(#[option("Flavor")] flavor: Flavor) -> String {
    format!("One {}, coming up", flavor.name())
}
fn main() {}
  • A variant’s name (shown to the user) is its identifier unless #[choice(name = "..")] sets it (1-100 characters).
  • Its value (what Discord sends) is the identifier unless #[choice(value = "..")] sets it. If every variant sets an integer #[choice(value = N)], the Option is an integer Option.
  • flavor.name() and flavor.value() give them back.
  • 1-25 variants.

The same enums supply the options of selects and radio groups.

Autocomplete

An #[autocomplete] function suggests values for a string, integer or number Option while the user types. It takes an optional ctx: Ctx and what has been typed so far as input: &str:

use composure::{autocomplete, command};

const FRUITS: [&str; 6] = ["apple", "apricot", "banana", "cherry", "grape", "mango"];

/// Fruits starting with what the user typed
#[autocomplete]
fn fruit_names(input: &str) -> Vec<&'static str> {
    let input = input.to_lowercase();
    FRUITS.into_iter().filter(|fruit| fruit.starts_with(&input)).collect()
}

/// Pick a fruit
#[command]
fn fruit(#[option("Fruit", autocomplete = fruit_names)] name: &str) -> String {
    format!("You picked {name}")
}
fn main() {}

It returns a Vec of suggestions, or a Result of one: strings (shown and sent as is), numbers, (name, value) pairs, or ApplicationCommandOptionChoices (IntoChoice). Discord shows at most 25; the rest are dropped. Autocomplete handlers can be async and use REST like Commands, but must answer within the same 3 seconds, and cannot defer.

Suggestions are only suggestions: the user can still send any value, so validate it in the Command. Options with Choices cannot autocomplete.

Groups

#[group] on a unit struct makes a Command whose subcommands are other Commands (or Groups): /mod ban, /mod kick.

use composure::{command, group, types::User};

/// Ban a member
#[command]
fn ban(#[option("Who")] user: &User) -> String {
    format!("Banned {}", user.display_name())
}

/// Kick a member
#[command]
fn kick(#[option("Who")] user: &User) -> String {
    format!("Kicked {}", user.display_name())
}

/// Moderation tools
#[group(subcommands(ban, kick), permissions = Permissions::BanMembers)]
pub struct Mod;
fn main() {}
  • The name defaults to the struct’s in snake_case; the description to its doc comment.
  • Groups take the same settings as Commands, except user/message. Settings like permissions, contexts and scope belong to the top level: Discord ignores them on subcommands, and scope on a subcommand is a compile error.
  • Groups nest one level: a Group may list Groups whose subcommands are Commands (/settings privacy dms).
  • List the Group (not its subcommands) in the #[app]. A subcommand can still be listed in an App on its own as a top-level Command.

Context menus

#[command(user)] and #[command(message)] make Commands shown in the right-click menu of a user or message. They take one target parameter instead of Options, and their names may contain spaces and capitals:

KindTarget parameter
user&User, User, or ResolvedMember
message&Message, Message
use composure::{command, types::{Message, User}};

/// Shows a user's id
#[command(user, name = "Copy id")]
fn copy_id(target: &User) -> String {
    format!("`{}`", target.id)
}

/// Counts the words in a message
#[command(message, name = "Word count")]
fn word_count(target: &Message) -> String {
    format!("{} words", target.content.split_whitespace().count())
}
fn main() {}

A doc comment is still allowed, but Discord does not show descriptions for context menus.

Scopes

Commands are global by default. scope = "dev" puts a Command (or Group) in a named Scope instead, and Sync sends it to the guilds configured for that Scope (COMPOSURE_SCOPE_DEV=<guild ids>), which is how you keep admin tools to your own server or test Commands before releasing them. See Syncing Commands.

Discord allows 100 chat-input, 15 user and 15 message Commands per Scope; an App exceeding that, or listing two Commands with the same name and type, fails to compile.

Listing Commands

A Command does nothing until an #[app] lists it; see Getting started. The macros implement these traits, should you need to write one by hand: CommandDefinition and SubcommandDefinition (the definition), Command (the handler), and RouteDefinition / Route (what an App lists). OptionType and CommandTarget make your own types usable as parameters.

Responses

What a handler returns is how it answers. Commands, component and modal handlers, and inline View handlers all answer the same way: return anything implementing IntoResponse, or a Result of one.

use composure::{command, command::Ephemeral, types::Embed};

/// Plain text
#[command]
fn ping() -> &'static str {
    "Pong!"
}

/// Only you can see this
#[command]
fn secret() -> Ephemeral<String> {
    Ephemeral(format!("The answer is {}", 6 * 7))
}

/// An embed
#[command]
fn info() -> Embed {
    Embed::new().with_title("Composure").with_description("Interactions only")
}
fn main() {}

Discord gives a bot 3 seconds to answer an Interaction. Work that may take longer answers with a Deferred Response instead and finishes in the background; see REST and deferring.

What can be returned

TypeAnswer
String, &strA message with that content
EmbedA message with one embed
ContainerA Components V2 message of one container
View, a #[view]’s PropsA Components V2 message (Views)
ModalView, a modal #[view]’s PropsOpens a modal
InteractionResponseExactly that; anything Discord accepts
Deferred“Thinking…”, then the result of Background Work (ctx.defer)
DeferredUpdateNothing visible yet, then an edit of the component’s message (ctx.defer_update)
Ephemeral<T>T, visible only to the user who invoked it
Update<T>T as an edit of the message the component is on (components and modals opened from one)
Result<T, E> with E: DisplayT, or an error message

The wrappers compose: Ephemeral(embed), Update(view), Ephemeral(ctx.defer(..)), Result<Ephemeral<String>, HandlerError>.

Ephemeral on a deferral makes the “thinking” message ephemeral, and the final message with it: ephemerality is decided when the Interaction is first answered and cannot change later.

Building messages by hand

InteractionResponse is Discord’s interaction response, with constructors for the common shapes:

use composure::{
    command,
    types::{Embed, InteractionResponse, MessageCallbackData, MessageFlags},
};

/// Two embeds, without a notification
#[command]
fn compare() -> InteractionResponse {
    InteractionResponse::ChannelMessageWithSource(MessageCallbackData {
        content: Some("Before and after:".into()),
        embeds: Some(vec![Embed::new().with_title("Before"), Embed::new().with_title("After")]),
        flags: Some(MessageFlags::SuppressNotifications),
        ..Default::default()
    })
}
fn main() {}

A Components V2 message (one with MessageFlags::IsComponentsV2, as built by respond_with_components, MessageCallbackData::components_v2, a Container or a View) is laid out entirely by its components: Discord rejects content, embeds and polls alongside it, so text goes in text displays.

Discord’s images

Avatars, guild icons and banners, role icons, emojis and stickers are served by Discord’s CDN. Their types build the URLs: User::display_avatar_url, Member::guild_avatar_url, Guild::icon_url, Emoji::url, … Each *_url has a *_url_with(format, size) variant; animated images are GIFs unless another ImageFormat is asked for.

use composure::{
    command,
    types::{Embed, EmbedThumbnail, User},
};

/// Show someone's avatar
#[command]
fn avatar(#[option("Whose")] user: &User) -> Embed {
    let url = user.display_avatar_url_with(None, Some(256));
    Embed::new()
        .with_title(user.display_name())
        .with_thumbnail(EmbedThumbnail::new(url, None, None, None))
}
fn main() {}

Files

with_file attaches a File to a message response. Reference it from an embed or a component as attachment://<name>.

use composure::{command, types::{File, InteractionResponse}};

/// Get your text back as a file
#[command]
fn export(#[option("What to put in the file", max_len = 2000)] text: &str) -> InteractionResponse {
    InteractionResponse::respond_with_message("Here's your file:".into())
        .with_file(File::new("export.txt", text.as_bytes()).content_type("text/plain"))
}
fn main() {}

A View carries its files itself: <File src={file}/> uploads the File with the message and points the component at it.

A response with files is multipart, which an HTTP reply to Discord cannot be: the Adapter sends it to the callback endpoint itself and answers Discord’s request with 202. Deferred results with files are sent with the edit of the original response.

Errors

A handler that returns Err is not answered with Discord’s “This interaction failed”: the Adapter logs the error and answers with an ephemeral message instead. What the user reads depends on the HandlerError kind:

ErrorThe user seesLogged at
HandlerError::user(msg)msgDEBUG
HandlerError::new(msg), any other error“Something went wrong handling this interaction.”ERROR
An Option that could not be read (ErrorKind::InvalidOption)the same generic messageWARN
A Command the App does not list (ErrorKind::UnknownCommand)“Unknown command.”WARN

#[app(errors = ..)] answers errors your way instead, say as a Components V2 message in your colors (Styling).

Any E: Display works as the error type, and any std::error::Error converts into a HandlerError with ?, so REST errors, parse errors and the like propagate as they are. HandlerError::user is for errors the user can act on:

use composure::{command, command::{Ephemeral, HandlerError}};

/// Roll dice, like 2d6
#[command]
fn roll(#[option("Dice, like 2d6")] dice: &str) -> Result<Ephemeral<String>, HandlerError> {
    let (count, sides) = dice
        .split_once('d')
        .ok_or_else(|| HandlerError::user("Write dice like `2d6`."))?;
    let count: u32 = count.parse()?; // a ParseIntError: logged, generic message
    let sides: u32 = sides.parse()?;
    Ok(Ephemeral(format!("Max roll: {}", count * sides)))
}
fn main() {}

A converted error keeps its causes (its source chain) for the logs: {} prints only the message, as before, while {:#} and the sources log field show the rest, like request failed: connection reset. with_source adds a cause to an error you build yourself, without showing it to the user:

use composure::{command, command::HandlerError};

/// Read the settings file
#[command]
fn settings() -> Result<String, HandlerError> {
    std::fs::read_to_string("settings.toml")
        .map_err(|error| HandlerError::user("Settings are unavailable.").with_source(error))
}
fn main() {}

An unknown command or an Option that cannot be read usually means Discord’s stored Commands are out of date: Sync again.

Your own types

Implement IntoResponse to return your own types directly:

use composure::{command, command::IntoResponse, types::{Embed, InteractionResponse}};

pub struct Forecast { pub city: String, pub celsius: f32 }

impl IntoResponse for Forecast {
    fn into_response(self) -> InteractionResponse {
        Embed::new()
            .with_title(&self.city)
            .with_description(&format!("{:.1} °C", self.celsius))
            .into_response()
    }
}

/// Today's weather
#[command]
fn weather() -> Forecast {
    Forecast { city: "Oslo".into(), celsius: -3.5 }
}
fn main() {}

Override try_into_response as well when building the response can fail; handlers call that one.

Components and modals

Buttons, select menus and modals send their own Interactions when used. Each carries the custom_id the bot gave the component, and Composure routes it to the handler whose prefix starts it: vote:12:1 goes to the handler for vote, which receives 12 and 1 as its state.

This chapter builds components by hand and answers them with #[component] and #[modal]. Views do the same with markup and inline handlers; the two mix freely.

Component handlers

use composure::{
    app, command, component,
    command::{Update, custom_id::CustomIdError},
    types::{ButtonStyle, Component, InteractionResponse, MessageCallbackData},
};

/// A message with a button that counts its clicks
#[command]
fn counter() -> Result<InteractionResponse, CustomIdError> {
    Ok(InteractionResponse::ChannelMessageWithSource(counter_message(0)?))
}

/// The count lives in the button's custom_id, so nothing is stored
#[component("counter")]
fn counter_click(count: u32) -> Result<Update<InteractionResponse>, CustomIdError> {
    Ok(Update(InteractionResponse::ChannelMessageWithSource(counter_message(count + 1)?)))
}

fn counter_message(count: u32) -> Result<MessageCallbackData, CustomIdError> {
    let button = Component::new_button(
        ButtonStyle::Primary,
        Some("+1".into()),
        None,
        Some(counter_click::custom_id(count)?), // "counter:<count>"
        None,
        None,
    );
    Ok(MessageCallbackData {
        content: Some(format!("Clicked {count} times")),
        components: Some(vec![Component::row(vec![button])]),
        ..Default::default()
    })
}

#[app(handlers(counter, counter_click))]
pub struct Bot;
fn main() {}

#[component("prefix")] on a function:

  • takes an optional leading ctx: ComponentCtx (with a State, like a Command’s Ctx), then the state parameters
  • adds counter_click::custom_id(state..), which encodes the prefix and the state into a custom_id, or fails with a CustomIdError past Discord’s 100 characters
  • answers like a Command: a new message by default, Update(..) to edit the message the component is on, ctx.defer_update(..) to edit it later, a modal, …

The prefix is 1-100 characters without : or \, and unique within the App (checked at compile time). Keep it short: it counts toward the 100.

ComponentCtx has everything Ctx has, plus values() for the selected values of a select menu (option values, or user, role and channel ids). The message the component is on is ctx.interaction().common.message(), parsed on first use.

use composure::{component, command::{ComponentCtx, Ephemeral}};

/// The selected values of a string select with custom_id "colors"
#[component("colors")]
fn pick_colors(ctx: ComponentCtx) -> Ephemeral<String> {
    Ephemeral(format!("You picked {}", ctx.values().join(", ")))
}
fn main() {}

State in the custom_id

Handlers are stateless: what a handler needs to know about the component that was used is encoded in its custom_id (ADR 0007). The format is prefix:field:field.., each field \-escaped, written in declaration order (structs and tuples flatten):

TypeEncoded as
integers, floats, char, strings, Snowflakethe value
bool1 / 0
Option<T>empty for None (so Some(String::new()) reads back as None)
enumsthe variant index, then its fields
sequences, mapsthe length, then the items

State parameters must be owned (String, not &str) and implement serde’s Serialize and DeserializeOwned; for your own structs and enums, derive them. The format does not describe itself, so changing a handler’s state types breaks the components already sent: give the new version a new prefix if old messages must keep working.

The encoding is available directly as custom_id::encode, decode and split.

Modals

A modal is a form Discord shows over the chat. It is opened as the response to a Command or component (not sent as a message), and its submit is a new Interaction, answered by a #[modal] handler.

Forms

#[derive(Modal)] describes a form as a struct: each field is one input inside a labeled Label, and its type decides whether it is required and how its value is read.

use composure::{Choice, Modal, app, command, modal,
    command::{Ephemeral, custom_id::CustomIdError}, types::{Attachment, InteractionResponse}};

#[derive(Choice, Debug)]
pub enum Kind { Bug, Idea, #[choice(name = "Something nice")] Praise }

#[derive(Modal)]
#[modal(title = "Feedback")]
pub struct FeedbackForm {
    #[input(max_len = 100, placeholder = "What is it about?")]
    topic: String,
    #[radio(label = "Kind")]
    kind: Kind,
    #[input(paragraph, max_len = 1000, description = "Optional")]
    details: Option<String>,
    #[file_upload(max = 3, file_types("image"))]
    screenshots: Vec<Attachment>,
    #[checkbox(label = "You may contact me about this")]
    contact: bool,
}

/// Send feedback
#[command]
fn feedback() -> Result<InteractionResponse, CustomIdError> {
    on_feedback::show() // the modal response opening the form
}

/// The submitted form arrives typed
#[modal("feedback")]
fn on_feedback(form: FeedbackForm) -> Ephemeral<String> {
    Ephemeral(format!(
        "{:?} **{}**: {} ({} screenshots, contact: {})",
        form.kind, form.topic, form.details.as_deref().unwrap_or("-"),
        form.screenshots.len(), form.contact,
    ))
}

#[app(handlers(feedback, on_feedback))]
pub struct Bot;
fn main() {}

On the struct, #[modal(title = "...", text = "markdown")]: the title (max 45 characters) and text displays shown above the inputs (text may repeat). At most 5 fields and texts together.

Each field takes one attribute naming its input (#[input] when there is none):

AttributeInputSettings besides label, description, id
#[input]TextInputparagraph, min_len, max_len, placeholder, value
#[select]StringSelectplaceholder, min, max, option("Label", "value")..
#[user_select], #[role_select], #[mentionable_select]entity selectsplaceholder, min, max
#[channel_select]ChannelSelectthose, and channel_types(GuildText, ..)
#[radio]RadioGroupoption("Label", "value")..
#[checkboxes]CheckboxGroupmin, max, option("Label", "value")..
#[checkbox]Checkboxdefault
#[file_upload]FileUploadmin, max, file_types("image", ".pdf")
  • label is the title above the input (default: the field name), description a line under it, id the input’s custom_id (default: the field name).
  • Without option(..)s, a #[derive(Choice)] field type supplies the options.
  • The field type (ModalValue) is String, bool, Snowflake, User, Role, PartialChannel, Attachment or a Choice enum, alone (required), in an Option (optional) or in a Vec (optional, several). A type the input cannot submit (a bool text input, a Vec radio group) is a compile error.

#[modal("prefix")] on a function taking an optional ctx: ModalCtx, then the form, then state parameters (as for components). It adds:

  • name::show(state..): the InteractionResponse opening the form, with the state in its custom_id
  • name::custom_id(state..): the custom_id alone, for a modal built some other way

ModalCtx reads submitted values by custom_id too: value(id) for text inputs and radio groups, values(id) for selects, checkbox groups and uploads, checked(id) for checkboxes.

A modal opened from a component can Update that component’s message when submitted.

Fallback

Components whose prefix no listed handler claims (left over from an older version, or handled some other way) get a “not handled” ephemeral message. Bot.with_fallback(handler) sends them, and modals, autocomplete and Commands nothing claims, to any InteractionHandler instead; see WithFallback.

use composure::{app, command, command::{HandlerError, InteractionHandler},
    types::{InteractionResponse, MessageComponentInteraction}};

/// Old buttons
struct Legacy;

impl<S: Sync> InteractionHandler<S> for Legacy {
    async fn component(
        &self,
        component: MessageComponentInteraction,
        _state: &S,
    ) -> Result<InteractionResponse, HandlerError> {
        Ok(InteractionResponse::respond_with_ephemeral_message(format!(
            "`{}` is from an older version; run the command again.",
            component.data.custom_id
        )))
    }
}

/// Ping
#[command]
fn ping() -> &'static str { "Pong" }

#[app(handlers(ping))]
pub struct Bot;

fn main() {
let handler = Bot.with_fallback(Legacy); // hand this to the Adapter
let _ = handler;
}

Views

A View is a message or modal layout written as markup with rsx!, and optionally a function of Props with #[view] whose buttons, selects and modal submits are handled inline. Markup builds the same types a hand-written response would, so everything here can also be done with Component constructors; markup is shorter, checks placement at compile time, and keeps a component next to the code that answers it.

use composure::{app, command, rsx, view, command::View};

/// A card that counts its likes; the count is carried in the button's `custom_id`
#[view]
pub fn Card(title: String, likes: u32) -> View {
    rsx! {
        <Container>
            "## " {title.as_str()}
            <Text>"-# " {likes} " likes"</Text>
            <Row>
                <Button primary on:click:like=move |_| Card { likes: likes + 1, title }>
                    "Like"
                </Button>
            </Row>
        </Container>
    }
}

/// Show a card
#[command]
fn card() -> Card {
    Card { title: "Hello".into(), likes: 0 }
}

#[app(handlers(card, Card))]
pub struct Bot;
fn main() {}

/card answers with the card. Clicking Like runs the closure, which returns new Props; the message the button is on is re-rendered with them. Nothing is stored anywhere: title and likes travel in the button’s custom_id.

Markup

rsx! takes JSX-like markup and evaluates to a View (a Components V2 message) or, when its only root is <Modal>, a ModalView. Both implement IntoResponse, so a Command can return them directly.

use composure::{command, rsx, command::View};

/// Show a status panel
#[command]
fn status() -> View {
    let healthy = 3;
    let total = 4;
    rsx! {
        <Container accent_color=0x57F287>
            "## Status"
            <Separator/>
            "Healthy: **" {healthy} "/" {total} "**"
        </Container>
    }
}
fn main() {}

Tags

PascalCase tags are either built-in (Discord’s components, listed in the tag reference) or the name of a #[view] function placed as a sub-View. Unknown lowercase tags are a compile error. Tags are self-closing (<Separator/>) or have children (<Row>..</Row>).

Placement follows Discord’s rules and is checked at compile time:

WhereWhat can go there
Top of a messagetext, <Text>, <Container>, <Section>, <Gallery>, <File>, <Separator>, <Row>
<Container>the same, except another <Container>
<Section>text and <Text> (1-3), plus one accessory: a <Button> or <Thumbnail>
<Row>up to 5 <Button>s, or one select
<Gallery><Item>s
select, <RadioGroup>, <CheckboxGroup><Option>s
<Modal> (the only root)text, <Text>, <Label>
<Label>exactly one input: <TextInput>, a select, <RadioGroup>, <CheckboxGroup>, <Checkbox>, <FileUpload>

A sub-View’s markup may also be a single <Button> or select meant for a row.

Discord’s limits are checked, so breaking one is an error that names it instead of Discord’s bare “This interaction failed”. What the markup shows is checked at compile time: more than 5 buttons in a <Row> (or a select with anything beside it), more than 3 texts in a <Section>, more than 5 children in a <Modal>, more than 25 <Option>s in a select (10 in a group) or 10 <Item>s in a <Gallery>. The rest (40 components in a message, 4000 characters of text, custom_ids past 100 characters, button labels past 80, levels filled by {..}) is checked in one walk when the View is answered with or sent through REST, and fails as a ViewError (a HandlerError in handlers). The limits are types::limits.

Text

String literals are text. Adjacent literals and {expressions} join into one TextDisplay, so these are the same:

use composure::rsx;
let name = "Ada";
let a =
rsx! { "Hello, **" {name} "**!" }
;
let b =
rsx! { <Text>"Hello, **" {name} "**!"</Text> }
;
assert_eq!(format!("{:?}", a.components()), format!("{:?}", b.components()));

Text is Discord markdown: # headings, -# subtext, **bold**, lists, links, mentions. A <Text> (or a component) ends the current run of text; use separate <Text> tags for separate text displays, for instance the 1-3 of a <Section>.

Text cannot go in a <Row>. Bare words without quotes are a compile error (put text in quotes).

Expressions

{expr} evaluates Rust inside markup. Where components can go, the value is anything the parent takes (each parent has a trait, like ContainerChild or RowChild):

ValueBecomes
&str, String, &String, char, integers, floats, Snowflake, format_args!(..)text, joined with adjacent text
Component, and the component structs the parent takes (ButtonComponent in a <Row>, …)that component
View, a #[view]’s Propsits components, in place
Option<T>nothing for None
Vec<T>, arrays, iterator adapters (map, filter, filter_map, flat_map, flatten, chain, take, skip, rev, once, vec::IntoIter)each item in order

So lists are a map, and conditionals are an Option:

use composure::{rsx, command::View};

fn shopping(items: &[&str], note: Option<&str>) -> View {
    rsx! {
        <Container>
            "## Shopping list"
            {items.iter().map(|item| rsx! { <Text>"- " {*item}</Text> })}
            {note.map(|note| rsx! { <Separator/> <Text>"-# " {note}</Text> })}
        </Container>
    }
}
fn main() { assert_eq!(shopping(&["eggs"], None).components().len(), 1); }

Other iterators (Peekable, Zip, …) need .collect::<Vec<_>>() first.

In text-only places (<Text>, a <Button>’s label, an <Option>’s label) the value is anything implementing IntoText: strings, numbers, Snowflake, and Options of them. Call .to_string() on other Display types.

Attributes

Attributes are written name=value; the value is a Rust expression (a literal, a variable, a call, a block {..}). A bare attribute is true: <Button disabled> is <Button disabled=true>. Each attribute may appear once.

As in Leptos’s view!, markup is a chain of builder calls: a tag is its type’s builder() (<Container> is Container::builder()), each attribute a method of the builder (ContainerBuilder::accent_color), each child a .child(..), then .build(). That means:

  • the builder’s methods are the attributes, each documented; your editor completes and documents them as you type
  • values are converted with IntoAttr: a &str works for a String or Option<String>, a T for an Option<T>, "👍" for an emoji
  • an attribute the tag does not have is a compile error from the compiler (no method named colourfound for structContainerBuilder``, with the closest name)
  • so is a child where Discord does not allow it (`&str` cannot go in a `<Row>`), pointing at the child

The builders also work by hand, outside markup.

Values that contain <..> (generics, closures with typed parameters) must be wrapped in braces, or the markup parser reads < as a tag: options={Vec::<SelectOption>::new()}.

Comments

<!-- comments --> are allowed anywhere and produce nothing.

Errors

rsx! reports every error in the markup at once, not just the first: a missing attribute, an unclosed or mismatched tag; and the compiler reports misplaced tags and unknown attributes. Each error points at the token that caused it, and repeats of the same error are dropped. The markup is still expanded, so the {..} blocks around a mistake keep type-checking. A #[view] with markup errors still gets its Props, builder and handlers, so code using the View doesn’t report errors of its own.

Your editor sees the builders: hovering <Container> (or </Container>) shows the type’s docs and go-to-definition opens it, attributes complete and show their docs like methods, and a tag being typed (<Con, even inside another tag) completes from the built-in tags and the sub-Views in scope.

View functions

#[view] turns a function returning View (or ModalView) into a component of its own. The parameters are its Props, which must be owned types:

use composure::{rsx, view, command::View};

/// A labeled progress bar
#[view]
pub fn Progress(label: String, done: u32, #[prop(default = 10)] total: u32, note: Option<String>) -> View {
    let filled = (done * 10 / total.max(1)).min(10) as usize;
    rsx! {
        <Text>
            "**" {label.as_str()} "** " {"▰".repeat(filled)} {"▱".repeat(10 - filled)}
            {note.map(|note| format!("\n-# {note}"))}
        </Text>
    }
}
fn main() {}

The macro keeps the function body and adds:

  • a Props struct with the same name and one public field per parameter: Progress { label, done, total, note }. Returning it from a Command (or a handler) sends the View; Progress { .. }.render() gives the View itself.
  • a builder, Progress::builder().label("Build").done(3).build(), which is what markup uses. Setters take anything IntoAttr converts, and leaving out a required Prop is a compile error.
  • IntoResponse, IntoView, Rerender for the Props, and the ViewHandler running its inline handlers.

Props:

PropIn markup
name: Trequired
name: Option<T>optional, None when left out; name=value sets Some(value)
#[prop(default = expr)] name: Toptional, expr when left out
children: Viewthe tag’s children (see below); not usable in handlers

Sub-Views

A View is placed in markup as a tag, with its Props as attributes:

use composure::{rsx, view, command::View};
#[view]
pub fn Progress(label: String, done: u32, #[prop(default = 10)] total: u32, note: Option<String>) -> View {
    rsx! { <Text>{label.as_str()}</Text> }
}
#[view]
pub fn Build(step: u32) -> View {
    rsx! {
        <Container>
            "## Build"
            <Progress label="Compile" done=step/>
            <Progress label="Test" done={step.saturating_sub(3)} total=7 note="flaky"/>
        </Container>
    }
}
fn main() {}

Tag names may be paths (<widgets::Progress done=1 label="x"/>). A sub-View with inline handlers must be listed in the same #[app] as the View placing it (or in any App of the tree); forgetting it is a compile error from #[app].

Children

A children: View Prop receives the markup between the tags, which lets a View wrap others:

use composure::{rsx, view, command::View};

/// A titled frame
#[view]
pub fn Panel(title: String, children: View) -> View {
    rsx! {
        <Container>
            "### " {title}
            <Separator/>
            {children}
        </Container>
    }
}

fn main() {
let view = rsx! {
    <Panel title="Inbox">
        <Text>"No new messages"</Text>
    </Panel>
};
assert_eq!(view.components().len(), 1);
}

Inline handlers

An on:<event>:<name>=closure attribute gives a component its own handler, inside the View that renders it. The macro turns each into an arm of the View’s handler, routed by the custom_id <prefix>:<name>:<state>:

  • <prefix> is the View’s name in snake_case (Card → card); change it with #[view(prefix = "c")] to save characters
  • <name> is the handler’s name, unique within the View
  • <state> holds the Props the closure uses, found by reading its body (format!("{name}") counts), encoded with custom_id

When the component is used, the Props are decoded and the closure runs with them in scope. Its return value answers the Interaction:

  • a View’s Props or a View (or a Result of one) re-renders the message the component is on (an Update)
  • anything else a Command can return (CommandOutput): a new message, Ephemeral(..), a ModalView to open a modal, ctx.defer_update(..), …
EventTagsClosure
on:click:name<Button>move |ctx| ..
on:select:nameselectsmove |ctx, values: T| ..
on:submit:name<Modal>move |ctx, field: T, ..| .. (Modal Views)

ctx is a ComponentCtx (a ModalCtx for submits); write _ when unused. Untyped, it works with any State; annotate it to require one, as Commands do: {move |ctx: ComponentCtx<impl Platform>| ..} (braced, because of the <..>). Closures may be async.

use composure::{rsx, view, command::{ComponentCtx, Ephemeral, Platform, View}};

#[view]
pub fn Vote(question: String, yes: u32, no: u32) -> View {
    rsx! {
        <Text>"**" {question.as_str()} "**\n" {yes} " yes, " {no} " no"</Text>
        <Row>
            <Button success on:click:yes=move |_| Vote { yes: yes + 1, question, no }>"Yes"</Button>
            <Button danger on:click:no=move |_| Vote { no: no + 1, question, yes }>"No"</Button>
            <Button on:click:who=move |ctx| {
                Ephemeral(format!("You are {}", ctx.user().map_or("nobody", |u| u.display_name())))
            }>"Who am I?"</Button>
        </Row>
    }
}
fn main() {}

Things to know:

  • The custom_id is limited to 100 characters, prefix and handler name included. Past that, answering with the View fails with a HandlerError (the error rides inside the View until then). Keep captured Props small: ids and counters, not text.
  • Captured Props must implement serde::Serialize and DeserializeOwned; integers, bool, String, Snowflake, Options, tuples, and #[derive(Serialize, Deserialize)] types do.
  • A children Prop cannot be captured: it is markup, not data.
  • A handler only sees Props. Using a local of the #[view] body (let total = ..) or a closure’s parameter around the markup ({items.iter().map(|item| rsx! { .. })}) is a compile error at its use (total is not a Prop), as is a local that shadows a Prop. Make it a Prop, or move the markup into a sub-View that takes it as one.
  • List every View with inline handlers in an #[app]; that is how its custom_ids are routed.

Selects

An on:select closure takes the selected values as its second parameter, typed with SelectValue: String, Snowflake, a resolved User, Role or PartialChannel, or a #[derive(Choice)] enum, each alone (one required selection), in an Option (none allowed) or a Vec (several; max then defaults to every option).

use composure::{Choice, rsx, view, command::View};

#[derive(Choice, Debug)]
pub enum Topping { Cheese, Olives, Basil }

#[view]
pub fn Pizza(picked: String) -> View {
    rsx! {
        <Text>"Toppings: " {picked.as_str()}</Text>
        <Row>
            <StringSelect placeholder="Pick toppings" options=Topping
                on:select:pick={move |_ctx, toppings: Vec<Topping>| Pizza {
                    picked: format!("{toppings:?}"),
                }}/>
        </Row>
    }
}
fn main() {}

Handlers defined elsewhere

on:click, on:select and on:submit without a name point at a #[component] or #[modal] handler instead, with the state to put in its custom_id: on:click=vote(poll, true) sets custom_id = vote::custom_id(poll, true), and on:click=refresh calls refresh::custom_id(). These work in any rsx!, not only in #[view]s. Or set custom_id=.. yourself and answer it however you like.

A #[view] returning ModalView with a <Modal> root is a form. With an inline on:submit, each input’s name matches one of the closure’s parameters, which receive the submitted values typed:

use composure::{Choice, app, command, rsx, view, command::{Ephemeral, ModalView}};

#[derive(Choice, Debug)]
pub enum Severity { Low, High }

#[view]
pub fn Report(page: String) -> ModalView {
    rsx! {
        <Modal title="Report a problem" on:submit:send={move |_ctx, what: String, severity: Severity, notify: bool| {
            Ephemeral(format!("{page}: {what} ({severity:?}, notify: {notify})"))
        }}>
            <Text>"Reporting **" {page.as_str()} "**"</Text>
            <Label text="What happened?">
                <TextInput name="what" paragraph max_len=500/>
            </Label>
            <Label text="Severity">
                <RadioGroup name="severity" options=Severity/>
            </Label>
            <Label text="Notify me">
                <Checkbox name="notify"/>
            </Label>
        </Modal>
    }
}

/// Report a problem with a page
#[command]
fn report(#[option("Which page")] page: String) -> Report {
    Report { page }
}

#[app(handlers(report, Report))]
pub struct Bot;
fn main() {}

Returning the Props opens the modal. The parameter types (anything implementing ModalValue) decide what each input requires, and mismatches are compile errors:

TypeRequiredInputs
String, a Choice enumyes<TextInput>, <RadioGroup>, <StringSelect>
Option<String>, Option<Choice>nothe same
boolno (checkboxes never are)<Checkbox>
Snowflake, User, Role, PartialChannelyesentity selects
Attachmentyes<FileUpload>
Option<..> of thosenothe same
Vec<..> of those, of String, of a Choiceno; several allowedselects, <CheckboxGroup>, <FileUpload>

Every input needs a parameter and every parameter an input. Inputs in a modal never take on: attributes of their own; the <Modal>’s submit reads them all.

A modal can also be submitted to a #[modal] handler (on:submit=rename(id)) or answered by custom_id=..; inputs then need no matching parameters, and their name is the custom_id the handler reads them by.

Message options

A View is the whole message, not just its components. These set the rest; they work on any View, rsx!’s output or a sub-View’s (whose options and files go to the View it is placed in):

Method
.ephemeral()Only the invoking user sees it. An update keeps the message’s ephemerality
.allowed_mentions(..)Who it may ping (AllowedMentions)
.suppress_notifications()No push or desktop notification
.file(..)Uploads a File with the message

<File>, <Thumbnail> and <Item> take a File as their src (or url): the View uploads it and points the component at attachment://<name>, so the names cannot drift apart.

use composure::{command, rsx, command::View, types::File};

/// Get your text back as a file
#[command]
fn export(#[option("What to put in the file", max_len = 2000)] text: &str) -> View {
    let file = File::new("export.txt", text.as_bytes()).content_type("text/plain");
    rsx! {
        <Text>"Here's your file:"</Text>
        <File src={file}/>
    }
    .ephemeral()
}
fn main() {
    let view = export("hi");
    assert_eq!(view.files()[0].name, "export.txt");
}

Ephemeral(view) still works, and a View with files is sent like any response with files (see Files).

Tag reference

Each tag builds the type named in its heading; its attributes are that type’s public fields plus the special ones listed. id (an i32, unique within the message) is accepted by every tag except <Modal>, <Item> and <Option>, and is omitted below.

Layout

<Container>

Container: a frame around other components, like an embed, with an optional accent bar. Top level only. Children: text, <Text>, <Section>, <Gallery>, <File>, <Separator>, <Row>, {..}.

AttributeType
accent_coloru32 (0xRRGGBB)Color of the bar on the left; none when unset
spoilerboolBlurs the content until clicked
<Container accent_color=0x5865F2 spoiler> .. </Container>

<Section>

Section: 1-3 text displays beside an accessory. Children: text and <Text>, plus exactly one <Button> or <Thumbnail>, which becomes the accessory wherever it is placed among the children.

AttributeType
accessoryimpl Into<Component>Special. The accessory as a value, instead of a child
<Section>
    <Text>"## Ada Lovelace"</Text>
    <Text>"Mathematician"</Text>
    <Thumbnail url=avatar_url/>
</Section>

<Text> (alias <TextDisplay>)

TextDisplay: markdown. Children: text and {..} only. Bare text outside a <Text> makes one too.

AttributeType
contentStringSpecial. The text, instead of children

<Separator>

Separator: vertical space, with a divider line by default. No children.

AttributeType
small / largeflagSpecial. Small (default) or large spacing
spacingSeparatorSpacingThe same, as a value
dividerboolfalse for space only, no line
<Separator large divider=false/>

<Row> (alias <ActionRow>)

ActionRow: up to 5 <Button>s, or one select. Children: those and {..}; no text.

Content

<Thumbnail>

Thumbnail: a small image, as a <Section>’s accessory only. No children.

AttributeType
src (or url)a URL, a File or an &AttachmentSpecial, required. An image URL (or attachment://<filename>), a File to upload with the message, or an attachment Discord has
descriptionStringAlt text, max 1024 characters
spoilerboolBlurs the image until clicked

MediaGallery: a grid of 1-10 images and videos. Children: <Item>s, and {..} of MediaGalleryItems.

<Item> (alias <MediaGalleryItem>)

MediaGalleryItem: one image or video in a <Gallery>. No children.

AttributeType
src (or url)a URL, a File or an &AttachmentSpecial, required. A URL (or attachment://<filename>), a File to upload with the message, or an attachment Discord has
descriptionStringAlt text, max 1024 characters
spoilerboolBlurs the media until clicked
<Gallery>
    <Item url="https://example.com/a.png" description="Before"/>
    <Item url="attachment://after.png" description="After" spoiler/>
    {extra.iter().map(|url| MediaGalleryItem::new(url.as_str()))}
</Gallery>

<File>

FileComponent: a file uploaded with the message, shown inline. No children. Give it the File and the View uploads it (Message options).

AttributeType
src (or url)a File, or impl Into<String>Special, required. The File to upload, or attachment://<filename> for one added with .file(..)
spoilerboolBlurs the file until clicked

Interactive

<Button>

ButtonComponent: in a <Row>, or as a <Section>’s accessory. Children: the label, as text and {..}.

AttributeType
primary / secondary / success / dangerflagSpecial. The style: blurple, grey (the default), green, red
styleButtonStyleSpecial. The style, as a value
on:click=handler(state..)Special. Answered by a #[component] handler (above)
on:click:name=closureSpecial. An inline handler; #[view] only
custom_idStringAnswered by whatever handles this custom_id; max 100 characters
urlStringA link button (style Link), opening the URL; no on:click or custom_id
sku_idSnowflakeA premium button (style Premium) offering the SKU; no label, emoji, on:click or custom_id
labelStringSpecial. The label, instead of children; max 80 characters
emoji&str or PartialEmojiShown before the label: "👍", or a custom emoji
disabledboolGreyed out, not clickable

A button needs exactly one of on:click, custom_id, url or sku_id.

<Row>
    <Button primary on:click:save=move |_| ..>"Save"</Button>
    <Button danger emoji="🗑️" on:click=delete(id)>"Delete"</Button>
    <Button url="https://discord.com/developers/docs">"Docs"</Button>
    <Button custom_id="help" disabled>"Help"</Button>
</Row>

<StringSelect> (alias <Select>)

StringSelect: a dropdown of up to 25 text options. In a <Row> in messages, or a <Label> in modals. Children: <Option>s, and {..} of SelectOptions.

AttributeType
on:select=handler(state..)Special. Answered by a #[component] handler
on:select:name=closureSpecial. An inline handler; #[view] only
custom_idStringAnswered by whatever handles this custom_id
namestring literalSpecial, modals only (required there). The input’s custom_id, matched to an on:submit parameter
optionsa #[derive(Choice)] enumSpecial. One option per variant, before any <Option> children
placeholderStringShown while nothing is selected; max 150 characters
min (or min_values)i32Fewest selections, 0-25 (default 1)
max (or max_values)i32Most selections, 1-25 (default 1; a Vec handler parameter makes it every option)
disabledboolNot selectable; messages only
requiredboolModals only; set from the submit parameter’s type

In messages, a select needs on:select or custom_id.

<Option>

SelectOption in a select, GroupOption in a <RadioGroup> or <CheckboxGroup>. Children: the label, as text and {..}.

AttributeType
valueimpl Into<String>Special, required. What is submitted when chosen; max 100 characters
labelStringSpecial. The label, instead of children; max 100 characters
descriptionStringA second line under the label; max 100 characters
emoji&str or PartialEmojiSelects only
defaultboolChosen initially
<StringSelect on:select:pick=move |_, size: Size| ..>
    <Option value="s" description="Fits most">"Small"</Option>
    <Option value="l" emoji="🐘" default>"Large"</Option>
</StringSelect>

<UserSelect>, <RoleSelect>, <MentionableSelect>, <ChannelSelect>

UserSelect, RoleSelect, MentionableSelect, ChannelSelect: pick users, roles, both, or channels, with Discord filling in the options. No children. The same attributes as <StringSelect> except options, plus:

AttributeType
default_valuesVec<SelectDefaultValue>Preselected entities: vec![SelectDefaultValue::user(id)]
channel_typesVec<ChannelType><ChannelSelect>: the kinds of channel offered
<Row>
    <ChannelSelect placeholder="Where to post?"
        channel_types={vec![ChannelType::GuildText]}
        on:select:pick=move |_, channel: PartialChannel| ..>
    </ChannelSelect>
</Row>

Modals

ModalCallbackData, as a ModalView. Must be the markup’s only root. Children: text, <Text> and <Label>s, at most 5 in total (Discord’s limit).

AttributeType
titleimpl Into<String>Required. Shown at the top; max 45 characters
on:submit=handler(state..)Submitted to a #[modal] handler
on:submit:name=closureAn inline handler; #[view] only
custom_idStringSubmitted to whatever handles this custom_id

A <Modal> needs exactly one of on:submit or custom_id, and takes no other attributes.

<Label>

Label: a title and optional description above one input. Children: exactly one <TextInput>, select, <RadioGroup>, <CheckboxGroup>, <Checkbox> or <FileUpload>.

AttributeType
text (or label)impl Into<String>Required. The title; max 45 characters
descriptionStringUnder the title; max 100 characters

Every input below takes name="..." (special, required, a string literal): its custom_id, matched to an on:submit parameter. None takes on: attributes.

<TextInput>

TextInput: free text. No children.

AttributeType
paragraphflagSpecial. Multi-line; single-line by default
styleTextInputStyleSpecial. The same, as a value
min_len (or min_length)i320-4000
max_len (or max_length)i321-4000
placeholderStringShown while empty; max 100 characters
valueStringPrefilled text; max 4000 characters
requiredboolSet from the submit parameter’s type

<RadioGroup>

RadioGroup: pick one of 2-10 options. Children: <Option>s, and {..} of GroupOptions.

AttributeType
optionsa #[derive(Choice)] enumSpecial. One option per variant
requiredboolSet from the submit parameter’s type

<CheckboxGroup>

CheckboxGroup: pick any of 1-10 options. Children: as <RadioGroup>.

AttributeType
optionsa #[derive(Choice)] enumSpecial. One option per variant
min (or min_values)u80-10
max (or max_values)u81-10
requiredboolSet from the submit parameter’s type

<Checkbox>

Checkbox: one yes/no, submitted as a bool. Never required. No children.

AttributeType
defaultboolChecked initially

<FileUpload>

FileUpload: attach files, submitted as Attachments. No children.

AttributeType
min (or min_values)u80-10
max (or max_values)u81-10 (a Vec parameter makes it 10)
file_typesVec<String>Up to 10 of "image", "video", "audio", or extensions like ".pdf"
requiredboolSet from the submit parameter’s type

Styling

Colors are yours: plain consts, set where a message is built. A wrapper View per tone keeps every message alike, and works the same whether a handler answers with it, Background Work edits with it, or it is sent through REST:

use composure::{command::View, rsx, view};

pub const SUCCESS: u32 = 0x57f287;

/// Something worked
#[view]
pub fn Success(title: String, children: View) -> View {
    rsx! {
        <Container accent_color=SUCCESS>
            <Text>"### " {title}</Text>
            {children}
        </Container>
    }
}

fn saved(channel: &str) -> View {
    rsx! { <Success title="Saved">"Announcements will appear in " {channel}</Success> }
}
fn main() { assert_eq!(saved("#news").into_components().unwrap().len(), 1); }

Handler errors are the one message Composure builds itself: by default, the ephemeral text of HandlerError::response. #[app(errors = ..)] takes an ErrorRenderer, a fn(&HandlerError) -> InteractionResponse, to answer them your way, both right away and when deferred work fails:

use composure::{
    app, command,
    command::{Ephemeral, HandlerError, IntoResponse},
    rsx,
    types::InteractionResponse,
};

const ERROR: u32 = 0xed4245;

/// Errors as an ephemeral red container
fn render_error(error: &HandlerError) -> InteractionResponse {
    let message = error.user_message().to_string();
    Ephemeral(rsx! { <Container accent_color=ERROR>{message}</Container> }).into_response()
}

/// Ping
#[command]
fn ping() -> &'static str {
    "Pong"
}

#[app(handlers(ping), errors = render_error)]
pub struct Bot;
fn main() {}

user_message() is what the user should read: a HandlerError::user’s message, a generic one otherwise. At most one App in a tree sets errors; the root uses it for the whole tree.

Without #[view]

rsx! works in any function; only inline handlers need #[view]. A plain function returning View is a good fit for layouts without state of their own, and for messages sent through REST. The message bodies are built from a &View with TryFrom: CreateMessage, CreateFollowup, EditMessage and ExecuteWebhook. Each sets the components, the Components V2 flag, the View’s other flags (ephemeral where the Endpoint takes it), its allowed mentions and its files, and fails before anything is sent when the View cannot be (a custom_id that could not be built, or past Discord’s limits):

use composure::{
    command::HandlerError,
    rest::{BotClient, CreateMessage, Transport},
    rsx,
    types::Snowflake,
};

async fn announce<T: Transport>(
    bot: &BotClient<T>,
    channel: Snowflake,
    text: &str,
) -> Result<(), HandlerError> {
    let view = rsx! { <Container>"📣 " {text}</Container> };
    bot.create_message(channel, &CreateMessage::try_from(&view)?).await?;
    Ok(())
}
fn main() {}

Built from the same tone Views as your replies, they look alike.

REST and deferring

Handlers can call Discord’s REST API, and answer later than Discord’s 3 seconds allow by deferring: replying “thinking…” at once and finishing in the background. Both need a State that implements Platform, which every Adapter’s State does; take ctx: Ctx<impl Platform> to say so and keep the handler testable off any platform.

Enable the rest feature (the rest client and Interaction Endpoints), plus the rest-* features for the BotClient Endpoints you call, or tier1 for the common ones. Beyond tier1 are groups such as Linked Roles metadata (rest-role-connections), onboarding and the welcome screen (rest-onboarding), stage instances (rest-stage-instances) and lobbies (rest-lobbies); full turns on every one. The feature flags list them all.

Calling the API

use composure::{command, command::{Ctx, HandlerError, Platform}};

/// Ask the bot who it is
#[command]
async fn whoami(ctx: Ctx<impl Platform>) -> Result<String, HandlerError> {
    let me = ctx.bot()?.get_current_user().await?.model()?;
    Ok(format!("I'm {}.", me.display_name()))
}
fn main() {}
ClientAuthorized byFor
ctx.client() → InteractionClientthe Interaction’s token (valid 15 minutes)follow-up messages, the original response: followup, create_followup, edit_original, delete_original, …
ctx.bot() → BotClientthe bot token (DISCORD_TOKEN); an error when the Adapter has noneeverything else: messages, members, roles, channels, Commands, … (one rest-* feature per group)

Every call returns a Response<T> whose body is decoded only when you ask with .model(), so calls whose result you ignore skip the parse. Errors are rest::Errors, which convert into HandlerError with ?. Rate limits (429) are waited out, up to with_max_rate_limit_wait (2 seconds by default), then returned as Error::RateLimited.

Inline calls count toward the 3 seconds. One or two fast calls are fine; anything slower should defer.

Deferring

ctx.defer(work) answers with a Deferred Response (the user sees “bot is thinking…”) and runs work as Background Work after the response is sent. When it finishes, its output replaces the “thinking” message, exactly as if the handler had returned it: text, an embed, a View, a Result (an Err becomes the error message), files included.

use std::time::Duration;
use composure::{command, command::{Ctx, Deferred, Ephemeral, HandlerError, Platform}};

/// Count the members of this server (slowly)
#[command]
fn census(ctx: Ctx<impl Platform>) -> Ephemeral<Deferred> {
    Ephemeral(ctx.defer(async |ctx| {
        ctx.sleep(Duration::from_secs(5)).await; // pretend this is slow
        let guild = ctx.guild_id().ok_or_else(|| HandlerError::user("Use this in a server."))?;
        Ok::<_, HandlerError>(format!("Counted the members of {guild}."))
    }))
}
fn main() {}
  • The closure receives a DeferredCtx: an owned, 'static copy of the Interaction and State, with the same user(), guild_id(), client(), bot(), plus sleep on the platform’s timer.
  • Its future must be Send + 'static, so it owns what it uses: convert borrowed parameters first (let text = text.to_owned();) and move them in.
  • Wrap the result of defer in Ephemeral to defer ephemerally; the final message is then ephemeral too, whatever work returns.
  • A failing work edits the response with the error, rendered as the App renders errors (errors = ..), and is logged.
  • Background Work has a time limit set by the platform: about 30 seconds after the response on Cloudflare Workers (wait_until), until shutdown’s grace period on tokio. The Interaction token, which the final edit uses, expires after 15 minutes.

Updating a component’s message later

For components (and modals opened from one), ctx.defer_update(work) acknowledges without any visible loading state and then replaces the message the component is on with work’s output. If work fails, the message is left as it was (buttons and state intact) and the error is shown in an ephemeral follow-up instead:

use std::time::Duration;
use composure::{component, command::{ComponentCtx, DeferredUpdate, HandlerError, Platform}};

/// Re-roll a die after a dramatic pause
#[component("roll")]
fn reroll(ctx: ComponentCtx<impl Platform>, rolls: u32) -> DeferredUpdate {
    ctx.defer_update(async move |ctx| {
        ctx.sleep(Duration::from_secs(1)).await;
        let face = ctx.interaction().common.id.to_u64() % 6 + 1;
        Ok::<_, HandlerError>(format!("Rolled a {face} ({} rolls)", rolls + 1))
    })
}
fn main() {}

Inline View handlers can do the same, returning new Props from work to re-render the View.

Follow-ups and progress

Background Work can talk more than once: edit the original response as it goes, and send follow-up messages (new messages under the original).

use std::time::Duration;
use composure::{command, command::{Ctx, Deferred, HandlerError, Platform}, rest::EditMessage};

/// Count down, editing the response every second
#[command]
fn countdown(ctx: Ctx<impl Platform>) -> Deferred {
    ctx.defer(async |ctx| {
        let client = ctx.client();
        for n in (1..=3).rev() {
            let content = format!("{n}...");
            client.edit_original(&EditMessage { content: Some(&content), ..Default::default() }).await?;
            ctx.sleep(Duration::from_secs(1)).await;
        }
        client.followup("Liftoff! 🚀").await?;
        Ok::<_, HandlerError>("Done.")
    })
}
fn main() {}

Request bodies (EditMessage, CreateFollowup, …) borrow their fields; absent fields are left out of the request. MessageCallbackData converts into both. Build them with the same Views and colors as your replies to match (Styling).

Outside Interactions

A BotClient can also be built from any Transport, a bot token and the application id, for cron jobs, queues or scripts: BotClient::new(transport, &token, application_id), or Credentials::bot_client(transport). The Adapters provide one (bot_client(&env) on Workers, host.bot() on tokio), and the reqwest feature adds a blocking ReqwestTransport for deploy-time tools. See Platforms.

Webhook Event handlers get the bot client from their EventCtx; see Webhook Events.

Webhook Events

Webhook Events are the few gateway-style events Discord delivers over HTTP: an app being installed or removed, entitlements being created or changed, and lobby messages. Composure receives them on the same server as Interactions and hands them to an EventHandler.

use composure::{
    app,
    command::{EventCtx, EventHandler, HandlerError, Platform},
    types::{ApplicationAuthorized, ApplicationIntegrationType},
};

/// Welcomes users who install the app to their account
pub struct Events;

impl<S: Platform> EventHandler<S> for Events {
    async fn application_authorized(
        &self,
        ctx: EventCtx<'_, S>,
        event: ApplicationAuthorized,
    ) -> Result<(), HandlerError> {
        if event.integration_type != Some(ApplicationIntegrationType::UserInstall) {
            return Ok(());
        }
        let bot = ctx.bot()?;
        let dm = bot.create_dm(event.user.id).await?.model()?;
        bot.send_message(dm.id, "Thanks for installing! Try `/hello` anywhere.").await?;
        Ok(())
    }
}

/// Say hello
#[composure::command]
fn hello() -> &'static str {
    "Hello!"
}

#[app(handlers(hello), events = Events)]
pub struct Bot;
fn main() {}

Setting it up

  1. Implement EventHandler<S> on a unit struct, overriding the methods for the events you want; the rest ignore their event.
  2. Register it on an App with #[app(.., events = Events)]. At most one App in a tree sets events (a bot has one events URL); the root App forwards to it.
  3. In the Developer Portal, under Webhooks, set the Events URL to https://<your bot>/events and subscribe to the event types.

The Adapter verifies the request signature like an Interaction’s, answers Discord with 204 immediately, and only then runs the handler as Background Work. So handlers can take their time (within the platform’s Background Work limit), but their errors are only logged: Discord does not retry.

Events

MethodEventPayload
application_authorizedThe app was installed to a server or a userApplicationAuthorized
application_deauthorizedA user removed the appApplicationDeauthorized
entitlement_createA user or guild bought a SKUEntitlement
entitlement_updateA subscription renewed or endedEntitlement
entitlement_deleteAn entitlement was refunded or removedEntitlement
lobby_message_createA message was sent in a lobbyLobbyMessage
lobby_message_updateA lobby message was editedLobbyMessage
lobby_message_deleteA lobby message was deletedLobbyMessageDelete
unknownAny other type (game DMs, quests, types added later), or one whose data did not match its modelthe type name and raw JSON

Override handle instead to see every Event before it is routed to a method.

An unknown or malformed event never fails the request: it arrives in unknown, so a bot keeps working when Discord adds event types.

The context

EventCtx carries the Adapter’s state(), the application_id(), the event’s timestamp() (events can arrive late and out of order), and, with a Platform State, bot() for REST. There is no Interaction, so no Interaction client.

With the tracing feature, handlers run inside an event span carrying event_type.

Platforms

An Adapter serves an App on one platform. Everything platform-specific (reading requests, the HTTP client, running Background Work, secrets) lives in the Adapter and reaches handlers as its State: the S in Ctx<S>.

AdapterStateBackground WorkREST transport
composure_adapter_cloudflareWorker<T>: the Worker’s Env (vars, secrets, KV, D1, …, also through Deref), its Context, and your Bot Data T (from .with_data(..))ctx.wait_until, about 30 s after the responsefetch
composure_adapter_tokioHost<T>: your Bot Data T (from .with_data(..), also through Deref), the bot token, the HTTP clienta tokio::spawned task, awaited on shutdownasync reqwest

Both implement Platform and HasData, and answer every request the same way because both run the shared pipeline in adapter: 401 for a bad signature, 400 for a body that is not an Interaction, the handler’s response (or its ephemeral error message) as JSON, and responses with files through the callback endpoint then 202.

Platform-agnostic Commands

A Command’s ctx type decides which States can serve it, and an App can only be served with a State every Command it lists accepts. Keep Commands as general as they can be:

  • ctx: Ctx (or no ctx) when they only answer
  • ctx: Ctx<impl Platform> when they use REST or defer
  • ctx: Ctx<impl HasData<Data = Config>> when they read the bot’s own configuration
  • ctx: Ctx<impl YourTrait> when they need something else from the platform

Bot Data

The bot’s own configuration and resources (settings, a database pool, …) are its Bot Data: one typed value, built once and shared by every Interaction. Each Adapter takes it with .with_data(..) and hands it to Commands through the State’s HasData, so a Command reads it the same way on every platform, and the bot loads it however that platform likes: config, figment, envy or std::env on a server, the Worker’s vars, secrets or KV on Workers.

use composure::{command, command::{Ctx, HasData}};

/// The bot's settings
pub struct Config {
    pub greeting: String,
}

/// Greet with the configured greeting
#[command]
fn greet(ctx: Ctx<impl HasData<Data = Config>>) -> String {
    format!("{} from Composure!", ctx.state().data().greeting)
}

// Per Adapter:
//
// // tokio: load it any way, e.g. `settings.get::<Config>("bot")?` with `config`
// TokioInteractionBot::from_env()?.with_handler(Bot).with_data(config)
//
// // Workers: built from the Env on an isolate's first request, then reused
// CloudflareInteractionBot::new(req, env, ctx)
//     .with_handler(Bot)
//     .with_data(async |env: Env| Ok(Config { greeting: env.var("GREETING")?.to_string() }))
fn main() {}

When one platform’s Bot Data holds more than the shared Commands need (a server’s own state), bound on a part of it, Ctx<impl HasData<Data: AsRef<Config>>>, and implement AsRef<Config> for each Bot Data type (Config itself included). In tests, implement HasData on the stand-in State. Bot Data must be Send + Sync; on Workers, bindings (KV, D1, …) stay on the Env.

A bot served on several platforms keeps its shared Commands in one App (or crate) generic like this, and each platform’s binary lists that App in its own, next to Commands that only work there (Ctx<Worker> reading Workers KV, Ctx<Host<MyData>> reading local files). The repository’s examples/ are built that way.

Platform-specific Commands

When a Command is inherently tied to a platform, take its State directly:

use composure::{command, command::{Ctx, HandlerError}};
use composure_adapter_tokio::Host;

pub struct Server { started: std::time::Instant }

/// How long the server has been up
#[command]
fn uptime(ctx: Ctx<Host<Server>>) -> String {
    format!("Up for {:?}", ctx.state().started.elapsed()) // Host<T> derefs to T
}

Send futures

Handler futures must be Send (ADR 0012), so the tokio Adapter can run each Interaction on any thread. Write async fn handlers normally; the compiler points out a non-Send value (an Rc, a RefCell borrow) held across .await.

Workers run on one thread, but their JS handles are not Send: the Adapter’s Worker and transport wrap them for you, and a handler awaiting a JS future itself (KV, D1, fetch) wraps it in worker::send::SendFuture::new(..), or marks a helper #[worker::send].

Credentials

What Composure itself needs is the application’s Credentials: the public key, and optionally the bot token and application id. Composure never reads them from anywhere itself: build them in code, from the conventional names with Credentials::from_lookup, or deserialize them with any serde loader (fields public_key, bot_token or token, application_id).

VariableCloudflaretokio
DISCORD_PUBLIC_KEYsecretenvRequired: verifies requests
DISCORD_TOKENsecretenvThe bot token, for ctx.bot()
DISCORD_APPLICATION_IDvarenvFor a bot client outside Interactions (cron, startup)
PORTenvPort to serve on (8080)

Those are the defaults: TokioInteractionBot::from_env() and CloudflareInteractionBot read them. Otherwise, pass Credentials loaded another way:

// tokio, with the `config` crate: a `[discord]` table in bot.toml, or DISCORD__PUBLIC_KEY, ...
let settings = config::Config::builder()
    .add_source(config::File::with_name("bot"))
    .add_source(config::Environment::default().separator("__"))
    .build()?;
TokioInteractionBot::new(settings.get::<Credentials>("discord")?)?

// Workers: other names, the Secrets Store, ...; loaded on an isolate's first request
CloudflareInteractionBot::new(req, env, ctx).with_credentials(async |env: Env| {
    let token = env.secret_store("BOT_TOKEN")?.get().await?.unwrap_or_default();
    Ok(Credentials::new(&env.var("PUBLIC_KEY")?.to_string())?.with_bot_token(token))
})

Debug on Credentials never shows the token.

Webhook Events are served on /events (change it with .with_events_path(..)), Interactions on every other path. The tokio Adapter also answers GET /health.

Logging

With the tracing feature, Composure reports through tracing. It never installs a subscriber; the Adapter or your main does (composure_adapter_cloudflare::tracing::init() on Workers, which writes to Workers Logs; tracing_subscriber::fmt().init() or any other on a server).

SpanFieldsAround
interactioninteraction_id, interaction_type, name (the Command, or the custom_id prefix), guild_id, channel_id, user_iddispatching one Interaction
background_workdeferred work, inside its interaction span
eventevent_typehandling a Webhook Event
restmethod, route (the template, never the URL, which can hold a token)one REST call

Everything logged inside a handler, Background Work included, carries its Interaction’s fields: tracing::info!(..) in a Command is enough. Handler errors are logged in their span at ERROR, unknown Commands and invalid Options at WARN, HandlerError::user errors at DEBUG.

Cold starts on Workers

A new Workers isolate compiles each wasm function on its first call, so the first request’s latency depends on how the bundle is built more than on the bot’s code. Two build settings (no one-caller inlining in wasm-opt, and no name section) make the example’s first request about 30% faster and its bundle 29% smaller; the Cloudflare adapter’s README has them, with measurements. Logging at INFO rather than DEBUG also saves a few milliseconds on the first request.

Writing an Adapter

A new platform needs little code: adapter does the work every Adapter shares, so an Adapter only maps HTTP to it.

  1. Take Credentials, and offer a loader for the platform’s usual source on top (by the conventional names through Credentials::from_lookup).
  2. Implement Platform on the State: a Transport (send a Request, sleep), a way to spawn Background Work after the response, and the bot token; and HasData for the Bot Data.
  3. Parse the public key once into a PublicKey and keep it.
  4. For each request, call handle_interaction_with_key (or handle_event_with_key on the events path) with the two signature headers and the body, and turn the InteractionOutcome into a response: status() and response() as JSON. When the response has files, send it with send_with_files and answer 202 instead.
  5. Render handler errors with the handler’s render_error, as the outcome already does.

The two existing Adapters (crates/adapters/ in the repository) are a few hundred lines each.

Testing

Bots built with Composure are tested in plain cargo test, without Discord or a platform: a Command is a function, an App is a handler that takes a parsed Interaction, and composure::testing stands in for the rest. Turn it on for tests only:

[dev-dependencies]
composure = { version = "0.2", features = ["testing"] }

Calling the function

#[command] and the handler macros keep the function as written, so its logic is a unit test away:

use composure::command;

/// Repeat some text
#[command]
fn echo(#[option("What")] text: &str, #[option("Times")] times: Option<u8>) -> String {
    text.repeat(times.unwrap_or(1).into())
}

fn main() {
assert_eq!(echo("ab", Some(2)), "abab");
}

Running an Interaction

testing::answer runs an App the way an Adapter does: route the Interaction, call the handler, and answer an error the way the App renders errors (an ephemeral message by default). Build the Interaction with the module’s builders, and read the response with ResponseExt:

use composure::{
    app, command,
    testing::{self, ResponseExt, TestPlatform},
};

/// Repeat some text
#[command]
fn echo(#[option("What")] text: &str, #[option("Times")] times: Option<u8>) -> String {
    text.repeat(times.unwrap_or(1).into())
}

#[app(handlers(echo))]
pub struct Bot;

fn main() {
let response = testing::answer(
    &Bot,
    testing::command("echo").option("text", "hi").option("times", 2),
    &TestPlatform::new(),
);
assert_eq!(response.text().as_deref(), Some("hihi"));
assert!(!response.is_ephemeral());

// `text` is required: the user gets an ephemeral error
let response = testing::answer(&Bot, testing::command("echo"), &TestPlatform::new());
assert!(response.is_ephemeral());
}

testing::run returns the handler’s Result instead, to check the error itself. Every builder starts from what Discord sends: invoked by testing::users::alice() in a DM, unless told otherwise.

Builder
command("name").option("text", "hi"), .option("times", 3), .user_option("who", &user), .option_json(..) for channels and roles; .subcommand("ban"), .group("mod")
user_command("Avatar", &user), message_command("Quote", "text")Context menu Commands on a user or a message
autocomplete("name").focused("option", "typed")The user typing in an Option
component("custom_id")A button click; .values(["red"]) for a select
modal("custom_id")A submit: .field("title", "Hi"), .select("kind", ["Bug"]), .input(json!(..)) for any other input
On every builder.by(&user), .in_guild(id), .permissions(..), .locale("fr"), .channel(id), .message(json!(..)), .json(|json| ..) for anything else

Build custom_ids with the handler’s custom_id(..) function, so the test follows the encoding: testing::component(&counter::custom_id(4)).

A stand-in Platform

Commands taking Ctx<impl Platform> (REST, deferring) or Ctx<impl HasData<..>> (Bot Data) run against TestPlatform. It records REST requests in a MockTransport (answering 200 {} unless told otherwise) and keeps Background Work until run_background(), so a test checks the immediate response, runs the work, then checks what was sent:

use composure::{
    app, command,
    command::{Ctx, Deferred, HandlerError, Platform},
    rest::{EditMessage, Method},
    testing::{self, ResponseExt, TestPlatform},
};

/// Count to three
#[command]
fn countdown(ctx: Ctx<impl Platform>) -> Deferred {
    ctx.defer(async |ctx| {
        for n in ["3...", "2...", "1..."] {
            let edit = EditMessage { content: Some(n), ..Default::default() };
            ctx.client().edit_original(&edit).await?;
        }
        Ok::<_, HandlerError>("Liftoff!")
    })
}

#[app(handlers(countdown))]
pub struct Bot;

fn main() {
let platform = TestPlatform::new();
let response = testing::answer(&Bot, testing::command("countdown"), &platform);
assert_eq!(response.response_type(), 5); // deferred: "thinking..."
assert!(platform.transport().requests().is_empty());

platform.run_background().unwrap();
let transport = platform.transport();
assert_eq!(transport.sent().len(), 4);
assert_eq!(transport.sent()[0].0, Method::Patch);
assert_eq!(transport.body(3)["content"], "Liftoff!");
}
  • Answers: MockTransport::new().respond(404, "..") queues answers in order, and .answering(|request| ..) answers by route, for canned reads or other APIs the bot calls. Pass it in with .with_transport(..).
  • Bot Data: .with_data(config) sets it, and .with_bot_token(None) removes the token ctx.bot() uses.
  • Your own traits: a bot whose handlers need more from their State (a key-value store, say) implements its trait on TestPlatform<YourData> in its own crate. An integration test can’t: the orphan rule forbids it. There, wrap a TestPlatform in a struct of your own that forwards Platform and HasData to it and adds the rest.

Signed requests

adapter::handle_interaction and handle_event run the whole Adapter pipeline (signature check, parsing, dispatch, error rendering) without a platform, and return the HTTP status and body an Adapter would send. A TestKey stands in for Discord’s key:

use composure::{
    adapter::{InteractionOutcome, handle_interaction},
    app, command,
    testing::{self, TestKey, block_on},
};

/// Ping
#[command]
fn ping() -> &'static str { "Pong" }

#[app(handlers(ping))]
pub struct Bot;

fn main() {
let key = TestKey::default();
let request = key.signed(testing::command("ping").body());

let outcome = block_on(handle_interaction(
    &key.public_key(), Some(&request.signature), Some(&request.timestamp), &request.body, &Bot, &(),
));
assert_eq!(outcome.status(), 200);

let forged = block_on(handle_interaction(
    &key.public_key(), Some(&request.signature), Some("1700000001"), &request.body, &Bot, &(),
));
assert!(matches!(forged, InteractionOutcome::Unauthorized));
}

request.headers() gives both signature headers, to send to a running server.

Definitions

What Sync would register is available without Discord too: CommandSetDefinition::definitions on an App lists every Command Definition with its Scope, ready to compare against a snapshot.

use composure::{app, command, command::CommandSetDefinition};

/// Ping
#[command(scope = "dev")]
fn ping() -> &'static str { "Pong" }

#[app(handlers(ping))]
pub struct Bot;

fn main() {
let definitions = Bot::definitions();
assert_eq!(definitions[0].scope, Some("dev"));
}

The repository’s tests go further:

  • examples/bot/tests/: offline.rs runs every example Command on a TestPlatform, regression.rs replays signed fixtures for every Interaction shape, and perf.rs holds each path to an allocation budget.
  • bots/composure/tests/offline.rs: wraps a TestPlatform to add the bot’s own key-value store.

Local development

Discord only talks to a public HTTPS URL. To develop a bot, run it on your machine, give it a public URL with a tunnel, point a development application at that URL, and register the Commands in a test server, where changes show up at once.

Start from a template

cargo-generate creates a bot with a /hello Command, a Sync binary, a test and the platform’s configuration:

cargo install cargo-generate
cargo generate --git https://github.com/BlueFrog130/composure-rs templates/cloudflare --name my-bot
cargo generate --git https://github.com/BlueFrog130/composure-rs templates/tokio --name my-bot

A development application

Create a second application in the Developer Portal for development, so pointing it at your machine never touches the bot people use. Note its public key (General Information), its application id, and its bot token (Bot), then add it to a test server through Installation.

Running the bot

On Workers, wrangler dev serves the Worker locally and reads its secrets from .dev.vars:

cp .dev.vars.example .dev.vars   # DISCORD_PUBLIC_KEY, DISCORD_TOKEN
npx wrangler dev                 # http://localhost:8787

On a server, run it as is; the template reads .env:

cp .env.example .env             # DISCORD_PUBLIC_KEY, DISCORD_TOKEN, DISCORD_APPLICATION_ID
cargo run                        # http://localhost:8080

A tunnel

A tunnel gives the local port a public HTTPS URL:

cloudflared tunnel --url http://localhost:8787   # prints https://<random>.trycloudflare.com
ngrok http 8787                                  # or ngrok

Use port 8080 for the tokio bot. Quick tunnels get a new URL each time they start; a named tunnel (or a reserved ngrok domain) keeps one.

The Interactions Endpoint URL

Paste the tunnel’s URL into the development application’s Interactions Endpoint URL (General Information) and save. Discord checks it before accepting: it sends a signed Ping, which must be answered with a Pong, and requests with invalid signatures, which must be refused with 401. The Adapters do both, so a save that fails means the bot isn’t reachable at that URL, or its DISCORD_PUBLIC_KEY belongs to another application.

For Webhook Events, set the Webhook Events URL to the same URL plus /events and enable the events you handle.

Commands in a test server

Global Commands are shared by every server; while developing, send them all to your test server instead, where Discord shows changes immediately:

cp .env.example .env             # DISCORD_TOKEN, DISCORD_APPLICATION_ID (Workers template)
cargo run --bin sync -- --guild <test server id>

--guild ignores Scopes and leaves global Commands alone. Copy a server’s id with Developer Mode on (User Settings › Advanced), from the server’s context menu. Run it again whenever a Command’s name, Options or description change; handler code changes only need the bot restarted.

Logs and tests

wrangler dev prints what the Worker logs; the tokio template logs to stderr, filtered by RUST_LOG (RUST_LOG=info,composure=debug shows every Interaction). Handler errors are logged with their causes, and the user gets an ephemeral message instead of Discord’s failure (Troubleshooting).

Most changes don’t need Discord at all: cargo test runs the Commands against stand-ins (Testing).

Deploying

A deploy has two halves: the bot’s code, served where Discord can reach it, and its Commands, registered with Discord by Sync. Deploy the code first, then sync: a new Command synced before the code that answers it is “Unknown command.” for a moment, while the other order only shows the old Commands a little longer.

Cloudflare Workers

npx wrangler deploy
npx wrangler secret put DISCORD_PUBLIC_KEY   # once; DISCORD_TOKEN too for the bot client
cargo run --bin sync                         # shows the plan, applies after confirming

The Worker answers at https://<name>.<your subdomain>.workers.dev, or a custom domain from the dashboard; put that URL in the Developer Portal as the Interactions Endpoint URL. Secrets (wrangler secret put) hold the public key and bot token; plain settings go in [vars] in wrangler.toml, next to bindings like KV. See composure_adapter_cloudflare for the build settings that keep cold starts short.

From CI

A GitHub Actions workflow that tests, deploys and syncs on every push to main, as the repository’s own bot does:

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: wasm32-unknown-unknown
      - uses: Swatinem/rust-cache@v2
      - run: cargo install worker-build@0.8.7 --locked
      - run: cargo test
      - uses: cloudflare/wrangler-action@v4
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: deploy
      - run: cargo run --bin sync -- --yes
        env:
          DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }}
          DISCORD_APPLICATION_ID: ${{ secrets.DISCORD_APPLICATION_ID }}

CLOUDFLARE_API_TOKEN needs the Workers Scripts: Edit permission. Outside a terminal, Sync only applies with --yes; cargo run --bin sync -- --check in pull requests fails (exit status 2) when Discord’s Commands differ from the App.

A server

The tokio template’s Dockerfile builds a small image:

docker build -t my-bot .
docker run -p 8080:8080 -e DISCORD_PUBLIC_KEY=... -e DISCORD_TOKEN=... my-bot

Run it anywhere that runs containers. What the Adapter expects from its host:

  • TLS in front. It speaks plain HTTP on $PORT (8080). Put a TLS-terminating proxy or load balancer in front (most container platforms provide one), and use its HTTPS URL as the Interactions Endpoint URL. With Caddy, for one:

    bot.example.com {
        reverse_proxy localhost:8080
    }
    
  • Health checks. GET /health answers 200.

  • Graceful stops. On SIGTERM (or Ctrl-C) it stops accepting requests and waits up to 30 seconds for in-flight requests and Background Work, such as deferred answers, before exiting; .with_grace(..) changes the wait. Give the platform’s stop timeout at least as long.

  • Configuration from the environment. DISCORD_PUBLIC_KEY, DISCORD_TOKEN, DISCORD_APPLICATION_ID and PORT, or Credentials loaded any other way.

Each Interaction runs on its own task, so one replica handles many at once; several replicas behind the load balancer need nothing shared, since Interactions carry their own state.

Before going live

  • Use the production application’s public key and token, not the development one’s.
  • Sync without --guild, so the Commands are global and follow their Scopes.
  • Check the Endpoint URL saves: Discord tests it, so a mistake shows at once.

Troubleshooting

Discord shows “This interaction failed” (or “The application did not respond”) when it gets no valid answer in time. Composure answers handler errors itself, with an ephemeral message the user reads and a log line with the cause, so Discord’s own failure usually means the answer never arrived or Discord rejected it. Start with the logs: wrangler tail or Workers Logs on Cloudflare, stderr on a server.

“This interaction failed”

CauseWhat you seeFix
No answer within 3 seconds“The application did not respond”; the handler finishes laterAnswer with ctx.defer(..) and do the slow part in Background Work (REST and deferring). On Workers, a cold start plus slow REST calls before answering can be enough
Signature or public key mismatch401 in the logs; the Endpoint URL won’t saveSet DISCORD_PUBLIC_KEY to the public key of the application whose Endpoint URL points at the bot, not another one’s (development and production apps differ)
Configuration failed to load500 on Workers, logged on the isolate’s first requestA missing secret or var, or an error from .with_credentials / .with_data; set it and retry
Discord rejected the responseThe handler ran, nothing is logged as an errorA response Discord refuses: more than 10 embeds, an empty message, a component built by hand over its limits. Views are checked before answering (counts, lengths, repeated select option values) and fail as a handler error instead. Compare the response with Discord’s limits; Testing shows the JSON
Components V2 with content or embedsAn error naming the fieldA Components V2 message (a Container, a View) carries its text in text displays: move content and embeds into components

Errors the user sees

MessageCauseFix
“Unknown command.”The App has no Command with that name and type: Discord’s Commands are out of sync with the deployed codecargo run --bin sync -- --check shows the differences; sync, or deploy the code first (Deploying)
“Something went wrong handling this interaction.”A handler returned an error, or an Option couldn’t be readThe log line has the error and its causes (sources)
The same, from a button posted before a deployIts custom_id was encoded by an older version of the handler: the state’s fields changedKeep the state’s fields compatible, or give the handler a new prefix so old buttons get the “not handled” answer instead
The same, logged as “custom_id is N characters”The state doesn’t fit Discord’s 100 charactersKeep less in the custom_id: an id, with the rest in storage
“This bot does not handle components.”No handler for the custom_id’s prefixList the handler (or the View) in the App, or set a fallback (Components)

REST errors

ctx.bot() and ctx.client() calls return rest::Error, with Discord’s JSON error code when it sent one.

StatusCauseFix
401 from ctx.bot()DISCORD_TOKEN is missing, wrong, or reset in the Developer PortalSet the current bot token
401 from ctx.client()The Interaction’s token expired: follow-ups and edits work for 15 minutesFinish within 15 minutes, or post with ctx.bot()
403, code 50013 (Missing Permissions)The bot lacks a permission in that channel, or the target’s role is above the bot’sGrant the permission (the invite link’s permissions), or move the bot’s role up
403, code 50001 (Missing Access)The bot can’t see the channel or isn’t in the server, as with a user-installed app used in a server without the botCheck ctx.interaction().common.authorizing_integration_owners before calling the bot client, or answer through ctx.client(), which needs no membership
429Rate limited for longer than the client waits (2 seconds by default)with_max_rate_limit_wait(..) on the client, or do less per Interaction

Other surprises

  • A Command doesn’t show up. Sync it; global Commands can take a moment to appear in the client, and a guild-only Command shows only where it was sent. Restarting Discord (Ctrl+R) refreshes its cache.
  • An ephemeral deferral turned the answer ephemeral. Ephemerality is decided by the first response: Ephemeral(ctx.defer(..)) makes the final message ephemeral too.
  • Webhook Events don’t arrive. The Webhook Events URL must end in /events, and each event must be enabled in the Developer Portal.