use std::{
future::Future,
pin::pin,
sync::Arc,
task::{Context, Poll, Wake, Waker},
thread::{self, Thread},
};
use crate::{
command::{HandlerError, InteractionHandler, dispatch},
types::{Interaction, InteractionResponse, Snowflake},
};
mod builders;
mod platform;
mod response;
mod sign;
mod transport;
pub use builders::{
CommandData, ComponentData, InteractionBuilder, ModalData, OptionValue, autocomplete, command,
component, message_command, modal, user_command,
};
pub use platform::TestPlatform;
pub use response::ResponseExt;
pub use sign::{SignedRequest, TestKey};
pub use transport::{MockTransport, json_response};
pub const APPLICATION_ID: Snowflake = Snowflake::from_u64(1052322265397739523);
pub const INTERACTION_TOKEN: &str = "token";
pub const CHANNEL_ID: Snowflake = Snowflake::from_u64(300000000000000001);
pub const GUILD_ID: Snowflake = Snowflake::from_u64(798662131062931547);
pub const MESSAGE_ID: Snowflake = Snowflake::from_u64(500000000000000001);
pub mod users {
use crate::types::{Snowflake, User};
pub fn alice() -> User {
user(100000000000000001, "alice", Some("Alice"))
}
pub fn bob() -> User {
user(100000000000000002, "bob", Some("Bob"))
}
pub fn user(id: u64, username: &str, global_name: Option<&str>) -> User {
User {
avatar: None,
discriminator: "0".into(),
global_name: global_name.map(Into::into),
id: Snowflake::from_u64(id),
public_flags: 0,
username: username.into(),
}
}
}
pub fn block_on<F: Future>(future: F) -> F::Output {
struct Unpark(Thread);
impl Wake for Unpark {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
}
thread_local! {
static WAKER: Waker = Waker::from(Arc::new(Unpark(thread::current())));
}
let waker = WAKER.with(Waker::clone);
let mut cx = Context::from_waker(&waker);
let mut future = pin!(future);
loop {
match future.as_mut().poll(&mut cx) {
Poll::Ready(output) => return output,
Poll::Pending => thread::park(),
}
}
}
pub fn run<S, H: InteractionHandler<S>>(
handler: &H,
interaction: impl Into<Interaction>,
state: &S,
) -> Result<InteractionResponse, HandlerError> {
block_on(dispatch(interaction.into(), handler, state))
}
pub fn answer<S, H: InteractionHandler<S>>(
handler: &H,
interaction: impl Into<Interaction>,
state: &S,
) -> InteractionResponse {
run(handler, interaction, state).unwrap_or_else(|error| handler.render_error(&error))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::{
command::{HasData, Platform},
rest::{Method, Request, Transport},
types::{ApplicationCommandInteraction, Interaction, Permissions},
};
struct Echo;
impl<S: Platform> InteractionHandler<S> for Echo {
async fn command(
&self,
command: ApplicationCommandInteraction,
state: &S,
) -> Result<InteractionResponse, HandlerError> {
match command.data.name.as_str() {
"fail" => Err(HandlerError::user("nope")),
"later" => {
let transport = state.transport();
state.spawn(Box::pin(async move {
let request = Request {
method: Method::Get,
url: format!("{}/later", crate::rest::DISCORD_API),
headers: Vec::new(),
body: None,
};
transport.send(&request).await.map_err(HandlerError::new)?;
Ok(())
}));
Ok(InteractionResponse::DeferredChannelMessageWithSource(None))
}
name => Ok(InteractionResponse::respond_with_message(name.to_string())),
}
}
}
#[test]
fn builds_commands_as_discord_sends_them() {
let json = command("mod")
.group("users")
.subcommand("ban")
.user_option("user", &users::bob())
.option("days", 7)
.option("reason", "spam")
.in_guild(GUILD_ID)
.permissions(Permissions::BanMembers)
.locale("fr")
.to_json();
assert_eq!(json["type"], 2);
assert_eq!(json["guild_id"], "798662131062931547");
assert_eq!(json["member"]["user"]["username"], "alice");
assert_eq!(json["member"]["permissions"], "4");
assert_eq!(json.get("user"), None);
assert_eq!(json["locale"], "fr");
let group = &json["data"]["options"][0];
assert_eq!(
(&group["name"], &group["type"]),
(&json!("users"), &json!(2))
);
let ban = &group["options"][0];
assert_eq!((&ban["name"], &ban["type"]), (&json!("ban"), &json!(1)));
assert_eq!(
ban["options"],
json!([
{ "name": "user", "type": 6, "value": "100000000000000002" },
{ "name": "days", "type": 4, "value": 7 },
{ "name": "reason", "type": 3, "value": "spam" },
])
);
assert_eq!(
json["data"]["resolved"]["users"]["100000000000000002"]["username"],
"bob"
);
let Interaction::ApplicationCommand(command) = command("ping").by(&users::bob()).build()
else {
panic!("a command")
};
assert_eq!(command.data.name, "ping");
assert_eq!(command.common.invoking_user().unwrap().username, "bob");
}
#[test]
fn builds_every_interaction_kind() {
assert!(matches!(
user_command("Avatar", &users::bob()).build(),
Interaction::ApplicationCommand(_)
));
let Interaction::ApplicationCommand(message) = message_command("Quote", "hello").build()
else {
panic!("a command")
};
assert_eq!(message.data.target_id, Some(MESSAGE_ID));
assert!(matches!(
autocomplete("fruit").focused("name", "Bl").build(),
Interaction::ApplicationCommandAutocomplete(_)
));
let select = component("colors").values(["red", "blue"]).to_json();
assert_eq!(
select["data"],
json!({ "custom_id": "colors", "component_type": 3, "values": ["red", "blue"] })
);
assert!(matches!(
component("counter:4").build(),
Interaction::MessageComponent(_)
));
let submit = modal("feedback")
.field("topic", "Docs")
.select("kind", ["Idea"]);
let json = submit.to_json();
assert_eq!(
json["data"]["components"][1]["component"]["values"],
json!(["Idea"])
);
assert!(matches!(
modal("feedback").field("topic", "Docs").build(),
Interaction::ModalSubmit(_)
));
let edited = command("ping").json(|json| json["guild_locale"] = "de".into());
assert_eq!(edited.to_json()["guild_locale"], "de");
}
#[test]
fn runs_and_answers_like_an_adapter() {
let platform = TestPlatform::new();
let response = answer(&Echo, command("echo").option("text", "hi"), &platform);
assert_eq!(response.response_type(), 4);
assert_eq!(response.text().as_deref(), Some("echo"));
assert!(!response.is_ephemeral());
assert_eq!(
run(&Echo, command("fail"), &platform)
.unwrap_err()
.to_string(),
"nope"
);
let error = answer(&Echo, command("fail"), &platform);
assert_eq!(error.text().as_deref(), Some("nope"));
assert!(error.is_ephemeral());
}
#[test]
fn background_work_runs_on_demand() {
let platform = TestPlatform::new();
let response = answer(&Echo, command("later"), &platform);
assert_eq!(response.response_type(), 5);
assert_eq!(platform.queued_work(), 1);
assert!(platform.transport().requests().is_empty());
platform.run_background().unwrap();
assert_eq!(platform.queued_work(), 0);
assert_eq!(
platform.transport().sent(),
[(Method::Get, "/later".into())]
);
}
#[test]
fn platform_data_and_token() {
let platform = TestPlatform::new().with_data(41).with_bot_token(None);
assert_eq!(*platform.data(), 41);
assert_eq!(Platform::bot_token(&platform), None);
assert_eq!(Platform::bot_token(&TestPlatform::new()), Some("bot-token"));
}
#[test]
fn transport_queues_then_answers_then_defaults() {
let transport = MockTransport::new()
.respond(404, "{}")
.answering(|request| {
request
.url
.starts_with("https://example.com")
.then(|| json_response(200, &json!({ "from": "example" })))
});
let request = |url: &str| crate::rest::Request {
method: Method::Get,
url: url.into(),
headers: Vec::new(),
body: None,
};
let send = |url: &str| block_on(transport.send(&request(url))).unwrap();
assert_eq!(send("https://example.com/a").status, 404, "queued first");
assert_eq!(send("https://example.com/a").body, br#"{"from":"example"}"#);
assert_eq!(send("https://discord.com/api/v10/users/@me").body, b"{}");
assert_eq!(
transport.paths(),
[
"https://example.com/a",
"https://example.com/a",
"/users/@me"
]
);
}
#[test]
fn responses_find_components() {
let response = json!({
"type": 4,
"data": { "flags": 32768, "components": [{ "type": 17, "components": [
{ "type": 10, "content": "one" },
{ "type": 9, "components": [{ "type": 10, "content": "two" }],
"accessory": { "type": 2, "style": 1, "label": "Next", "custom_id": "page:2" } },
] }] },
});
assert_eq!(response.text().as_deref(), Some("one\ntwo"));
assert_eq!(response.custom_id("Next").as_deref(), Some("page:2"));
assert_eq!(response.button("Back"), None);
assert_eq!(response.components().len(), 5);
}
#[test]
fn keys_sign_like_discord() {
let key = TestKey::default();
let request = key.signed(command("ping").body());
let public_key = block_on(crate::auth::PublicKey::from_hex(&key.public_key())).unwrap();
assert!(
block_on(public_key.verify(&request.signature, &request.timestamp, &request.body))
.is_ok()
);
assert_eq!(request.headers()[0].0, crate::adapter::SIGNATURE_HEADER);
}
}