Tutorial: an HTTP bot in Rust
Build Dice Bot from an empty folder: a Rust service that receives slash commands as signed webhooks, checks every signature, and answers through the REST API. By the end it's registered, verified, installed in a nest through OAuth2, and rolling dice for real people.
member types /roll 2d20+3
│
▼
Aleja ── POST /webhooks/aleja ──▶ Dice Bot (Aleja-Signature: t=…,v1=…)
◀──────── 202 Accepted ───┘
◀── POST /v1/interactions/{id}/messages ── "🎲 2d20+3 → [14, 7] + 3 = 24"
The finished project lives in the Aleja repository at examples/rust-http-bot. Every file below
is complete; copy them in order and it compiles.
Encryption. HTTP bots are the opt-in path that isn't end-to-end encrypted. The command and the bot's replies pass through Aleja in plain text, and the app labels them NOT E2EE. Ordinary messages in the same channel stay encrypted. For a bot that reads encrypted conversations, use the bot SDK. See two ways to run a bot.
What you need
- Rust 1.80 or later (
rustup update). - An Aleja account.
- A public HTTPS address that reaches your machine. While developing, a tunnel does it:
cloudflared tunnel --url http://localhost:3000orngrok http 3000. Note thehttps://…URL it prints. (Running your own Aleja server? SetALEJA_WEBHOOKS_ALLOW_LOCAL=1on it and usehttp://localhost:3000directly.)
1. Register the app
- Open the developer console and sign in.
- New application: username
dicebot, display nameDice Bot. - Under Scopes, keep only
commands(to register slash commands) andinteractions(to take them over HTTP and reply). An HTTP bot needs nothing else. Fewer scopes, smaller blast radius. - Create it and copy the bot token (
abot_…). It's shown once.
2. Create the project
cargo new aleja-dicebot
cd aleja-dicebot
Cargo.toml:
[package]
name = "aleja-dicebot"
version = "0.1.0"
edition = "2021"
description = "An Aleja bot that takes slash commands over signed webhooks and answers over the REST API"
publish = false
[dependencies]
anyhow = "1"
axum = "0.8"
base64 = "0.22"
dotenvy = "0.15"
hex = "0.4"
hmac = "0.12"
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync", "time"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2"
.gitignore, so secrets never reach a repository:
/target
.env
.env:
ALEJA_API=https://api.aleja.app
ALEJA_BOT_TOKEN=abot_paste_yours_here
# Filled in at step 9:
ALEJA_WEBHOOK_SECRET=whsec_placeholder
PORT=3000
RUST_LOG=aleja_dicebot=info
The layout we're building:
src/
main.rs the listener: verify, dedupe, acknowledge, dispatch
config.rs settings from the environment
signature.rs Aleja-Signature verification
events.rs typed webhook payloads
api.rs REST calls with the bot token
commands.rs /roll, /flip, /choose, /countdown
install.rs OAuth2 + PKCE install flow
3. Configuration
Fail fast, with a message that says what to fix. src/config.rs:
use anyhow::{bail, Context, Result};
/// Everything the bot reads from the environment (or `.env`) at startup.
#[derive(Clone)]
pub struct Config {
pub api: String,
pub bot_token: String,
pub webhook_secret: String,
pub port: u16,
pub oauth: Option<OAuthConfig>,
}
/// Present when the bot serves its own install link.
#[derive(Clone)]
pub struct OAuthConfig {
pub client_id: String,
pub client_secret: String,
pub authorize_url: String,
pub redirect_uri: String,
pub scopes: String,
}
fn var(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn required(name: &str) -> Result<String> {
var(name).with_context(|| format!("{name} is not set. Copy .env.example to .env and fill it in."))
}
impl Config {
pub fn from_env() -> Result<Self> {
let api = required("ALEJA_API")?.trim_end_matches('/').to_string();
let bot_token = required("ALEJA_BOT_TOKEN")?;
if !bot_token.starts_with("abot_") {
bail!("ALEJA_BOT_TOKEN should start with abot_. Copy it from the console's Token & scopes tab.");
}
let webhook_secret = required("ALEJA_WEBHOOK_SECRET")?;
if !webhook_secret.starts_with("whsec_") {
bail!("ALEJA_WEBHOOK_SECRET should start with whsec_. Copy it from the console's Webhooks tab.");
}
let port = match var("PORT") {
Some(port) => port.parse().context("PORT must be a number from 1 to 65535")?,
None => 3000,
};
let oauth = match (var("ALEJA_CLIENT_ID"), var("ALEJA_CLIENT_SECRET")) {
(Some(client_id), Some(client_secret)) => {
let public_url = required("PUBLIC_URL")?.trim_end_matches('/').to_string();
Some(OAuthConfig {
client_id,
client_secret,
authorize_url: var("ALEJA_AUTHORIZE_URL")
.unwrap_or_else(|| "https://aleja.app/developers/authorize/".to_string()),
redirect_uri: format!("{public_url}/oauth/callback"),
scopes: var("ALEJA_INSTALL_SCOPES").unwrap_or_else(|| "interactions".to_string()),
})
}
(None, None) => None,
_ => bail!("Set both ALEJA_CLIENT_ID and ALEJA_CLIENT_SECRET, or neither."),
};
Ok(Self { api, bot_token, webhook_secret, port, oauth })
}
}
4. Verify the signature
This is the part that keeps strangers from puppeting your bot. Anyone can POST to a public URL; only Aleja knows the signing secret. Every request carries:
Aleja-Signature: t=1761234567,v1=5f2c…e91a
v1 is hex(HMAC-SHA256(secret, "{t}.{raw body}")). Four rules:
- Use the raw bytes. Parse JSON only after verifying. Re-serializing changes whitespace and key order, and the HMAC won't match.
- Check the clock. Refuse a
tmore than 5 minutes off. A captured request can't be replayed later. - Compare in constant time. A byte-by-byte
==that stops early leaks, through timing, how much of a forged signature was right. - Accept any
v1. For 24 hours after you rotate the secret, Aleja signs with both the new and old secrets, so the bot keeps working while you deploy the new one.
src/signature.rs:
//! Checking `Aleja-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]`.
//!
//! Each `v1` is HMAC-SHA256 over `"{t}.{raw body}"`, keyed with the subscription's signing secret
//! (the whole `whsec_…` string). Right after a secret rotation the header carries two `v1` values,
//! one per secret, so a bot holding either one keeps working.
use std::fmt;
use hmac::{Hmac, Mac};
use sha2::Sha256;
/// How far the signed timestamp may be from this machine's clock. Older requests are replays.
pub const TOLERANCE_SECS: i64 = 300;
#[derive(Debug, PartialEq, Eq)]
pub enum SignatureError {
Missing,
Malformed,
Stale { skew: i64 },
Mismatch,
}
impl fmt::Display for SignatureError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Missing => write!(f, "no Aleja-Signature header"),
Self::Malformed => write!(f, "Aleja-Signature has no timestamp or no v1 signature"),
Self::Stale { skew } => write!(f, "signed {skew} s away from now, outside the {TOLERANCE_SECS} s window"),
Self::Mismatch => write!(f, "no v1 signature matches this body and secret"),
}
}
}
impl std::error::Error for SignatureError {}
pub fn verify(secret: &str, header: Option<&str>, body: &[u8], now: i64) -> Result<(), SignatureError> {
let header = header.ok_or(SignatureError::Missing)?;
let mut timestamp = None;
let mut candidates = Vec::new();
for part in header.split(',') {
let Some((key, value)) = part.trim().split_once('=') else { continue };
match key {
"t" => timestamp = value.parse::<i64>().ok(),
"v1" => {
if let Ok(bytes) = hex::decode(value) {
candidates.push(bytes);
}
}
_ => {}
}
}
let timestamp = timestamp.ok_or(SignatureError::Malformed)?;
if candidates.is_empty() {
return Err(SignatureError::Malformed);
}
let skew = now - timestamp;
if skew.abs() > TOLERANCE_SECS {
return Err(SignatureError::Stale { skew });
}
let signed_prefix = format!("{timestamp}.");
let matched = candidates.iter().any(|candidate| {
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts keys of any length");
mac.update(signed_prefix.as_bytes());
mac.update(body);
// verify_slice compares in constant time, so timing reveals nothing about the right answer.
mac.verify_slice(candidate).is_ok()
});
if matched {
Ok(())
} else {
Err(SignatureError::Mismatch)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sign(secret: &str, timestamp: i64, body: &[u8]) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(format!("{timestamp}.").as_bytes());
mac.update(body);
hex::encode(mac.finalize().into_bytes())
}
const BODY: &[u8] = br#"{"id":"d1","type":"ping","data":{"challenge":"abc"}}"#;
const NOW: i64 = 1_761_234_567;
#[test]
fn accepts_a_fresh_signature() {
let header = format!("t={NOW},v1={}", sign("whsec_new", NOW, BODY));
assert_eq!(verify("whsec_new", Some(&header), BODY, NOW + 3), Ok(()));
}
#[test]
fn accepts_either_secret_during_rotation() {
let header = format!("t={NOW},v1={},v1={}", sign("whsec_new", NOW, BODY), sign("whsec_old", NOW, BODY));
assert_eq!(verify("whsec_old", Some(&header), BODY, NOW), Ok(()));
assert_eq!(verify("whsec_new", Some(&header), BODY, NOW), Ok(()));
}
#[test]
fn rejects_tampering_wrong_secrets_and_replays() {
let header = format!("t={NOW},v1={}", sign("whsec_new", NOW, BODY));
assert_eq!(verify("whsec_new", Some(&header), b"{}", NOW), Err(SignatureError::Mismatch));
assert_eq!(verify("whsec_other", Some(&header), BODY, NOW), Err(SignatureError::Mismatch));
assert_eq!(
verify("whsec_new", Some(&header), BODY, NOW + TOLERANCE_SECS + 1),
Err(SignatureError::Stale { skew: TOLERANCE_SECS + 1 })
);
let moved = format!("t={},v1={}", NOW + 1, sign("whsec_new", NOW, BODY));
assert_eq!(verify("whsec_new", Some(&moved), BODY, NOW), Err(SignatureError::Mismatch));
}
#[test]
fn rejects_missing_and_malformed_headers() {
assert_eq!(verify("whsec_new", None, BODY, NOW), Err(SignatureError::Missing));
assert_eq!(verify("whsec_new", Some("v1=00"), BODY, NOW), Err(SignatureError::Malformed));
assert_eq!(verify("whsec_new", Some(&format!("t={NOW}")), BODY, NOW), Err(SignatureError::Malformed));
assert_eq!(verify("whsec_new", Some(&format!("t={NOW},v1=zz")), BODY, NOW), Err(SignatureError::Malformed));
}
}
The last test is the interesting one: moving t by one second breaks the signature, because the
timestamp is part of what's signed. An attacker can't refresh an old request's clock.
5. Model the events
Every delivery is an envelope with an id, a type and a data object whose shape depends on the
type (full list). Serde turns them into Rust types; anything unexpected
becomes Event::Other, so a new event type next year won't crash the bot. src/events.rs:
//! The JSON Aleja posts to the webhook. Every delivery is an envelope; `data` depends on `type`.
use serde::Deserialize;
use serde_json::{Map, Value};
#[derive(Debug, Deserialize)]
pub struct Envelope {
/// The delivery id, also sent as `Aleja-Delivery`. Retries and redeliveries reuse it.
pub id: String,
#[serde(rename = "type")]
pub kind: String,
pub bot_id: String,
pub data: Value,
}
#[derive(Debug, Deserialize)]
pub struct Ping {
pub challenge: String,
}
#[derive(Debug, Deserialize)]
pub struct InteractionCommand {
pub interaction: InteractionRef,
pub command: Command,
pub channel: Channel,
pub nest: Option<Nest>,
pub user: Person,
}
#[derive(Debug, Deserialize)]
pub struct InteractionRef {
pub id: String,
/// Unix milliseconds. Replies after this answer 410 Gone.
pub expires_at: i64,
}
#[derive(Debug, Deserialize)]
pub struct Command {
pub name: String,
#[serde(default)]
pub options: Map<String, Value>,
}
#[derive(Debug, Deserialize)]
pub struct Channel {
pub name: Option<String>,
/// `"nest"` or `"dm"`.
pub kind: String,
}
#[derive(Debug, Deserialize)]
pub struct Nest {
pub id: String,
pub name: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct Person {
pub username: String,
pub display_name: String,
}
#[derive(Debug, Deserialize)]
pub struct Installation {
pub nest: Nest,
pub installed_by: Person,
pub scopes: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct Uninstallation {
pub nest: Nest,
/// `removed_by_nest`, `removed_by_owner`, `banned`, `nest_deleted` or `bot_deleted`.
pub reason: String,
}
#[derive(Debug)]
pub enum Event {
Ping(Ping),
Command(InteractionCommand),
Installed(Installation),
Reauthorized(Installation),
Uninstalled(Uninstallation),
Other(String),
}
impl Envelope {
pub fn event(&self) -> Result<Event, serde_json::Error> {
let data = self.data.clone();
Ok(match self.kind.as_str() {
"ping" => Event::Ping(serde_json::from_value(data)?),
"interaction.command" => Event::Command(serde_json::from_value(data)?),
"installation.created" => Event::Installed(serde_json::from_value(data)?),
"installation.updated" => Event::Reauthorized(serde_json::from_value(data)?),
"installation.deleted" => Event::Uninstalled(serde_json::from_value(data)?),
other => Event::Other(other.to_string()),
})
}
}
impl Nest {
pub fn label(&self) -> &str {
self.name.as_deref().unwrap_or(&self.id)
}
}
An interaction.command delivery, for reference:
{
"id": "0b1e6c1a-…",
"type": "interaction.command",
"created_at": 1761234567000,
"bot_id": "6f1c…",
"data": {
"interaction": { "id": "9c2d…", "expires_at": 1761235467000, "reply_path": "/v1/interactions/9c2d…/messages" },
"command": { "name": "roll", "options": { "dice": "2d20+3" } },
"channel": { "id": "…", "name": "general", "kind": "nest" },
"nest": { "id": "…", "name": "Tabletop" },
"user": { "id": "…", "username": "alex", "display_name": "Alex" },
"message_id": "…"
}
}
6. Talk back: the REST client
The bot answers with its token in Authorization: Bearer abot_…. Four calls cover it: who am I,
publish commands, reply, edit. The client waits out 429 responses for as long as the server asks
and turns any other failure into an error carrying the server's own explanation. src/api.rs:
//! The few REST calls the bot makes, with the bot token, plus the OAuth2 code exchange.
use std::time::Duration;
use anyhow::{anyhow, bail, Result};
use reqwest::{header::RETRY_AFTER, Client, Method, StatusCode};
use serde::{de::DeserializeOwned, Deserialize};
use serde_json::{json, Value};
use crate::config::OAuthConfig;
const MAX_RATE_LIMIT_WAITS: u32 = 3;
#[derive(Clone)]
pub struct Api {
http: Client,
base: String,
token: String,
}
#[derive(Debug, Deserialize)]
pub struct Me {
pub id: String,
pub username: String,
}
#[derive(Debug, Deserialize)]
struct Posted {
id: String,
}
#[derive(Debug, Deserialize)]
struct Commands {
commands: Vec<Value>,
}
/// What `POST /v1/oauth2/token` reports about the install that produced the code.
#[derive(Debug, Deserialize)]
pub struct Installed {
pub scope: String,
pub installation: InstalledIn,
}
#[derive(Debug, Deserialize)]
pub struct InstalledIn {
pub nest: crate::events::Nest,
}
impl Api {
pub fn new(base: &str, token: &str) -> Result<Self> {
let http = Client::builder()
.user_agent(concat!("aleja-dicebot/", env!("CARGO_PKG_VERSION")))
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(15))
.build()?;
Ok(Self { http, base: base.to_string(), token: token.to_string() })
}
/// Sends one request with the bot token. A 429 waits as long as the server asks, a few times;
/// any other failure comes back with the server's own explanation.
async fn call<T: DeserializeOwned>(&self, method: Method, path: &str, body: Option<Value>) -> Result<T> {
let mut waits = 0;
loop {
let mut request = self.http.request(method.clone(), format!("{}{path}", self.base)).bearer_auth(&self.token);
if let Some(body) = &body {
request = request.json(body);
}
let response = request.send().await?;
let status = response.status();
let retry_after = response
.headers()
.get(RETRY_AFTER)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u64>().ok());
let text = response.text().await?;
if status == StatusCode::TOO_MANY_REQUESTS && waits < MAX_RATE_LIMIT_WAITS {
waits += 1;
let wait_ms = serde_json::from_str::<Value>(&text)
.ok()
.and_then(|body| body["retry_after_ms"].as_u64())
.or(retry_after.map(|seconds| seconds * 1000))
.unwrap_or(1000);
tokio::time::sleep(Duration::from_millis(wait_ms.min(30_000))).await;
continue;
}
if !status.is_success() {
let message = serde_json::from_str::<Value>(&text)
.ok()
.and_then(|body| body["error"].as_str().map(str::to_owned))
.unwrap_or(text);
bail!("{method} {path} answered {status}: {message}");
}
return serde_json::from_str(&text).map_err(|err| anyhow!("{method} {path} sent unexpected JSON: {err}"));
}
}
pub async fn me(&self) -> Result<Me> {
self.call(Method::GET, "/v1/me", None).await
}
/// Replaces the bot's slash commands. Returns how many are registered.
pub async fn set_commands(&self, commands: Value) -> Result<usize> {
let stored: Commands = self.call(Method::PUT, "/v1/bots/@me/commands", Some(json!({ "commands": commands }))).await?;
Ok(stored.commands.len())
}
/// Posts a reply to an interaction. Returns the new message's id, for editing it later.
pub async fn reply(&self, interaction_id: &str, content: &str) -> Result<String> {
let posted: Posted = self
.call(Method::POST, &format!("/v1/interactions/{interaction_id}/messages"), Some(json!({ "content": content })))
.await?;
Ok(posted.id)
}
pub async fn edit(&self, interaction_id: &str, message_id: &str, content: &str) -> Result<()> {
let _: Value = self
.call(
Method::PATCH,
&format!("/v1/interactions/{interaction_id}/messages/{message_id}"),
Some(json!({ "content": content })),
)
.await?;
Ok(())
}
/// Trades an authorization code for the install it records, proving the PKCE verifier.
pub async fn exchange_code(&self, oauth: &OAuthConfig, code: &str, verifier: &str) -> Result<Installed> {
let response = self
.http
.post(format!("{}/v1/oauth2/token", self.base))
.basic_auth(&oauth.client_id, Some(&oauth.client_secret))
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", oauth.redirect_uri.as_str()),
("code_verifier", verifier),
])
.send()
.await?;
let status = response.status();
let body: Value = response.json().await?;
if !status.is_success() {
bail!(
"{}: {}",
body["error"].as_str().unwrap_or("token exchange failed"),
body["error_description"].as_str().unwrap_or("no description")
);
}
Ok(serde_json::from_value(body)?)
}
}
7. The commands
The bot publishes its commands at startup with PUT /v1/bots/@me/commands, which replaces the whole
list. Aleja validates options against these definitions before the bot ever sees an invocation, so
seconds here can only be 3, 5 or 10. The bot still clamps it: never trust input, even pre-checked
input.
/countdown shows the edit endpoint: one reply, edited each second, instead of five messages.
Every handler draws its random numbers before its first .await, because rand's thread-local
generator can't cross one. src/commands.rs:
//! The bot's slash commands: what it registers, and how it answers each one.
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::Result;
use rand::{seq::SliceRandom, Rng};
use serde_json::{json, Value};
use tracing::info;
use crate::api::Api;
use crate::events::InteractionCommand;
const MAX_DICE: u32 = 100;
const MAX_SIDES: u32 = 1000;
const MAX_MODIFIER: i64 = 10_000;
const SHOWN_ROLLS: usize = 20;
const MAX_CHOICES: usize = 20;
/// Published with `PUT /v1/bots/@me/commands` at startup, replacing whatever was there.
pub fn definitions() -> Value {
json!([
{
"name": "roll",
"description": "Roll dice, like 2d20+3",
"options": [
{ "name": "dice", "description": "Dice to roll: NdS+M, e.g. d20, 4d6, 2d8-1", "type": "string", "required": false }
]
},
{ "name": "flip", "description": "Flip a coin", "options": [] },
{
"name": "choose",
"description": "Pick one of several options",
"options": [
{ "name": "from", "description": "Options separated by commas", "type": "string", "required": true }
]
},
{
"name": "countdown",
"description": "Count down out loud, then launch",
"options": [
{
"name": "seconds",
"description": "How long to count",
"type": "integer",
"required": false,
"choices": [{ "name": "3", "value": 3 }, { "name": "5", "value": 5 }, { "name": "10", "value": 10 }]
}
]
}
])
}
#[derive(Debug, PartialEq, Eq)]
pub struct Dice {
count: u32,
sides: u32,
modifier: i64,
}
impl std::fmt::Display for Dice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}d{}", self.count, self.sides)?;
match self.modifier {
0 => Ok(()),
m if m > 0 => write!(f, "+{m}"),
m => write!(f, "{m}"),
}
}
}
/// Reads `NdS`, `dS`, `NdS+M` or `NdS-M`, ignoring spaces and case.
pub fn parse_dice(raw: &str) -> Result<Dice, String> {
let text: String = raw.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_lowercase();
let (dice, modifier) = match text.find(['+', '-']) {
Some(at) => {
let modifier: i64 = text[at..].parse().map_err(|_| "the modifier after + or - must be a whole number".to_string())?;
(&text[..at], modifier)
}
None => (text.as_str(), 0),
};
let (count, sides) = dice.split_once('d').ok_or("write dice like 2d20 or d6")?;
let count: u32 = if count.is_empty() { 1 } else { count.parse().map_err(|_| "the number of dice must be a whole number")? };
let sides: u32 = sides.parse().map_err(|_| "the number of sides must be a whole number")?;
if !(1..=MAX_DICE).contains(&count) {
return Err(format!("roll 1 to {MAX_DICE} dice"));
}
if !(2..=MAX_SIDES).contains(&sides) {
return Err(format!("dice have 2 to {MAX_SIDES} sides"));
}
if modifier.abs() > MAX_MODIFIER {
return Err(format!("keep the modifier within ±{MAX_MODIFIER}"));
}
Ok(Dice { count, sides, modifier })
}
fn roll(dice: &Dice, rng: &mut impl Rng) -> String {
let rolls: Vec<u32> = (0..dice.count).map(|_| rng.gen_range(1..=dice.sides)).collect();
let total = rolls.iter().map(|&r| i64::from(r)).sum::<i64>() + dice.modifier;
let shown = if rolls.len() <= SHOWN_ROLLS {
rolls.iter().map(u32::to_string).collect::<Vec<_>>().join(", ")
} else {
format!("{} dice", rolls.len())
};
let modifier = match dice.modifier {
0 => String::new(),
m if m > 0 => format!(" + {m}"),
m => format!(" − {}", m.abs()),
};
format!("🎲 **{dice}** → [{shown}]{modifier} = **{total}**")
}
fn choose(raw: &str, rng: &mut impl Rng) -> Result<String, String> {
let options: Vec<&str> = raw.split(',').map(str::trim).filter(|option| !option.is_empty()).collect();
if options.len() < 2 {
return Err("give me at least two options, separated by commas".into());
}
if options.len() > MAX_CHOICES {
return Err(format!("that's more than {MAX_CHOICES} options"));
}
let pick = options.choose(rng).expect("checked non-empty");
Ok(format!("🎯 Out of {} options: **{pick}**", options.len()))
}
fn now_ms() -> i64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
}
/// Answers one command. Everything random is drawn before the first `.await`, since the thread RNG
/// can't be held across one.
pub async fn run(api: &Api, invocation: InteractionCommand) -> Result<()> {
let id = invocation.interaction.id.as_str();
let who = &invocation.user.display_name;
info!(
command = %invocation.command.name,
user = %invocation.user.username,
channel = invocation.channel.name.as_deref().unwrap_or("direct message"),
kind = %invocation.channel.kind,
nest = invocation.nest.as_ref().map(|nest| nest.label()).unwrap_or("-"),
"command"
);
let options = &invocation.command.options;
match invocation.command.name.as_str() {
"roll" => {
let raw = options.get("dice").and_then(Value::as_str).unwrap_or("d20");
let text = match parse_dice(raw) {
Ok(dice) => format!("{who} rolls {}", roll(&dice, &mut rand::thread_rng())),
Err(problem) => format!("Couldn't roll `{raw}`: {problem}."),
};
api.reply(id, &text).await?;
}
"flip" => {
let side = if rand::thread_rng().gen_bool(0.5) { "Heads" } else { "Tails" };
api.reply(id, &format!("🪙 {who} flips a coin: **{side}**")).await?;
}
"choose" => {
let raw = options.get("from").and_then(Value::as_str).unwrap_or_default();
let text = choose(raw, &mut rand::thread_rng()).unwrap_or_else(|problem| format!("Can't choose: {problem}."));
api.reply(id, &text).await?;
}
"countdown" => {
let seconds = options.get("seconds").and_then(Value::as_i64).unwrap_or(5).clamp(1, 10);
let message_id = api.reply(id, &format!("⏱️ **{seconds}**")).await?;
for left in (1..seconds).rev() {
tokio::time::sleep(Duration::from_secs(1)).await;
if now_ms() >= invocation.interaction.expires_at {
return Ok(());
}
api.edit(id, &message_id, &format!("⏱️ **{left}**")).await?;
}
tokio::time::sleep(Duration::from_secs(1)).await;
api.edit(id, &message_id, &format!("🚀 Liftoff, {who}!")).await?;
}
other => {
api.reply(id, &format!("I don't know /{other} yet. My commands were just updated; try again in a moment.")).await?;
}
}
Ok(())
}
The example project also has unit tests for parse_dice, roll and choose at the bottom of this
file.
8. The listener
This is where it comes together. For each request the handler:
- Verifies the signature before touching the body. A bad one gets
401and nothing else. - Parses the envelope and checks it's addressed to this bot.
- Answers
pingright away with the challenge. That's how the console verifies the endpoint. - Dedupes on the delivery id. Delivery is at-least-once: if your
202is lost in transit, Aleja sends the event again with the same id, and it should do nothing the second time. - Acknowledges with
202immediately and does the work on a spawned task. Aleja waits 10 seconds; a slow answer is a failed attempt and a duplicate later. - If handling fails, it forgets the id, so a redelivery from the console's log gets another try.
src/main.rs:
//! Dice Bot: an Aleja bot that takes slash commands over signed webhooks and answers with the REST API.
//! The tutorial at /docs/tutorial-rust-bot walks through every part of it.
mod api;
mod commands;
mod config;
mod events;
mod install;
mod signature;
use std::{
collections::HashMap,
net::SocketAddr,
sync::Arc,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use anyhow::Context;
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use serde_json::json;
use tokio::sync::Mutex;
use tracing::{debug, error, info, warn};
use tracing_subscriber::EnvFilter;
use api::Api;
use config::Config;
use events::{Envelope, Event};
/// How long a handled delivery id is remembered. Retries of one event span well under this.
const SEEN_FOR: Duration = Duration::from_secs(24 * 60 * 60);
pub struct AppState {
config: Config,
api: Api,
bot_id: String,
seen: Mutex<HashMap<String, Instant>>,
installs: install::Pending,
}
impl AppState {
/// True the first time a delivery id shows up. Aleja delivers at least once, so the same event
/// can arrive twice (a timeout after you answered, a redelivery from the console).
async fn first_sighting(&self, id: &str) -> bool {
let mut seen = self.seen.lock().await;
seen.retain(|_, at| at.elapsed() < SEEN_FOR);
seen.insert(id.to_string(), Instant::now()).is_none()
}
/// Lets a later redelivery through when handling failed.
async fn forget(&self, id: &str) {
self.seen.lock().await.remove(id);
}
}
fn unix_now() -> i64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)
}
async fn webhook(State(app): State<Arc<AppState>>, headers: HeaderMap, body: Bytes) -> Response {
let signature = headers.get("aleja-signature").and_then(|value| value.to_str().ok());
if let Err(err) = signature::verify(&app.config.webhook_secret, signature, &body, unix_now()) {
warn!(error = %err, "rejected an unsigned or badly signed request");
return (StatusCode::UNAUTHORIZED, "bad signature").into_response();
}
let envelope: Envelope = match serde_json::from_slice(&body) {
Ok(envelope) => envelope,
Err(err) => {
warn!(error = %err, "signed but unreadable event");
return (StatusCode::BAD_REQUEST, "unreadable event").into_response();
}
};
if envelope.bot_id != app.bot_id {
warn!(bot_id = %envelope.bot_id, "event for a different bot; is the webhook secret shared?");
return (StatusCode::FORBIDDEN, "wrong bot").into_response();
}
let event = match envelope.event() {
Ok(event) => event,
Err(err) => {
warn!(error = %err, kind = %envelope.kind, "event data didn't match its type");
return (StatusCode::BAD_REQUEST, "unreadable event data").into_response();
}
};
let attempt = headers.get("aleja-attempt").and_then(|value| value.to_str().ok()).unwrap_or("1");
if let Event::Ping(ping) = event {
info!("verification ping answered");
return Json(json!({ "challenge": ping.challenge })).into_response();
}
if !app.first_sighting(&envelope.id).await {
debug!(delivery = %envelope.id, attempt, "duplicate delivery acknowledged");
return StatusCode::OK.into_response();
}
debug!(delivery = %envelope.id, kind = %envelope.kind, attempt, "accepted");
// Answer now and work afterwards: Aleja waits 10 seconds, and a slow answer counts as a failure.
let id = envelope.id;
tokio::spawn(async move {
if let Err(err) = handle(&app, event).await {
error!(delivery = %id, error = %err, "handling failed");
app.forget(&id).await;
}
});
StatusCode::ACCEPTED.into_response()
}
async fn handle(app: &AppState, event: Event) -> anyhow::Result<()> {
match event {
Event::Command(invocation) => commands::run(&app.api, invocation).await,
Event::Installed(install) => {
info!(nest = install.nest.label(), by = %install.installed_by.username, scopes = ?install.scopes, "installed");
Ok(())
}
Event::Reauthorized(install) => {
info!(nest = install.nest.label(), by = %install.installed_by.username, scopes = ?install.scopes, "grants changed");
Ok(())
}
Event::Uninstalled(removal) => {
info!(nest = removal.nest.label(), reason = %removal.reason, "removed from a nest");
Ok(())
}
Event::Ping(_) => Ok(()),
Event::Other(kind) => {
debug!(%kind, "ignored an event this bot doesn't handle");
Ok(())
}
}
}
async fn shutdown() {
let _ = tokio::signal::ctrl_c().await;
info!("shutting down");
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("aleja_dicebot=info")))
.init();
let config = Config::from_env()?;
let api = Api::new(&config.api, &config.bot_token)?;
let me = api.me().await.context("couldn't sign in with ALEJA_BOT_TOKEN")?;
info!(bot = %me.username, id = %me.id, "signed in");
let count = api
.set_commands(commands::definitions())
.await
.context("couldn't publish slash commands; does the token have the commands scope?")?;
info!(count, "slash commands published");
let port = config.port;
let serves_installs = config.oauth.is_some();
let app = Arc::new(AppState {
config,
api,
bot_id: me.id,
seen: Mutex::new(HashMap::new()),
installs: install::Pending::default(),
});
let mut router = Router::new()
.route("/webhooks/aleja", post(webhook))
.route("/health", get(|| async { "ok" }));
if serves_installs {
router = router.route("/install", get(install::start)).route("/oauth/callback", get(install::callback));
}
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = tokio::net::TcpListener::bind(addr).await.with_context(|| format!("couldn't listen on {addr}"))?;
info!(%addr, "listening; point a webhook at <public url>/webhooks/aleja");
if serves_installs {
info!("install link: <public url>/install");
}
axum::serve(listener, router.with_state(app)).with_graceful_shutdown(shutdown()).await?;
Ok(())
}
main.rs declares mod install;, which comes in step 10. To build before then, create
src/install.rs from step 10 now; it stays inactive until OAuth2 settings are in .env.
9. Connect the webhook
-
Start the bot:
cargo runINFO aleja_dicebot: signed in bot=dicebot id=6f1c… INFO aleja_dicebot: slash commands published count=4 INFO aleja_dicebot: listening; point a webhook at <public url>/webhooks/aleja addr=0.0.0.0:3000It refuses to start until
ALEJA_WEBHOOK_SECRETstarts withwhsec_; the placeholder from step 2 is enough for now. -
Start the tunnel and note its URL, e.g.
https://dice-xyz.trycloudflare.com. -
In the console, open Dice Bot → Webhooks → Add subscription. URL:
https://dice-xyz.trycloudflare.com/webhooks/aleja. Events:interaction.command,installation.created,installation.updated,installation.deleted. -
Copy the signing secret into
.envasALEJA_WEBHOOK_SECRET, and restart the bot. -
Press Verify. The bot logs
verification ping answered, and the subscription's lamp turns Live.
If verification fails, the console shows why: a status code, a timeout, or "didn't echo the
challenge". A 401 from your bot means the secret in .env isn't the one shown, or the bot wasn't
restarted.
Test it without Aleja
You can sign a request yourself to check the bot end to end (bash with openssl):
source .env
BOT_ID=6f1c… # from the "signed in" log line
BODY="{\"id\":\"local-1\",\"type\":\"ping\",\"created_at\":0,\"bot_id\":\"$BOT_ID\",\"data\":{\"challenge\":\"hello\"}}"
T=$(date +%s)
SIG=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac "$ALEJA_WEBHOOK_SECRET" -hex | sed 's/^.* //')
curl -s http://localhost:3000/webhooks/aleja \
-H "Content-Type: application/json" -H "Aleja-Signature: t=$T,v1=$SIG" -d "$BODY"
# {"challenge":"hello"}
Change one character of BODY after signing, or use an old T, and the bot answers 401.
10. Install it with OAuth2
To get into a nest, the bot sends people to Aleja's consent screen. They pick a nest where they have Manage Nest, approve the scopes, and come back with a one-time code. PKCE ties that code to this exact request: the bot keeps a random verifier, sends only its SHA-256 challenge, and proves it holds the verifier when exchanging the code. A code intercepted on the way back is useless without it.
src/install.rs:
//! OAuth2 installs with PKCE: `/install` sends someone to Aleja's consent screen, and
//! `/oauth/callback` finishes the install when they come back.
use std::{
collections::HashMap,
sync::Arc,
time::{Duration, Instant},
};
use axum::{
extract::{Query, State},
http::StatusCode,
response::{Html, IntoResponse, Redirect, Response},
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use rand::RngCore;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use tracing::{info, warn};
use crate::AppState;
/// How long someone has to finish the consent screen.
const STATE_TTL: Duration = Duration::from_secs(10 * 60);
/// Verifiers waiting for their callback, keyed by the `state` sent along with them.
#[derive(Default)]
pub struct Pending(Mutex<HashMap<String, (String, Instant)>>);
impl Pending {
async fn insert(&self, state: String, verifier: String) {
let mut pending = self.0.lock().await;
pending.retain(|_, (_, at)| at.elapsed() < STATE_TTL);
pending.insert(state, (verifier, Instant::now()));
}
/// Each state works once.
async fn take(&self, state: &str) -> Option<String> {
let (verifier, at) = self.0.lock().await.remove(state)?;
(at.elapsed() < STATE_TTL).then_some(verifier)
}
}
fn random_text(bytes: usize) -> String {
let mut raw = vec![0u8; bytes];
rand::thread_rng().fill_bytes(&mut raw);
URL_SAFE_NO_PAD.encode(raw)
}
/// The S256 challenge: BASE64URL(SHA-256(verifier)), without padding.
fn challenge_for(verifier: &str) -> String {
URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
}
fn escape(text: &str) -> String {
text.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
}
fn page(status: StatusCode, title: &str, body: &str) -> Response {
let html = format!(
"<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width'>\
<title>{title}</title>\
<body style='font:15px/1.5 ui-monospace,monospace;background:#0d0d0d;color:#ececea;max-width:36rem;margin:12vh auto;padding:0 1rem'>\
<h1 style='font-size:1.25rem'>{title}</h1><p>{body}</p></body>",
title = escape(title)
);
(status, Html(html)).into_response()
}
pub async fn start(State(app): State<Arc<AppState>>) -> Response {
let Some(oauth) = &app.config.oauth else {
return StatusCode::NOT_FOUND.into_response();
};
let verifier = random_text(48);
let state = random_text(16);
let challenge = challenge_for(&verifier);
let Ok(mut url) = url::Url::parse(&oauth.authorize_url) else {
return page(StatusCode::INTERNAL_SERVER_ERROR, "Misconfigured", "ALEJA_AUTHORIZE_URL isn't a URL.");
};
url.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", &oauth.client_id)
.append_pair("redirect_uri", &oauth.redirect_uri)
.append_pair("scope", &oauth.scopes)
.append_pair("state", &state)
.append_pair("code_challenge", &challenge)
.append_pair("code_challenge_method", "S256");
app.installs.insert(state, verifier).await;
Redirect::to(url.as_str()).into_response()
}
#[derive(Deserialize)]
pub struct Callback {
code: Option<String>,
state: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
pub async fn callback(State(app): State<Arc<AppState>>, Query(query): Query<Callback>) -> Response {
let Some(oauth) = &app.config.oauth else {
return StatusCode::NOT_FOUND.into_response();
};
let verifier = match query.state.as_deref() {
Some(state) => app.installs.take(state).await,
None => None,
};
let Some(verifier) = verifier else {
return page(
StatusCode::BAD_REQUEST,
"Link expired",
"This install link expired or was already used. <a style='color:#c9f66b' href='/install'>Start again</a>.",
);
};
if let Some(error) = query.error {
let reason = query.error_description.unwrap_or(error);
return page(StatusCode::OK, "Install cancelled", &escape(&reason));
}
let Some(code) = query.code else {
return page(StatusCode::BAD_REQUEST, "No code", "Aleja didn't send an authorization code.");
};
match app.api.exchange_code(oauth, &code, &verifier).await {
Ok(installed) => {
let nest = installed.installation.nest.label().to_string();
info!(nest = %nest, scopes = %installed.scope, "installed through OAuth2");
page(
StatusCode::OK,
"Installed",
&format!(
"Dice Bot is in <b>{}</b> with <code>{}</code>. Type <code>/roll</code> in any channel there.",
escape(&nest),
escape(&installed.scope)
),
)
}
Err(err) => {
warn!(error = %err, "OAuth2 code exchange failed");
page(StatusCode::BAD_GATEWAY, "Install failed", &escape(&err.to_string()))
}
}
}
The state value does double duty: it finds the right verifier, and since it's random and works
once, a forged callback (a CSRF attempt) finds nothing and is refused.
Turn it on:
-
Console → Dice Bot → OAuth2 → Generate secret. Copy it.
-
Add the redirect URI
https://dice-xyz.trycloudflare.com/oauth/callbackand Save URIs. -
Add to
.env:ALEJA_CLIENT_ID=6f1c… # the app id ALEJA_CLIENT_SECRET=acs_… PUBLIC_URL=https://dice-xyz.trycloudflare.com -
Restart the bot and open
https://dice-xyz.trycloudflare.com/install. Pick a nest, approve, and you land on "Installed in Tabletop". The bot logs both theinstallation.createdevent and the code exchange.
The consent screen asks for interactions only, the least an HTTP bot needs in a nest. Ask for more
with ALEJA_INSTALL_SCOPES="interactions members.read", within the app's own scopes.
11. Roll
In the Aleja app, in a channel of that nest, type /. Dice Bot's commands appear, with a
Not end-to-end encrypted chip on them. Run /roll dice:2d20+3:
Alex /roll dice:2d20+3 NOT E2EE
Dice Bot Alex rolls 🎲 2d20+3 → [14, 7] + 3 = 24 NOT E2EE
Then /countdown seconds:5 and watch one message count down.
Everything is on the record in Webhooks → Delivery log: each event, its attempts, status codes, latency and payload. Stop the bot and run a command: the delivery goes pending, retries at 10 s, 20 s, 40 s… and is delivered the moment the bot comes back, within the interaction's 15 minutes.
12. Run the tests
cargo test
test signature::tests::accepts_a_fresh_signature ... ok
test signature::tests::accepts_either_secret_during_rotation ... ok
test signature::tests::rejects_tampering_wrong_secrets_and_replays ... ok
test install::tests::challenge_matches_rfc_7636 ... ok
…
test result: ok. 14 passed
Going to production
- Secrets. Load
ALEJA_BOT_TOKEN,ALEJA_WEBHOOK_SECRETandALEJA_CLIENT_SECRETfrom your platform's secret store, not a file in the image. - Rotate without downtime. Webhooks → Rotate secret, deploy the new secret within 24 hours; both are accepted meanwhile. Rotating the bot token or client secret takes effect immediately, so deploy right after.
- More than one instance. The in-memory dedupe map and pending installs are per process. Behind
a load balancer, keep them in a shared store (Redis
SET NX EX, or a table with a unique key on the delivery id). - Keep handlers idempotent. Even with dedupe, design each handler so running it twice is harmless.
- Clock. Run NTP. A clock more than 5 minutes off rejects every request as stale.
- Watch the dial. The console shows a 24-hour success rate per subscription. After 10 deliveries in a row fail, Aleja switches the subscription off; switch it back on and redeliver from the log.
Troubleshooting
| Symptom | Cause |
|---|---|
couldn't sign in with ALEJA_BOT_TOKEN … 401 | Wrong or reset token |
couldn't publish slash commands … 403 | The token lacks the commands scope |
Bot logs rejected an unsigned or badly signed request | .env has a different secret than the subscription, or it was rotated more than 24 hours ago |
Stale signature errors | This machine's clock is off |
Commands are missing from the / menu | Not installed in that nest, or commands not yet published (restart the bot) |
| Commands go to the bot as encrypted messages instead of webhooks | The subscription isn't verified and on, it doesn't include interaction.command, or the token or that nest's grant lacks interactions |
Replies answer 410 Gone | More than 15 minutes passed since the command |
Replies answer 400 … up to 5 replies | Edit an earlier reply instead |
| Consent screen says "redirect_uri isn't one of the app's registered redirect URIs" | PUBLIC_URL + /oauth/callback must match a saved URI exactly |
| Callback says "Link expired" | More than 10 minutes on the consent screen, the bot restarted in between, or the link was reused |
Next: the platform reference for every endpoint, header and limit.
