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.
- Getting started: crates, features, a first bot, serving and syncing it
- Commands:
#[command], Options, Choices, Groups, context menus, autocomplete, Scopes - Responses: what handlers return; ephemeral messages, embeds, files, errors
- Components and modals: buttons, selects and forms with
#[component],#[modal]and#[derive(Modal)], state in thecustom_id - Views:
rsx!markup and#[view], with a reference of every tag and attribute - REST and deferring: calling Discord’s API, follow-ups, Deferred Responses
- Webhook Events: app installs, entitlements, lobby messages
- Platforms: Adapters, State, platform-specific Commands, logging
- Testing: running handlers without Discord
- 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_cloudflareandcomposure_adapter_tokioexist 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:
- Commands (
#[command]) and Groups (#[group]) - component and modal handlers (
#[component],#[modal]) - Views with inline handlers (
#[view]) - other Apps
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:
| Parameter | Works with |
|---|---|
ctx: Ctx | any 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 type | Option type | Settings |
|---|---|---|
&str, String | string | min_len, max_len (0-6000), autocomplete |
i8, i16, i32, i64, u8, u16, u32 | integer | min, max (inside the type’s range), autocomplete |
f64 | number | min, max, autocomplete |
bool | boolean | |
&User, User | user | |
ResolvedMember | user, with their guild membership (fails outside guilds) | |
&PartialChannel, PartialChannel | channel | channel_types(GuildText, ..) |
&Role, Role | role | |
UserOrRole | mentionable | |
&Attachment, Attachment | attachment | |
a #[derive(Choice)] enum | string or integer, restricted to its variants | |
Option<T> | T, not required | as 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 = N | Bounds of integer and number Options |
min_len = N, max_len = N | Length bounds of string Options |
channel_types(GuildText, GuildVoice, ..) | ChannelTypes a channel Option offers |
autocomplete = handler | Suggestions 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) |
nsfw | Age-restricted |
user, message | A 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()andflavor.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 likepermissions,contextsandscopebelong to the top level: Discord ignores them on subcommands, andscopeon 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:
| Kind | Target 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
| Type | Answer |
|---|---|
String, &str | A message with that content |
Embed | A message with one embed |
Container | A Components V2 message of one container |
View, a #[view]’s Props | A Components V2 message (Views) |
ModalView, a modal #[view]’s Props | Opens a modal |
InteractionResponse | Exactly that; anything Discord accepts |
Deferred | “Thinking…”, then the result of Background Work (ctx.defer) |
DeferredUpdate | Nothing 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: Display | T, 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:
| Constructor | |
|---|---|
respond_with_message(content) | Text |
respond_with_ephemeral_message(content) | Ephemeral text |
respond_with_embed(embed) | One embed |
respond_with_components(components) | A Components V2 message |
InteractionResponse::ChannelMessageWithSource(data) | Any MessageCallbackData: content, up to 10 embeds, components, a poll, allowed mentions, flags |
InteractionResponse::Modal(data) | A modal (ModalCallbackData) |
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:
| Error | The user sees | Logged at |
|---|---|---|
HandlerError::user(msg) | msg | DEBUG |
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 message | WARN |
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’sCtx), then the state parameters - adds
counter_click::custom_id(state..), which encodes the prefix and the state into acustom_id, or fails with aCustomIdErrorpast 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):
| Type | Encoded as |
|---|---|
integers, floats, char, strings, Snowflake | the value |
bool | 1 / 0 |
Option<T> | empty for None (so Some(String::new()) reads back as None) |
| enums | the variant index, then its fields |
| sequences, maps | the 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):
| Attribute | Input | Settings besides label, description, id |
|---|---|---|
#[input] | TextInput | paragraph, min_len, max_len, placeholder, value |
#[select] | StringSelect | placeholder, min, max, option("Label", "value").. |
#[user_select], #[role_select], #[mentionable_select] | entity selects | placeholder, min, max |
#[channel_select] | ChannelSelect | those, and channel_types(GuildText, ..) |
#[radio] | RadioGroup | option("Label", "value").. |
#[checkboxes] | CheckboxGroup | min, max, option("Label", "value").. |
#[checkbox] | Checkbox | default |
#[file_upload] | FileUpload | min, max, file_types("image", ".pdf") |
labelis the title above the input (default: the field name),descriptiona line under it,idthe input’scustom_id(default: the field name).- Without
option(..)s, a#[derive(Choice)]field type supplies the options. - The field type (
ModalValue) isString,bool,Snowflake,User,Role,PartialChannel,Attachmentor a Choice enum, alone (required), in anOption(optional) or in aVec(optional, several). A type the input cannot submit (abooltext input, aVecradio group) is a compile error.
Modal handlers
#[modal("prefix")] on a function taking an optional ctx: ModalCtx, then the form, then
state parameters (as for components). It adds:
name::show(state..): theInteractionResponseopening the form, with the state in itscustom_idname::custom_id(state..): thecustom_idalone, 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!, text,{expressions}, attributes #[view]: Props, sub-Views, children- Inline handlers:
on:click,on:select,on:submit - Modal Views: forms read back as typed parameters
- Message options: ephemeral, allowed mentions, files
- Tag reference: every tag and its attributes
- Styling: colors, tone Views, and how errors look
- Without
#[view]: markup in plain functions, sending Views through REST
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:
| Where | What can go there |
|---|---|
| Top of a message | text, <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):
| Value | Becomes |
|---|---|
&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 Props | its 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&strworks for aStringorOption<String>, aTfor anOption<T>,"👍"for an emoji - an attribute the tag does not have is a compile error from the compiler
(
no method namedcolourfound 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 theViewitself. - a builder,
Progress::builder().label("Build").done(3).build(), which is what markup uses. Setters take anythingIntoAttrconverts, and leaving out a required Prop is a compile error. IntoResponse,IntoView,Rerenderfor the Props, and theViewHandlerrunning its inline handlers.
Props:
| Prop | In markup |
|---|---|
name: T | required |
name: Option<T> | optional, None when left out; name=value sets Some(value) |
#[prop(default = expr)] name: T | optional, expr when left out |
children: View | the 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 insnake_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 withcustom_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 aResultof one) re-renders the message the component is on (anUpdate) - anything else a Command can return (
CommandOutput): a new message,Ephemeral(..), aModalViewto open a modal,ctx.defer_update(..), …
| Event | Tags | Closure |
|---|---|---|
on:click:name | <Button> | move |ctx| .. |
on:select:name | selects | move |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_idis limited to 100 characters, prefix and handler name included. Past that, answering with the View fails with aHandlerError(the error rides inside the View until then). Keep captured Props small: ids and counters, not text. - Captured Props must implement
serde::SerializeandDeserializeOwned; integers,bool,String,Snowflake,Options, tuples, and#[derive(Serialize, Deserialize)]types do. - A
childrenProp 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 (totalis 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 itscustom_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.
Modal Views
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:
| Type | Required | Inputs |
|---|---|---|
String, a Choice enum | yes | <TextInput>, <RadioGroup>, <StringSelect> |
Option<String>, Option<Choice> | no | the same |
bool | no (checkboxes never are) | <Checkbox> |
Snowflake, User, Role, PartialChannel | yes | entity selects |
Attachment | yes | <FileUpload> |
Option<..> of those | no | the same |
Vec<..> of those, of String, of a Choice | no; several allowed | selects, <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>, {..}.
| Attribute | Type | |
|---|---|---|
accent_color | u32 (0xRRGGBB) | Color of the bar on the left; none when unset |
spoiler | bool | Blurs 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.
| Attribute | Type | |
|---|---|---|
accessory | impl 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.
| Attribute | Type | |
|---|---|---|
content | String | Special. The text, instead of children |
<Separator>
Separator: vertical space, with a divider line by default. No
children.
| Attribute | Type | |
|---|---|---|
small / large | flag | Special. Small (default) or large spacing |
spacing | SeparatorSpacing | The same, as a value |
divider | bool | false 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.
| Attribute | Type | |
|---|---|---|
src (or url) | a URL, a File or an &Attachment | Special, required. An image URL (or attachment://<filename>), a File to upload with the message, or an attachment Discord has |
description | String | Alt text, max 1024 characters |
spoiler | bool | Blurs the image until clicked |
<Gallery> (alias <MediaGallery>)
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.
| Attribute | Type | |
|---|---|---|
src (or url) | a URL, a File or an &Attachment | Special, required. A URL (or attachment://<filename>), a File to upload with the message, or an attachment Discord has |
description | String | Alt text, max 1024 characters |
spoiler | bool | Blurs 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).
| Attribute | Type | |
|---|---|---|
src (or url) | a File, or impl Into<String> | Special, required. The File to upload, or attachment://<filename> for one added with .file(..) |
spoiler | bool | Blurs the file until clicked |
Interactive
<Button>
ButtonComponent: in a <Row>, or as a <Section>’s
accessory. Children: the label, as text and {..}.
| Attribute | Type | |
|---|---|---|
primary / secondary / success / danger | flag | Special. The style: blurple, grey (the default), green, red |
style | ButtonStyle | Special. The style, as a value |
on:click=handler(state..) | Special. Answered by a #[component] handler (above) | |
on:click:name=closure | Special. An inline handler; #[view] only | |
custom_id | String | Answered by whatever handles this custom_id; max 100 characters |
url | String | A link button (style Link), opening the URL; no on:click or custom_id |
sku_id | Snowflake | A premium button (style Premium) offering the SKU; no label, emoji, on:click or custom_id |
label | String | Special. The label, instead of children; max 80 characters |
emoji | &str or PartialEmoji | Shown before the label: "👍", or a custom emoji |
disabled | bool | Greyed 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.
| Attribute | Type | |
|---|---|---|
on:select=handler(state..) | Special. Answered by a #[component] handler | |
on:select:name=closure | Special. An inline handler; #[view] only | |
custom_id | String | Answered by whatever handles this custom_id |
name | string literal | Special, modals only (required there). The input’s custom_id, matched to an on:submit parameter |
options | a #[derive(Choice)] enum | Special. One option per variant, before any <Option> children |
placeholder | String | Shown while nothing is selected; max 150 characters |
min (or min_values) | i32 | Fewest selections, 0-25 (default 1) |
max (or max_values) | i32 | Most selections, 1-25 (default 1; a Vec handler parameter makes it every option) |
disabled | bool | Not selectable; messages only |
required | bool | Modals 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 {..}.
| Attribute | Type | |
|---|---|---|
value | impl Into<String> | Special, required. What is submitted when chosen; max 100 characters |
label | String | Special. The label, instead of children; max 100 characters |
description | String | A second line under the label; max 100 characters |
emoji | &str or PartialEmoji | Selects only |
default | bool | Chosen 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:
| Attribute | Type | |
|---|---|---|
default_values | Vec<SelectDefaultValue> | Preselected entities: vec![SelectDefaultValue::user(id)] |
channel_types | Vec<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
<Modal>
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).
| Attribute | Type | |
|---|---|---|
title | impl Into<String> | Required. Shown at the top; max 45 characters |
on:submit=handler(state..) | Submitted to a #[modal] handler | |
on:submit:name=closure | An inline handler; #[view] only | |
custom_id | String | Submitted 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>.
| Attribute | Type | |
|---|---|---|
text (or label) | impl Into<String> | Required. The title; max 45 characters |
description | String | Under 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.
| Attribute | Type | |
|---|---|---|
paragraph | flag | Special. Multi-line; single-line by default |
style | TextInputStyle | Special. The same, as a value |
min_len (or min_length) | i32 | 0-4000 |
max_len (or max_length) | i32 | 1-4000 |
placeholder | String | Shown while empty; max 100 characters |
value | String | Prefilled text; max 4000 characters |
required | bool | Set from the submit parameter’s type |
<RadioGroup>
RadioGroup: pick one of 2-10 options. Children: <Option>s, and
{..} of GroupOptions.
| Attribute | Type | |
|---|---|---|
options | a #[derive(Choice)] enum | Special. One option per variant |
required | bool | Set from the submit parameter’s type |
<CheckboxGroup>
CheckboxGroup: pick any of 1-10 options. Children: as
<RadioGroup>.
| Attribute | Type | |
|---|---|---|
options | a #[derive(Choice)] enum | Special. One option per variant |
min (or min_values) | u8 | 0-10 |
max (or max_values) | u8 | 1-10 |
required | bool | Set from the submit parameter’s type |
<Checkbox>
Checkbox: one yes/no, submitted as a bool. Never required. No
children.
| Attribute | Type | |
|---|---|---|
default | bool | Checked initially |
<FileUpload>
FileUpload: attach files, submitted as
Attachments. No children.
| Attribute | Type | |
|---|---|---|
min (or min_values) | u8 | 0-10 |
max (or max_values) | u8 | 1-10 (a Vec parameter makes it 10) |
file_types | Vec<String> | Up to 10 of "image", "video", "audio", or extensions like ".pdf" |
required | bool | Set 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() {}
| Client | Authorized by | For |
|---|---|---|
ctx.client() → InteractionClient | the Interaction’s token (valid 15 minutes) | follow-up messages, the original response: followup, create_followup, edit_original, delete_original, … |
ctx.bot() → BotClient | the bot token (DISCORD_TOKEN); an error when the Adapter has none | everything 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,'staticcopy of the Interaction and State, with the sameuser(),guild_id(),client(),bot(), plussleepon 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();) andmovethem in. - Wrap the result of
deferinEphemeralto defer ephemerally; the final message is then ephemeral too, whateverworkreturns. - A failing
workedits 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
- Implement
EventHandler<S>on a unit struct, overriding the methods for the events you want; the rest ignore their event. - Register it on an App with
#[app(.., events = Events)]. At most one App in a tree setsevents(a bot has one events URL); the root App forwards to it. - In the Developer Portal, under Webhooks, set
the Events URL to
https://<your bot>/eventsand 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
| Method | Event | Payload |
|---|---|---|
application_authorized | The app was installed to a server or a user | ApplicationAuthorized |
application_deauthorized | A user removed the app | ApplicationDeauthorized |
entitlement_create | A user or guild bought a SKU | Entitlement |
entitlement_update | A subscription renewed or ended | Entitlement |
entitlement_delete | An entitlement was refunded or removed | Entitlement |
lobby_message_create | A message was sent in a lobby | LobbyMessage |
lobby_message_update | A lobby message was edited | LobbyMessage |
lobby_message_delete | A lobby message was deleted | LobbyMessageDelete |
unknown | Any other type (game DMs, quests, types added later), or one whose data did not match its model | the 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>.
| Adapter | State | Background Work | REST transport |
|---|---|---|---|
composure_adapter_cloudflare | Worker<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 response | fetch |
composure_adapter_tokio | Host<T>: your Bot Data T (from .with_data(..), also through Deref), the bot token, the HTTP client | a tokio::spawned task, awaited on shutdown | async 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 noctx) when they only answerctx: Ctx<impl Platform>when they use REST or deferctx: Ctx<impl HasData<Data = Config>>when they read the bot’s own configurationctx: 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).
| Variable | Cloudflare | tokio | |
|---|---|---|---|
DISCORD_PUBLIC_KEY | secret | env | Required: verifies requests |
DISCORD_TOKEN | secret | env | The bot token, for ctx.bot() |
DISCORD_APPLICATION_ID | var | env | For a bot client outside Interactions (cron, startup) |
PORT | env | Port 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).
| Span | Fields | Around |
|---|---|---|
interaction | interaction_id, interaction_type, name (the Command, or the custom_id prefix), guild_id, channel_id, user_id | dispatching one Interaction |
background_work | deferred work, inside its interaction span | |
event | event_type | handling a Webhook Event |
rest | method, 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.
- Take
Credentials, and offer a loader for the platform’s usual source on top (by the conventional names throughCredentials::from_lookup). - Implement
Platformon the State: aTransport(sendaRequest,sleep), a way tospawnBackground Work after the response, and the bot token; andHasDatafor the Bot Data. - Parse the public key once into a
PublicKeyand keep it. - For each request, call
handle_interaction_with_key(orhandle_event_with_keyon the events path) with the two signature headers and the body, and turn theInteractionOutcomeinto a response:status()andresponse()as JSON. When the response has files, send it withsend_with_filesand answer202instead. - 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 tokenctx.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 aTestPlatformin a struct of your own that forwardsPlatformandHasDatato 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.rsruns every example Command on aTestPlatform,regression.rsreplays signed fixtures for every Interaction shape, andperf.rsholds each path to an allocation budget.bots/composure/tests/offline.rs: wraps aTestPlatformto 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 /healthanswers 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_IDandPORT, 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”
| Cause | What you see | Fix |
|---|---|---|
| No answer within 3 seconds | “The application did not respond”; the handler finishes later | Answer 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 mismatch | 401 in the logs; the Endpoint URL won’t save | Set 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 load | 500 on Workers, logged on the isolate’s first request | A missing secret or var, or an error from .with_credentials / .with_data; set it and retry |
| Discord rejected the response | The handler ran, nothing is logged as an error | A 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 embeds | An error naming the field | A Components V2 message (a Container, a View) carries its text in text displays: move content and embeds into components |
Errors the user sees
| Message | Cause | Fix |
|---|---|---|
| “Unknown command.” | The App has no Command with that name and type: Discord’s Commands are out of sync with the deployed code | cargo 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 read | The log line has the error and its causes (sources) |
| The same, from a button posted before a deploy | Its custom_id was encoded by an older version of the handler: the state’s fields changed | Keep 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 characters | Keep 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 prefix | List 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.
| Status | Cause | Fix |
|---|---|---|
401 from ctx.bot() | DISCORD_TOKEN is missing, wrong, or reset in the Developer Portal | Set the current bot token |
401 from ctx.client() | The Interaction’s token expired: follow-ups and edits work for 15 minutes | Finish 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’s | Grant 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 bot | Check ctx.interaction().common.authorizing_integration_owners before calling the bot client, or answer through ctx.client(), which needs no membership |
| 429 | Rate 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.