├── .github └── workflows │ ├── examples.yml │ └── rust.yml ├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── attributes ├── Cargo.toml ├── README.md └── src │ └── lib.rs ├── examples ├── Cargo.toml ├── e1_basic_handler │ ├── Cargo.toml │ ├── README.md │ └── src │ │ └── main.rs ├── e2_tls_handler │ ├── Cargo.toml │ ├── README.md │ └── src │ │ └── main.rs ├── e3_deffering_commands │ ├── Cargo.toml │ ├── README.md │ └── src │ │ └── main.rs ├── e4_followups │ ├── Cargo.toml │ ├── README.md │ └── src │ │ └── main.rs ├── e5_manipulating_original_message │ ├── Cargo.toml │ ├── README.md │ └── src │ │ └── main.rs ├── e6_components │ ├── Cargo.toml │ ├── README.md │ ├── img │ │ └── demo.gif │ └── src │ │ └── main.rs ├── e7_embeds │ ├── Cargo.toml │ ├── README.md │ └── src │ │ └── main.rs ├── e8_managing_commands │ ├── Cargo.toml │ ├── README.md │ └── src │ │ └── main.rs └── e9_accessing_other_data │ ├── Cargo.toml │ ├── README.md │ └── src │ └── main.rs ├── rustfmt.toml └── src ├── handler.rs ├── lib.rs ├── macros.rs ├── security.rs ├── tests.rs └── types ├── application.rs ├── attachment.rs ├── components.rs ├── embed.rs ├── guild.rs ├── interaction.rs ├── mod.rs ├── modal.rs └── user.rs /.github/workflows/examples.yml: -------------------------------------------------------------------------------- 1 | name: Rust Examples 2 | 3 | on: 4 | push: 5 | branches: [ "*" ] 6 | pull_request: 7 | branches: [ "*" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Build 19 | working-directory: examples 20 | run: cargo build 21 | 22 | rustfmt: 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: Rustfmt 28 | run: 'bash -c "find examples/ -not \( -path \"examples/target\" -prune \) -name \"*.rs\" | xargs rustfmt --check"' 29 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: RustCI 2 | 3 | on: 4 | push: 5 | branches: [ "*" ] 6 | pull_request: 7 | branches: [ "*" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build-default: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Build 19 | run: cargo build --verbose 20 | 21 | build-handler: 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Build 27 | run: cargo build --features handler --verbose 28 | #- name: Run tests on handler 29 | # run: cargo test --verbose --features handler 30 | 31 | build-extended-handler: 32 | runs-on: ubuntu-latest 33 | 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: Build 37 | run: cargo build --features extended-handler --verbose 38 | #- name: Run tests on handler 39 | # run: cargo test --verbose --features extended-handler 40 | 41 | rustfmt: 42 | runs-on: ubuntu-latest 43 | 44 | steps: 45 | - uses: actions/checkout@v4 46 | - name: Rustfmt 47 | run: 'bash -c "find src/ -name \"*.rs\" | xargs rustfmt --check"' 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | attributes/target 6 | attributes/debug 7 | 8 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 9 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 10 | Cargo.lock 11 | attributes/Cargo.lock 12 | # These are backup files generated by rustfmt 13 | **/*.rs.bk 14 | 15 | # JetBrains IDEs 16 | .idea/ 17 | *.iml 18 | *.iws 19 | *.ipr 20 | 21 | # VSCode 22 | .vscode/ 23 | 24 | # Environment files 25 | .env 26 | 27 | # Testing folder 28 | /testing -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rusty_interaction" 3 | version = "0.3.0" 4 | authors = ["Hugo Woesthuis "] 5 | license = "ISC" 6 | description = "Handle Discord Interactions as outgoing webhook" 7 | repository = "https://github.com/showengineer/rusty-interaction" 8 | readme = "README.md" 9 | edition = "2018" 10 | keywords = ["discord", "interactions", "discord-interactions", "slash-commands", "api"] 11 | categories = ["api-bindings", "web-programming"] 12 | include = ["src/**/*", "LICENSE.md", "README.md"] 13 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 14 | 15 | 16 | [dependencies] 17 | # For types 18 | serde_repr = "0.1" 19 | serde_json = "1" 20 | chrono = "0.4" 21 | 22 | # For security 23 | ed25519-dalek = { version = "2.1.0", optional = true } 24 | hex = { version = "0.4", optional = true } 25 | 26 | # For handler 27 | 28 | actix = { version = "0.13.1", optional = true } 29 | actix-web = { version = "4.4.0", features = ["rustls"], optional = true } 30 | actix-rt = { version = "2", optional = true } 31 | reqwest = { version = "0.11.23", features = ["json"], optional = true } 32 | 33 | async-trait = { version = "0.1", optional = true } 34 | 35 | log = { version = "0.4", optional = true } 36 | 37 | anymap = {version = "1.0.0-beta.2", optional = true} 38 | rustls = {version = "0.20", optional = true } 39 | [dependencies.serde] 40 | version = "1" 41 | features = ["derive"] 42 | 43 | [dependencies.serde_with] 44 | version = "3.4.0" 45 | features = [ "macros", "chrono", "json" ] 46 | 47 | [dependencies.attributes] 48 | path = "./attributes" 49 | version = "0.0.8" 50 | optional = true 51 | 52 | [features] 53 | default = ["types", "security"] 54 | security = ["ed25519-dalek", "hex", "types"] 55 | builder = ["log"] 56 | types = [] 57 | handler = ["types", "builder", "security", "actix", "actix-web", "actix-rt", "rustls", "async-trait", "attributes", "reqwest", "anymap"] 58 | extended-handler = ["handler"] 59 | 60 | [package.metadata.docs.rs] 61 | all-features = true 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Hugo Woesthuis and contributors 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![ci-img]][ci-link] [![cio-img]][cio-link] [![lic-img]][lic-link] [![doc-img]][doc-link] 2 | 3 | **OBSOLETE: I am dropping support of this library, as of 14-3-2025. I do not have the time anymore to maintain this lib. Feel free to fork and further develop** 4 | 5 | # Rusty Interaction 6 | This library provides types and helper functions for handling Discord's [Interactions](https://discord.com/developers/docs/interactions/slash-commands). It also provides an actix-web backend handler system to handle Interactions through your own API (instead of using the gateway). 7 | 8 | 9 | ## Getting started 10 | To install this library, add this dependency entry to your `Cargo.toml` file: 11 | ```toml 12 | rusty_interaction = "0" 13 | ``` 14 | By default, this only exposes the types and security check function. If you want to use the handler, add the following to your `Cargo.toml`: 15 | ```toml 16 | [dependencies.rusty_interaction] 17 | version = "0" 18 | features = ["handler"] 19 | ``` 20 | Take a look at the [documentation](https://docs.rs/rusty_interaction) and the [examples](/examples) to get yourself familiar with using the library. 21 | 22 | # Basic bot/handler 23 | Please take a look at the following examples: 24 | - [Basic HTTP handler](https://github.com/0x2b00b1e5/rusty-interaction/tree/main/examples/e1_basic_handler) 25 | - [Basic HTTPS handler](https://github.com/0x2b00b1e5/rusty-interaction/tree/main/examples/e2_tls_handler) 26 | 27 | ## Contributing 28 | More than welcome! :D 29 | 30 | ## What it has right now 31 | - [x] - Data models exposure 32 | - [x] - Interaction validation (`crate::security::verify_discord_message()`) 33 | - [x] - Receive Interactions from Discord 34 | - [x] - Bind interactions to a function (with the help of a macro) 35 | - [x] - Properly respond to interactions from Discord 36 | - [x] - Nice system to make follow-up messages. 37 | - [x] - Nice system to manage guild-specific commands. 38 | - [x] - Support for components (buttons, dropdowns, etc) 39 | - [ ] - Not a pile of spaghetti code that just works (oops...👀) 40 | 41 | 42 | 43 | ## Difference between receiving interactions through the gateway and your own endpoint 44 | The gateway requires you to have a discord client where you receive interactions. 45 | Setting up your own endpoint makes Discord send the interactions to your own API endpoint (ex. `https://example.com/api/discord/interactions`). 46 | 47 | If you already have an API that runs your service and you're looking to integrate with Discord, this way of receiving interactions can be really interesting. 48 | 49 | One distinct difference is that you do not need a bot or oauth token for most features. Some features (like command management) do require a bot token. 50 | 51 | ### Ok, I want to receive interactions through the gateway. Does your library support that? 52 | No. If you want to receive interactions through the gateway, you want to take a look at [Serenity](https://github.com/serenity-rs/serenity) or one of the [other libraries](https://discord.com/developers/docs/topics/community-resources#libraries-discord-libraries). 53 | 54 | [ci-link]: https://github.com/0x2b00b1e5/rusty-interaction/actions 55 | [ci-img]: https://img.shields.io/github/workflow/status/0x2b00b1e5/rusty-interaction/RustCI?style=flat-square 56 | [cio-link]: https://crates.io/crates/rusty_interaction 57 | [cio-img]: https://img.shields.io/crates/v/rusty-interaction?style=flat-square 58 | [lic-link]: /LICENSE 59 | [lic-img]: https://img.shields.io/crates/l/rusty-interaction?style=flat-square 60 | [doc-link]: https://docs.rs/rusty_interaction 61 | [doc-img]: https://img.shields.io/docsrs/rusty_interaction/latest?style=flat-square 62 | -------------------------------------------------------------------------------- /attributes/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "attributes" 3 | version = "0.0.8" 4 | authors = ["Hugo Woesthuis "] 5 | edition = "2018" 6 | description = "Procedural macros for command creation with rusty-interaction" 7 | license = "ISC" 8 | readme = "README.md" 9 | homepage = "https://github.com/0x2b00b1e5/rusty-interaction" 10 | 11 | [lib] 12 | proc-macro = true 13 | 14 | [dependencies] 15 | syn = {version = "1.0.70", features=["full"]} 16 | quote = {version = "1.0.9"} 17 | -------------------------------------------------------------------------------- /attributes/README.md: -------------------------------------------------------------------------------- 1 | # Rusty Interaction Attributes 2 | Attribute macros for [rusty_interaction](https://github.com/hugopilot/rusty-interaction) 3 | -------------------------------------------------------------------------------- /attributes/src/lib.rs: -------------------------------------------------------------------------------- 1 | extern crate proc_macro; 2 | 3 | use proc_macro::*; 4 | 5 | use quote::format_ident; 6 | use quote::quote; 7 | 8 | use syn::{Expr, ExprReturn, FnArg, ReturnType, Stmt}; 9 | 10 | fn handler( 11 | _attr: TokenStream, 12 | item: TokenStream, 13 | defer_return: quote::__private::TokenStream, 14 | ) -> TokenStream { 15 | // There is _probably_ a more efficient way to do what I want to do, but hey I am here 16 | // to learn so why not join me on my quest to create this procedural macro...lol 17 | let mut defer = false; 18 | 19 | // Parse the stream of tokens to something more usable. 20 | let input = syn::parse_macro_input!(item as syn::ItemFn); 21 | 22 | // Let's see if the programmer wants to respond with a deferring acknowlegdement first. 23 | // If so, the end-result needs to be built differently. 24 | for at in &input.attrs { 25 | for seg in at.path.segments.clone() { 26 | if seg.ident == "defer" { 27 | defer = true; 28 | } 29 | } 30 | } 31 | 32 | // Ok here comes the fun part 33 | 34 | // Get the function name 35 | let fname = &input.sig.ident; 36 | // Get the visibility (public fn, private fn, etc) 37 | let vis = &input.vis; 38 | 39 | // Get the parameters and return types 40 | let params = &input.sig.inputs; 41 | let ret_sig = &input.sig.output; 42 | 43 | // Must be filled later, but define its type for now. 44 | let ret: syn::Type; 45 | 46 | // Get the function body 47 | let body = &input.block; 48 | 49 | // Check for a proper return type and fill ret if found. 50 | match ret_sig { 51 | ReturnType::Default => { 52 | panic!("Expected an `Result` return type, but got no return type. Consider adding `-> Result` to your function signature."); 53 | } 54 | ReturnType::Type(_a, b) => { 55 | ret = *b.clone(); 56 | } 57 | } 58 | 59 | // Find the name of the Context parameter 60 | let mut ctxname: Option = None; 61 | let mut handlename: Option = None; 62 | // eprintln!("{:#?}", params); 63 | 64 | // I am honestly laughing at this... 65 | // But hey it works! :D 66 | for p in params { 67 | if let FnArg::Typed(t) = p { 68 | match &*t.ty { 69 | // This might be a Context 70 | syn::Type::Path(b) => { 71 | for segment in b.path.segments.clone() { 72 | if segment.ident == "Context" { 73 | if let syn::Pat::Ident(a) = &*t.pat { 74 | ctxname = Some(a.ident.clone()); 75 | break; 76 | } 77 | } else if segment.ident == "InteractionHandler" { 78 | panic!("Cannot take ownership of `InteractionHandler`. Try using &InteractionHandler!") 79 | } 80 | } 81 | } 82 | // This might be an &InteractionHandler! 83 | syn::Type::Reference(r) => { 84 | let e = r.elem.clone(); 85 | if let syn::Type::Path(w) = &*e { 86 | for segment in w.path.segments.clone() { 87 | if segment.ident == "InteractionHandler" { 88 | if let syn::Pat::Ident(a) = &*t.pat { 89 | handlename = Some(a.ident.clone()); 90 | break; 91 | } 92 | } 93 | } 94 | } 95 | } 96 | _ => { 97 | continue; 98 | } 99 | } 100 | } 101 | if let FnArg::Receiver(_) = p { 102 | panic!("`self` arguments are not allowed. If you need to access data in your function, use the `InteractionHandler.data` field and `InteractionHandler::add_data` method") 103 | } 104 | } 105 | 106 | if ctxname.is_none() { 107 | panic!("Couldn't determine the Context parameter. Make sure you take a `Context` as an argument"); 108 | } 109 | 110 | let mut ih_n = quote!(_); 111 | 112 | if handlename.is_some() { 113 | ih_n = quote!(#handlename); 114 | } 115 | 116 | // Using quasi-quoting to generate a new function. This is what will be the end function returned to the compiler. 117 | if !defer { 118 | // Build the function 119 | let subst_fn = quote! { 120 | #vis fn #fname (#ih_n: &mut InteractionHandler, #ctxname: Context) -> ::std::pin::Pin<::std::boxed::Box + '_>>{ 121 | Box::pin(async move { 122 | #body 123 | }) 124 | } 125 | }; 126 | subst_fn.into() 127 | } 128 | // Deferring is requested, this will require a bit more manipulation. 129 | else { 130 | // Create two functions. One that actually does the work, and one that handles the threading. 131 | 132 | let act_fn = format_ident!("__actual_{}", fname); 133 | 134 | let subst_fn = quote! { 135 | fn #act_fn (#ih_n: &mut InteractionHandler, #ctxname: Context) -> ::std::pin::Pin<::std::boxed::Box + '_>>{ 136 | Box::pin(async move { 137 | #body 138 | }) 139 | } 140 | #vis fn #fname (ihd: &mut InteractionHandler, ctx: Context) -> ::std::pin::Pin<::std::boxed::Box + '_>>{ 141 | Box::pin(async move { 142 | // TODO: Try to do this without cloning. 143 | let mut __ih_c = ihd.clone(); 144 | 145 | ::rusty_interaction::actix::Arbiter::spawn(async move { 146 | 147 | let __response = #act_fn (&mut __ih_c, ctx.clone()).await; 148 | if let Ok(__r) = __response{ 149 | if __r.r#type != InteractionResponseType::Pong && __r.r#type != InteractionResponseType::None{ 150 | if let Err(i) = ctx.edit_original(&WebhookMessage::from(__r)).await{ 151 | ::rusty_interaction::log::error!("Editing original message failed: {:?}", i); 152 | } 153 | } 154 | } 155 | else{ 156 | // Nothing 157 | } 158 | 159 | 160 | }); 161 | 162 | return InteractionResponseBuilder::default().respond_type(#defer_return).finish(); 163 | 164 | }) 165 | } 166 | }; 167 | subst_fn.into() 168 | } 169 | } 170 | 171 | #[proc_macro_attribute] 172 | /// Convenience procedural macro that allows you to bind an async function to the [`InteractionHandler`] for handling component interactions. 173 | pub fn component_handler(attr: TokenStream, item: TokenStream) -> TokenStream { 174 | let ret = quote!( 175 | ::rusty_interaction::types::interaction::InteractionResponseType::DefferedUpdateMessage 176 | ); 177 | 178 | handler(attr, item, ret) 179 | } 180 | 181 | #[proc_macro_attribute] 182 | /// Convenience procedural macro that allows you to bind an async function to the [`InteractionHandler`] 183 | pub fn slash_command(attr: TokenStream, item: TokenStream) -> TokenStream { 184 | let ret = quote!(::rusty_interaction::types::interaction::InteractionResponseType::DefferedChannelMessageWithSource); 185 | 186 | handler(attr, item, ret) 187 | } 188 | 189 | #[proc_macro_attribute] 190 | /// Send out a deffered channel message response before doing work. 191 | pub fn defer(_attr: TokenStream, item: TokenStream) -> TokenStream { 192 | item 193 | } 194 | 195 | #[doc(hidden)] 196 | #[proc_macro_attribute] 197 | #[doc(hidden)] 198 | // This is just here to make the tests work...lol 199 | pub fn slash_command_test(_attr: TokenStream, item: TokenStream) -> TokenStream { 200 | // There is _probably_ a more efficient way to do what I want to do, but hey I am here 201 | // to learn so why not join me on my quest to create this procedural macro...lol 202 | let mut defer = false; 203 | 204 | // Parse the stream of tokens to something more usable. 205 | let input = syn::parse_macro_input!(item as syn::ItemFn); 206 | 207 | // Let's see if the programmer wants to respond with a deferring acknowlegdement first. 208 | // If so, the end-result needs to be built differently. 209 | for at in &input.attrs { 210 | for seg in at.path.segments.clone() { 211 | if seg.ident == "defer" { 212 | defer = true; 213 | } 214 | } 215 | } 216 | 217 | // Ok here comes the fun part 218 | 219 | // Get the function name 220 | let fname = &input.sig.ident; 221 | // Get the visibility (public fn, private fn, etc) 222 | let vis = &input.vis; 223 | 224 | // Get the parameters and return types 225 | let params = &input.sig.inputs; 226 | let ret_sig = &input.sig.output; 227 | 228 | // Must be filled later, but define its type for now. 229 | let ret: syn::Type; 230 | 231 | // Get the function body 232 | let body = &input.block; 233 | 234 | // Check for a proper return type and fill ret if found. 235 | match ret_sig { 236 | ReturnType::Default => { 237 | panic!("Expected an `InteractionResponse` return type, but got no return type. Consider adding `-> InteractionResponse` to your function signature."); 238 | } 239 | ReturnType::Type(_a, b) => { 240 | ret = *b.clone(); 241 | } 242 | } 243 | 244 | // Find the name of the Context parameter 245 | let mut ctxname: Option = None; 246 | let mut handlename: Option = None; 247 | // eprintln!("{:#?}", params); 248 | 249 | // I am honestly laughing at this... 250 | // But hey it works! :D 251 | for p in params { 252 | if let FnArg::Typed(t) = p { 253 | match &*t.ty { 254 | // This might be a Context 255 | syn::Type::Path(b) => { 256 | for segment in b.path.segments.clone() { 257 | if segment.ident == "Context" { 258 | if let syn::Pat::Ident(a) = &*t.pat { 259 | ctxname = Some(a.ident.clone()); 260 | break; 261 | } 262 | } 263 | } 264 | } 265 | // This might be an &InteractionHandler! 266 | syn::Type::Reference(r) => { 267 | let e = r.elem.clone(); 268 | if let syn::Type::Path(w) = &*e { 269 | for segment in w.path.segments.clone() { 270 | if segment.ident == "InteractionHandler" { 271 | if let syn::Pat::Ident(a) = &*t.pat { 272 | handlename = Some(a.ident.clone()); 273 | break; 274 | } 275 | } 276 | } 277 | } 278 | } 279 | _ => { 280 | continue; 281 | } 282 | } 283 | } 284 | } 285 | 286 | if ctxname.is_none() { 287 | panic!("Couldn't determine the Context parameter. Make sure you take a `Context` as an argument"); 288 | } 289 | 290 | let mut ih_n = quote!(_); 291 | 292 | if handlename.is_some() { 293 | ih_n = quote!(#handlename); 294 | } 295 | 296 | // Using quasi-quoting to generate a new function. This is what will be the end function returned to the compiler. 297 | if !defer { 298 | // Build the function 299 | let subst_fn = quote! { 300 | #vis fn #fname (#ih_n: &mut InteractionHandler, #ctxname: Context) -> ::std::pin::Pin<::std::boxed::Box + '_>>{ 301 | Box::pin(async move { 302 | #body 303 | }) 304 | } 305 | }; 306 | subst_fn.into() 307 | } 308 | // Deferring is requested, this will require a bit more manipulation. 309 | else { 310 | // Find the return statement and split the entire tokenstream there. 311 | let mut ind: Option = None; 312 | let mut expr: Option = None; 313 | for n in 0..body.stmts.len() { 314 | let s = &body.stmts[n]; 315 | match s { 316 | Stmt::Expr(Expr::Return(a)) => { 317 | expr = Some(a.clone()); 318 | ind = Some(n); 319 | break; 320 | } 321 | Stmt::Semi(Expr::Return(a), _) => { 322 | expr = Some(a.clone()); 323 | ind = Some(n); 324 | break; 325 | } 326 | _ => (), 327 | } 328 | } 329 | let (nbody, _reta) = body.stmts.split_at(ind.unwrap_or_else(|| { 330 | panic!( 331 | "Could not find return statement in slash-command. Explicit returns are required." 332 | ); 333 | })); 334 | 335 | // Unwrap, unwrap, unwrap, unwrap. 336 | let expra = expr 337 | .unwrap_or_else(|| panic!("Expected return")) 338 | .expr 339 | .unwrap_or_else(|| panic!("Expected some return value")); 340 | 341 | let nvec = nbody.to_vec(); 342 | 343 | // Now that we have all the information we need, we can finally start building our new function! 344 | // The difference here being that the non-deffered function doesn't have to spawn a new thread that 345 | // does the actual work. Here we need it to reply with a deffered channel message. 346 | let subst_fn = quote! { 347 | #vis fn #fname (#ih_n: &mut InteractionHandler, #ctxname: Context) -> ::std::pin::Pin<::std::boxed::Box + '_>>{ 348 | Box::pin(async move { 349 | actix::Arbiter::spawn(async move { 350 | #(#nvec)* 351 | if #expra.r#type != InteractionResponseType::Pong && #expra.r#type != InteractionResponseType::None{ 352 | if let Err(i) = #ctxname.edit_original(&WebhookMessage::from(#expra)).await{ 353 | error!("Editing original message failed: {:?}", i); 354 | } 355 | } 356 | 357 | }); 358 | 359 | return InteractionResponseBuilder::default().respond_type(InteractionResponseType::DefferedChannelMessageWithSource).finish(); 360 | 361 | }) 362 | } 363 | }; 364 | subst_fn.into() 365 | } 366 | } 367 | -------------------------------------------------------------------------------- /examples/Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "e1_basic_handler", 4 | "e2_tls_handler", 5 | "e3_deffering_commands", 6 | "e4_followups", 7 | "e5_manipulating_original_message", 8 | "e6_components", 9 | "e7_embeds", 10 | "e8_managing_commands", 11 | "e9_accessing_other_data" 12 | ] -------------------------------------------------------------------------------- /examples/e1_basic_handler/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "e1_basic_handler" 3 | version = "0.1.0" 4 | authors = ["Hugo Woesthuis "] 5 | edition = "2018" 6 | 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [dependencies] 11 | rusty_interaction = {path = "../../", features=["handler"]} 12 | actix-web = "3" 13 | dotenv = "0.13.0" 14 | env_logger = "0" 15 | -------------------------------------------------------------------------------- /examples/e1_basic_handler/README.md: -------------------------------------------------------------------------------- 1 | # Example 1: A basic handler 2 | This the most basic handler you can make with Rusty Interaction. 3 | 4 | If `/summon` was called, it will print `I HAVE BEEN SUMMONED!!!` on the console and reply with `I was summoned?`. 5 | 6 | ## Important design note 7 | Whatever you return is **the initial response**. It was chosen this way because Discord _always_ wants a response from you. This way, you're forced to 8 | give a response. 9 | 10 | However, this might be confusing at times. Especially if you're using followup messages and/or you're editing your original response. Be aware of this. 11 | 12 | # Running this example 13 | You can use regular `cargo build` and `cargo run` commands. 14 | 15 | To run this example: 16 | `cargo run`. Note that you'll need to edit the `PUB_KEY` constant accordingly (it will panic if you don't give a vaild key). 17 | 18 | # Security 19 | This example starts a plain HTTP server. Using plain HTTP these days is a **bad idea**. 20 | 21 | Look at example 2 for a HTTPS server implementation. 22 | 23 | # Docs to read 24 | - [InteractionHandler](https://docs.rs/rusty_interaction/latest/rusty_interaction/handler/struct.InteractionHandler.html) 25 | - [Context](https://docs.rs/rusty_interaction/0.1.0/rusty_interaction/types/interaction/struct.Context.html) -------------------------------------------------------------------------------- /examples/e1_basic_handler/src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate rusty_interaction; 3 | 4 | use rusty_interaction::handler::InteractionHandler; 5 | use rusty_interaction::types::interaction::*; 6 | 7 | // This key is needed for verifying incoming Interactions. This verification is mandatory. 8 | // You can find this key in the Discord Developer Portal. 9 | const PUB_KEY: &str = "YOUR_APP'S_PUBLIC_KEY"; 10 | // Fill with your application ID 11 | const APP_ID: u64 = 0; 12 | 13 | // This macro will transform the function to something the handler can use 14 | #[slash_command] 15 | // Function handlers should take an `Interaction` object and should return an `InteractionResponse` 16 | async fn test(ctx: Context) -> Result { 17 | println!("I HAVE BEEN SUMMONED!!!"); 18 | 19 | // Return a response by using the `Context.respond` function. 20 | // `Context.respond` returns an `InteractionResponseBuilder`. 21 | // You can now build a `InteractionResponse` by using it's functions. 22 | ctx.respond().message("I was summoned?").finish() 23 | } 24 | 25 | // The lib uses actix-web 26 | #[actix_web::main] 27 | async fn main() -> std::io::Result<()> { 28 | // Enable the logger 29 | env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); 30 | 31 | // Initalize our InteractionHandler 32 | // This will handle incoming interactions and route them to your own handlers 33 | let mut handle = InteractionHandler::new(APP_ID, PUB_KEY, None); 34 | 35 | // This will tell the handler to route the `/summon` command to the test function. So if someone uses `/summon`, test() will be called. 36 | // Please note that you'll need to register your commands to Discord if you haven't yet. This library only handles incoming Interactions (as of now), 37 | // not command management. 38 | handle.add_global_command("summon", test); 39 | 40 | // Run the API server! NOTE: the server runs at port 10080 (Socket binds to 0.0.0.0:10080) 41 | // This server starts a HTTP server. You MUST switch to HTTPS if you want to move to production. See example 2 for that 42 | handle.run(10080).await 43 | } 44 | -------------------------------------------------------------------------------- /examples/e2_tls_handler/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "e2_tls_handler" 3 | version = "0.1.0" 4 | authors = ["Hugo Woesthuis "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | rusty_interaction = {path = "../../", features=["handler"]} 11 | actix-web = "3" 12 | dotenv = "0.13.0" 13 | env_logger = "0" 14 | rustls = "0.18" 15 | -------------------------------------------------------------------------------- /examples/e2_tls_handler/README.md: -------------------------------------------------------------------------------- 1 | # Example 2: Another basic handler, but with TLS 2 | Practically the same as example 1, but with TLS integrated. 3 | 4 | 5 | # Running this example 6 | You can use regular `cargo build` and `cargo run` commands. 7 | 8 | To run this example: 9 | 10 | `cargo run`. 11 | 12 | Note that you'll need to edit the `PUB_KEY` constant accordingly (it will panic if you don't give a vaild key). 13 | You'll also need to supply a TLS certificate and it's corresponding private key (`cert.pem` and `key.pem` by default). 14 | 15 | # Docs to read 16 | - [actix tls example](https://github.com/actix/examples/tree/master/security/rustls) -------------------------------------------------------------------------------- /examples/e2_tls_handler/src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate rusty_interaction; 3 | 4 | use rusty_interaction::handler::InteractionHandler; 5 | use rusty_interaction::types::interaction::*; 6 | 7 | // Used for getting TLS to work 8 | use rustls::internal::pemfile::{certs, pkcs8_private_keys}; 9 | use rustls::{NoClientAuth, ServerConfig}; 10 | use std::fs::File; 11 | use std::io::BufReader; 12 | 13 | // This key is needed for verifying incoming Interactions. This verification is mandatory. 14 | // You can find this key in the Discord Developer Portal. 15 | const PUB_KEY: &str = "YOUR_APP'S_PUBLIC_KEY"; 16 | const APP_ID: u64 = 0; 17 | 18 | // This macro will transform the function to something the handler can use 19 | #[slash_command] 20 | // Function handlers should take an `Interaction` object and should return an `InteractionResponse` 21 | async fn test(ctx: Context) -> Result { 22 | println!("I HAVE BEEN SUMMONED!!!"); 23 | 24 | // Return a response by using the `Context.respond` function. 25 | // `Context.respond` returns an `InteractionResponseBuilder`. 26 | // You can now build a `InteractionResponse` by using it's functions. 27 | return ctx.respond().message("I was summoned?").finish(); 28 | } 29 | 30 | // The lib uses actix-web 31 | #[actix_web::main] 32 | async fn main() -> std::io::Result<()> { 33 | // Enable the logger 34 | env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); 35 | 36 | // Initalize our InteractionHandler 37 | // This will handle incoming interactions and route them to your own handlers 38 | let mut handle = InteractionHandler::new(APP_ID, PUB_KEY, None); 39 | 40 | // This will tell the handler to route the `/summon` command to the test function. So if someone uses `/summon`, test() will be called. 41 | // Please note that you'll need to register your commands to Discord if you haven't yet. This library only handles incoming Interactions (as of now), 42 | // not command management. 43 | handle.add_global_command("summon", test); 44 | 45 | // This is to setup TLS. 46 | let mut config = ServerConfig::new(NoClientAuth::new()); 47 | let cert_file = &mut BufReader::new(File::open("cert.pem").unwrap()); 48 | let key_file = &mut BufReader::new(File::open("key.pem").unwrap()); 49 | let cert_chain = certs(cert_file).unwrap(); 50 | let mut keys = pkcs8_private_keys(key_file).unwrap(); 51 | config.set_single_cert(cert_chain, keys.remove(0)).unwrap(); 52 | 53 | // Run the API. Note the use of run_ssl(config) instead of run() 54 | // The server runs on port 10443! 55 | return handle.run_ssl(config, 10443).await; 56 | } 57 | -------------------------------------------------------------------------------- /examples/e3_deffering_commands/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "e3_deffering_commands" 3 | version = "0.1.0" 4 | authors = ["Hugo Woesthuis "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | rusty_interaction = {path = "../../", features=["handler"]} 11 | actix-web = "3" 12 | dotenv = "0.13.0" 13 | env_logger = "0" 14 | 15 | log = "0" 16 | async-std = "1.9.0" 17 | -------------------------------------------------------------------------------- /examples/e3_deffering_commands/README.md: -------------------------------------------------------------------------------- 1 | # Example 3: Deffered commands 2 | Discord wants you to reply to a slash command in no more than three seconds. However, some tasks simply take longer to complete than three seconds. 3 | 4 | To make this possible, you can send a 'deffered command response'. If you do that, the user will be notified that the bot is processing their request. 5 | 6 | You'll still have to reply in _no more than 15 minutes_, or the interaction will expire. 7 | 8 | To indicate you want to reply with a deffered response first, simply put `#[defer]` under the `#[slash_command]` proc-macro! The way you respond to an interaction stays the same: the compiler transforms this into the correct form for you. Example: 9 | 10 | ```rust 11 | #[slash_command] 12 | #[defer] 13 | async fn some_handler(ctx: Context) -> InteractionResponse{ 14 | // ... 15 | 16 | return ctx.respond().content("Wowh! That was quite a task!").finish(); 17 | } 18 | ``` 19 | 20 | # Running this example 21 | You can use regular `cargo build` and `cargo run` commands. 22 | 23 | To run this example: 24 | 25 | `cargo run`. Note that you'll need to edit the `PUB_KEY` constant accordingly (it will panic if you don't give a vaild key). 26 | 27 | 28 | # Useful documentation 29 | - [defer Attribute Macro](https://docs.rs/rusty_interaction/latest/rusty_interaction/attr.defer.html) 30 | -------------------------------------------------------------------------------- /examples/e3_deffering_commands/src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate rusty_interaction; 3 | 4 | use rusty_interaction::handler::InteractionHandler; 5 | use rusty_interaction::types::interaction::*; 6 | 7 | use async_std::task; 8 | use std::time::Duration; 9 | 10 | const PUB_KEY: &str = "YOUR_APP'S_PUBLIC_KEY"; 11 | 12 | const APP_ID: u64 = 0; 13 | 14 | #[slash_command] 15 | // Sending a deffered response by adding the `#[defer]` attribute 16 | #[defer] 17 | async fn test(ctx: Context) -> Result { 18 | println!("I HAVE BEEN SUMMONED!!!"); 19 | 20 | // This is representing some work that needs to be done before a response can be made 21 | task::sleep(Duration::from_secs(5)).await; 22 | 23 | return ctx.respond().message("I was summoned?").finish(); 24 | } 25 | 26 | #[actix_web::main] 27 | async fn main() -> std::io::Result<()> { 28 | env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); 29 | 30 | let mut handle = InteractionHandler::new(APP_ID, PUB_KEY, None); 31 | 32 | handle.add_global_command("summon", test); 33 | 34 | return handle.run(10443).await; 35 | } 36 | -------------------------------------------------------------------------------- /examples/e4_followups/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "e4_followups" 3 | version = "0.1.0" 4 | authors = ["Hugo Woesthuis "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | rusty_interaction = {path = "../../", features=["handler"]} 11 | actix-web = "3" 12 | dotenv = "0.13.0" 13 | env_logger = "0" 14 | 15 | async-std = "1.9.0" 16 | -------------------------------------------------------------------------------- /examples/e4_followups/README.md: -------------------------------------------------------------------------------- 1 | # Example 4: Creating Follow-up messages 2 | 3 | Sometimes, responding with just one message isn't enough. For those situations we have 'follow-up' messages. 4 | 5 | These are webhook messages and are just like other messages (except you can add linked text too!). 6 | 7 | Just like InteractionResponses, you can edit and delete as you wish. 8 | 9 | # Running this example 10 | You can use regular `cargo build` and `cargo run` commands. 11 | 12 | To run this example: 13 | 14 | `cargo run`. Note that you'll need to edit the `PUB_KEY` constant accordingly (it will panic if you don't give a vaild key). 15 | 16 | # Useful documentation 17 | - [FollowupMessage](https://docs.rs/rusty_interaction/latest/rusty_interaction/types/interaction/struct.FollowupMessage.html) 18 | -------------------------------------------------------------------------------- /examples/e4_followups/src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate rusty_interaction; 3 | 4 | use rusty_interaction::handler::InteractionHandler; 5 | use rusty_interaction::types::interaction::*; 6 | 7 | use async_std::task; 8 | use std::time::Duration; 9 | 10 | const PUB_KEY: &str = "YOUR_PUBLIC_KEY"; 11 | const APP_ID: u64 = 0; 12 | 13 | #[slash_command] 14 | async fn test(ctx: Context) -> Result { 15 | // Send a followup message 16 | let fu = ctx 17 | .clone() 18 | .create_followup(&WebhookMessage::default().content("This is a follow up!")) 19 | .await; 20 | 21 | // Mind you: The return value is the INITIAL RESPONSE. What is returned here is sent directly to Discord 22 | return ctx.respond().message("I was summoned?").finish(); 23 | } 24 | 25 | // The lib uses actix-web 26 | #[actix_web::main] 27 | async fn main() -> std::io::Result<()> { 28 | env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); 29 | 30 | let mut handle = InteractionHandler::new(APP_ID, PUB_KEY, None); 31 | 32 | handle.add_global_command("summon", test); 33 | 34 | return handle.run(10443).await; 35 | } 36 | -------------------------------------------------------------------------------- /examples/e5_manipulating_original_message/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "e5_manipulating_original_message" 3 | version = "0.1.0" 4 | authors = ["Hugo Woesthuis "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | rusty_interaction = {path = "../../", features=["handler"]} 11 | actix-web = "3" 12 | dotenv = "0.13.0" 13 | env_logger = "0" 14 | 15 | async-std = "1.9.0" -------------------------------------------------------------------------------- /examples/e5_manipulating_original_message/README.md: -------------------------------------------------------------------------------- 1 | # Example 5: Manipulating original messages 2 | 3 | This basic example shows how to delete a message three seconds after it has been sent. 4 | 5 | This example spawns a new thread that waits for three seconds before deleting and then sends the message. 6 | 7 | # Running this example 8 | You can use regular `cargo build` and `cargo run` commands. 9 | 10 | To run this example: 11 | 12 | `cargo run`. Note that you'll need to edit the `PUB_KEY` constant accordingly (it will panic if you don't give a vaild key). 13 | -------------------------------------------------------------------------------- /examples/e5_manipulating_original_message/src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate rusty_interaction; 3 | 4 | use rusty_interaction::handler::InteractionHandler; 5 | use rusty_interaction::types::interaction::*; 6 | 7 | use async_std::task; 8 | use std::time::Duration; 9 | 10 | use rusty_interaction::actix::Arbiter; 11 | 12 | const PUB_KEY: &str = "YOUR_PUBLIC_KEY"; 13 | 14 | const APP_ID: u64 = 0; 15 | 16 | #[slash_command] 17 | async fn test(ctx: Context) -> Result { 18 | let m = ctx.clone(); 19 | // Spawn a new thread before sending a response. 20 | Arbiter::spawn(async move { 21 | // Wait three seconds and delete 22 | task::sleep(Duration::from_secs(3)).await; 23 | 24 | m.delete_original().await; 25 | }); 26 | 27 | return ctx.respond().message("I was summoned?").finish(); 28 | } 29 | 30 | // The lib uses actix-web 31 | #[actix_web::main] 32 | async fn main() -> std::io::Result<()> { 33 | env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); 34 | 35 | let mut handle = InteractionHandler::new(APP_ID, PUB_KEY, None); 36 | 37 | handle.add_global_command("summon", test); 38 | 39 | return handle.run(10043).await; 40 | } 41 | -------------------------------------------------------------------------------- /examples/e6_components/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "e6_components" 3 | version = "0.1.0" 4 | authors = ["Hugo Woesthuis "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | rusty_interaction = {path = "../../", features=["handler"]} 11 | actix-web = "3" 12 | dotenv = "0.13.0" 13 | env_logger = "0" 14 | 15 | async-std = "1.9.0" 16 | -------------------------------------------------------------------------------- /examples/e6_components/README.md: -------------------------------------------------------------------------------- 1 | # Example 6: Components 2 | 3 | Components are an exciting way of user interaction with your bot. 4 | 5 | With components, you can add rich elements (like buttons) to your messages! 6 | 7 | This is what this demo will produce: 8 | 9 | ![demo](https://raw.githubusercontent.com/0x2b00b1e5/rusty-interaction/main/examples/e6_components/img/demo.gif) 10 | 11 | 12 | 13 | # Running this example 14 | You can use regular `cargo build` and `cargo run` commands. 15 | 16 | To run this example: 17 | 18 | `cargo run`. Note that you'll need to edit the `PUB_KEY` constant accordingly (it will panic if you don't give a vaild key). 19 | 20 | # Useful documentation 21 | - [add_component_handler](https://docs.rs/rusty_interaction/latest/rusty_interaction/handler/struct.InteractionHandler.html#method.add_component_handle) 22 | - [component_handler proc macro](https://docs.rs/rusty_interaction/latest/rusty_interaction/attr.component_handler.html) 23 | - [types::components module](https://docs.rs/rusty_interaction/latest/rusty_interaction/types/components/index.html) 24 | -------------------------------------------------------------------------------- /examples/e6_components/img/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/showengineer/rusty-interaction/16ad7a1639ee1be5489862e631bf52493f8d10b4/examples/e6_components/img/demo.gif -------------------------------------------------------------------------------- /examples/e6_components/src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate rusty_interaction; 3 | 4 | use std::convert::Infallible; 5 | 6 | use rusty_interaction::handler::InteractionHandler; 7 | use rusty_interaction::types::interaction::*; 8 | // Import for using components 9 | use rusty_interaction::types::components::*; 10 | use rusty_interaction::Builder; 11 | 12 | use async_std::task; 13 | use std::time::Duration; 14 | 15 | use rusty_interaction::actix::Arbiter; 16 | 17 | const PUB_KEY: &str = "My Public Key"; 18 | const APP_ID: u64 = 0; 19 | 20 | // Use the component_handler macro. 21 | #[component_handler] 22 | async fn edit_button(ctx: Context) -> Result { 23 | return ctx.respond().message("HAHA").finish(); 24 | } 25 | 26 | // We defer in this instance, because we don't want to edit anything 27 | #[component_handler] 28 | #[defer] 29 | async fn delete_button(ctx: Context) -> Result { 30 | ctx.delete_original().await; 31 | 32 | // Since we've deleted the original message, it's safe to use respond().none() 33 | return ctx.respond().none(); 34 | } 35 | #[slash_command] 36 | async fn test(ctx: Context) -> Result { 37 | // Let's build our message! 38 | let resp = ctx 39 | .respond() 40 | // Set message content 41 | .content("Not edited") 42 | // add a component action row using it's builder 43 | // Example for adding buttons 44 | .add_component_row( 45 | ComponentRowBuilder::default() 46 | // Add buttons using it's builder 47 | .add_button( 48 | ComponentButtonBuilder::default() 49 | .label("Edit") 50 | .custom_id("EDIT_BUTTON_PRIMARY") 51 | .style(&ComponentButtonStyle::Primary) 52 | .build() 53 | .unwrap(), 54 | ) 55 | .add_button( 56 | ComponentButtonBuilder::default() 57 | .label("Delete") 58 | .custom_id("DELETE_BUTTON") 59 | .style(&ComponentButtonStyle::Danger) 60 | .build() 61 | .unwrap(), 62 | ) 63 | .build() 64 | .unwrap(), 65 | ) 66 | // Select menu example (interactions with it will fail) 67 | .add_component_row( 68 | ComponentRowBuilder::default() 69 | .add_select_menu( 70 | ComponentSelectMenuBuilder::default() 71 | .custom_id("TEST") 72 | .add_option( 73 | ComponentSelectOption::default() 74 | .label("Test 1") 75 | .value("Some Test idk") 76 | .description("What?") 77 | .set_default(true), 78 | ) 79 | .add_option( 80 | ComponentSelectOption::default() 81 | .label("Test 2") 82 | .value("Another test") 83 | .description("What?"), 84 | ) 85 | .build() 86 | .unwrap(), 87 | ) 88 | .build() 89 | .unwrap(), 90 | ) 91 | .finish(); 92 | 93 | return resp; 94 | } 95 | 96 | // The lib uses actix-web 97 | #[actix_web::main] 98 | async fn main() -> std::io::Result<()> { 99 | env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); 100 | 101 | let mut handle = InteractionHandler::new(APP_ID, PUB_KEY, None); 102 | 103 | handle.add_global_command("summon", test); 104 | 105 | // Here we attach our custom ids we defined with our buttons to the handler 106 | handle.add_component_handle("EDIT_BUTTON_PRIMARY", edit_button); 107 | handle.add_component_handle("DELETE_BUTTON", delete_button); 108 | 109 | return handle.run(10443).await; 110 | } 111 | -------------------------------------------------------------------------------- /examples/e7_embeds/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "e7_embeds" 3 | version = "0.1.0" 4 | authors = ["Hugo Woesthuis "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | rusty_interaction = {path = "../../", features=["handler"]} 11 | actix-web = "3" 12 | dotenv = "0.13.0" 13 | env_logger = "0" 14 | -------------------------------------------------------------------------------- /examples/e7_embeds/README.md: -------------------------------------------------------------------------------- 1 | # Example 7: Embeds 2 | 3 | Embeds are a way of displaying rich content in Discord messages. 4 | 5 | This is what this demo will produce: 6 | 7 | ![demo gif](https://user-images.githubusercontent.com/10338882/121349853-45b17800-c92a-11eb-80d6-d182b685c468.gif) 8 | 9 | 10 | 11 | # Running this example 12 | You can use regular `cargo build` and `cargo run` commands. 13 | 14 | To run this example: 15 | 16 | `cargo run`. Note that you'll need to edit the `PUB_KEY` constant accordingly (it will panic if you don't give a vaild key). 17 | 18 | # Useful documentation 19 | - [`types::embed` module](https://docs.rs/rusty_interaction/latest/rusty_interaction/types/embed/index.html) 20 | -------------------------------------------------------------------------------- /examples/e7_embeds/src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate rusty_interaction; 3 | 4 | use std::convert::Infallible; 5 | 6 | use rusty_interaction::handler::InteractionHandler; 7 | use rusty_interaction::types::components::*; 8 | use rusty_interaction::types::interaction::*; 9 | // Embed objects can be found here 10 | use rusty_interaction::types::embed::*; 11 | 12 | const PUB_KEY: &str = "YOUR_PUBLIC_KEY"; 13 | const APP_ID: u64 = 0; 14 | 15 | // Use the component_handler macro. 16 | #[component_handler] 17 | async fn edit_button(ctx: Context) -> Result { 18 | return ctx.respond().message("HAHA").finish(); 19 | } 20 | 21 | // We defer in this instance, because we don't want to edit anything 22 | #[component_handler] 23 | #[defer] 24 | async fn delete_button(ctx: Context) -> Result { 25 | if let Ok(_) = ctx.delete_original().await {} 26 | return ctx.respond().none(); 27 | } 28 | #[slash_command] 29 | async fn test(ctx: Context) -> Result { 30 | // You can use the EmbedBuilder to build embeds 31 | // ...you might have figured that out when looking at the name. 32 | let embed = EmbedBuilder::default() 33 | .title("My beautiful embed!") 34 | // I am using hex values here 35 | .color(0x00FF00A3 as u32) 36 | .add_field( 37 | EmbedField::default() 38 | .name("It's a bright day!") 39 | .value("Right?"), 40 | ) 41 | .footer(EmbedFooter::default().text("rusty-interaction")) 42 | .finish(); 43 | 44 | let components = ComponentRowBuilder::default() 45 | .add_button( 46 | ComponentButtonBuilder::default() 47 | .label("Delete") 48 | .custom_id("DELETE") 49 | .style(&ComponentButtonStyle::Danger) 50 | .finish(), 51 | ) 52 | .finish(); 53 | 54 | // Let's build our message! 55 | let resp = ctx 56 | .respond() 57 | // Set message content 58 | .content("Not edited") 59 | .add_component_row(components) 60 | // Add the embed. You can add a maximum of 10 embeds 61 | .add_embed(&embed) 62 | .finish(); 63 | 64 | return resp; 65 | } 66 | 67 | // The lib uses actix-web 68 | #[actix_web::main] 69 | async fn main() -> std::io::Result<()> { 70 | env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); 71 | 72 | let mut handle = InteractionHandler::new(APP_ID, PUB_KEY, None); 73 | 74 | handle.add_global_command("summon", test); 75 | 76 | // Here we attach our custom ids we defined with our buttons to the handler 77 | handle.add_component_handle("DELETE", delete_button); 78 | 79 | return handle.run(10443).await; 80 | } 81 | -------------------------------------------------------------------------------- /examples/e8_managing_commands/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "e8_managing_commands" 3 | version = "0.1.0" 4 | authors = ["Hugo Woesthuis "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | rusty_interaction = {path = "../../", features=["extended-handler"]} 11 | actix-web = "3" 12 | dotenv = "0.13.0" 13 | env_logger = "0" -------------------------------------------------------------------------------- /examples/e8_managing_commands/README.md: -------------------------------------------------------------------------------- 1 | # Example 8: Managing commands 2 | 3 | From version 0.1.7, you can access the `InteractionHandler` that you can use to, for example, create or delete guild-specific commands. 4 | 5 | When using this demo, `/summon` will create a `/generated` command. If you use `/generated`, it will delete/unregister itself. 6 | 7 | Note: These features are included in the `extended-handler` feature! 8 | 9 | # Important design note 10 | The handler does not 'remember' what guild-specific commands are registered and to which function they were attached. 11 | 12 | This means that every time you have to terminate the application, the handler 'forgets' what function belonged to which command. 13 | 14 | # Running this example 15 | You can use regular `cargo build` and `cargo run` commands. 16 | 17 | To run this example: 18 | 19 | `cargo run`. Note that you'll need to edit the `PUB_KEY`, `APP_ID` and `TOKEN` constants accordingly (it will panic if you don't give a vaild key). 20 | 21 | # Useful documentation 22 | - [InteractionHandler](https://docs.rs/rusty_interaction/latest/rusty_interaction/handler/struct.InteractionHandler.html) 23 | - [`types::application` module](https://docs.rs/rusty_interaction/latest/rusty_interaction/types/application/index.html) 24 | - [ManipulationScope](https://docs.rs/rusty_interaction/latest/rusty_interaction/handler/enum.ManipulationScope.html) 25 | -------------------------------------------------------------------------------- /examples/e8_managing_commands/src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate rusty_interaction; 3 | 4 | use std::convert::Infallible; 5 | 6 | use rusty_interaction::handler::{InteractionHandler, ManipulationScope}; 7 | use rusty_interaction::types::interaction::*; 8 | // Relevant imports here 9 | use rusty_interaction::types::application::{ 10 | ApplicationCommandOption, ApplicationCommandOptionType, SlashCommandDefinitionBuilder, 11 | }; 12 | use rusty_interaction::Builder; 13 | 14 | const PUB_KEY: &str = "MY PUB KEY"; 15 | const TOKEN: &str = "MY TOKEN"; 16 | const APP_ID: u64 = 00000000000000000; 17 | 18 | #[slash_command] 19 | async fn delete_self( 20 | handler: &mut InteractionHandler, 21 | ctx: Context, 22 | ) -> Result { 23 | let sec_ctx = ctx.clone(); 24 | if let Some(g) = sec_ctx.interaction.guild_id { 25 | if let Some(data) = sec_ctx.interaction.data { 26 | let cid = data.id; 27 | 28 | // Using this to remove the guild command 29 | let r = handler 30 | .deregister_guild_handle(g, cid.unwrap(), &ManipulationScope::All) 31 | .await; 32 | if r.is_ok() { 33 | return ctx.respond().content("`/generated` deleted!").finish(); 34 | } else { 35 | return ctx.respond().content("Something went wrong!").finish(); 36 | } 37 | } 38 | return ctx.respond().content("Something went wrong!").finish(); 39 | } else { 40 | return ctx 41 | .respond() 42 | .content("This command should be invoked in a guild!") 43 | .finish(); 44 | } 45 | } 46 | 47 | #[slash_command] 48 | async fn test( 49 | handler: &mut InteractionHandler, 50 | ctx: Context, 51 | ) -> Result { 52 | if let Some(i) = ctx.interaction.guild_id { 53 | // Build a simple command 54 | let cmd = SlashCommandDefinitionBuilder::default() 55 | .name("generated") 56 | .description("This is a generated guild command!") 57 | .add_option( 58 | ApplicationCommandOption::default() 59 | .option_type(&ApplicationCommandOptionType::String) 60 | .name("string") 61 | .description("I will do absolutely nothing with this"), 62 | ) 63 | .build() 64 | .unwrap(); 65 | 66 | match handler 67 | .register_guild_handle(i, &cmd, delete_self, &ManipulationScope::All) 68 | .await 69 | { 70 | Ok(_) => { 71 | return ctx 72 | .respond() 73 | .content("`/generated` has been registered!") 74 | .finish(); 75 | } 76 | Err(e) => { 77 | return ctx 78 | .respond() 79 | .content(format!("Error ({}): \n```json\n{:?}```", e.code, e.message)) 80 | .finish(); 81 | } 82 | } 83 | } else { 84 | return ctx.respond().content("Not in a guild!").finish(); 85 | } 86 | } 87 | 88 | // The lib uses actix-web 89 | #[actix_web::main] 90 | async fn main() -> std::io::Result<()> { 91 | env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); 92 | 93 | // Note the use of the TOKEN. You'll also need to specify your application ID 94 | let mut handle = InteractionHandler::new(APP_ID, PUB_KEY, Some(&TOKEN.to_string())); 95 | 96 | handle.add_global_command("summon", test); 97 | 98 | return handle.run(10443).await; 99 | } 100 | -------------------------------------------------------------------------------- /examples/e9_accessing_other_data/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "e9_accessing_other_data" 3 | version = "0.1.0" 4 | authors = ["Hugo Woesthuis "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | rusty_interaction = {path = "../../", features=["extended-handler"]} 11 | actix-web = "3" 12 | dotenv = "0.13.0" 13 | env_logger = "0" -------------------------------------------------------------------------------- /examples/e9_accessing_other_data/README.md: -------------------------------------------------------------------------------- 1 | # Example 9: Accessing other data 2 | 3 | From version 0.2.0, the `InteractionHandler` has a field called `data`. This is used to access other data, like database connections for example. 4 | 5 | You can add data to the handler using `InteractionHandler::add_data()`. The backbone is an `AnyMap` and shares the same syntax with accessing data. 6 | 7 | 8 | # Result 9 | ![Peek 2021-07-29 21-53](https://user-images.githubusercontent.com/10338882/127557511-724e139a-4a5c-44cf-b403-6d270bbd8953.gif) 10 | 11 | 12 | # Running this example 13 | You can use regular `cargo build` and `cargo run` commands. 14 | 15 | To run this example: 16 | 17 | `cargo run`. Note that you'll need to edit the `PUB_KEY`, `APP_ID` and `TOKEN` constants accordingly (it will panic if you don't give a vaild key). 18 | 19 | # Useful documentation 20 | - [InteractionHandler](https://docs.rs/rusty_interaction/latest/rusty_interaction/handler/struct.InteractionHandler.html) 21 | -------------------------------------------------------------------------------- /examples/e9_accessing_other_data/src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate rusty_interaction; 3 | 4 | use rusty_interaction::handler::{InteractionHandler, ManipulationScope}; 5 | use rusty_interaction::types::interaction::*; 6 | // Relevant imports here 7 | use rusty_interaction::types::application::{ 8 | ApplicationCommandOption, ApplicationCommandOptionType, SlashCommandDefinitionBuilder, 9 | }; 10 | use rusty_interaction::Builder; 11 | 12 | const PUB_KEY: &str = "YOUR PUB KEY"; 13 | const APP_ID: u64 = 000000000000000000; 14 | 15 | // Must implement Clone 16 | #[derive(Clone)] 17 | struct MyStruct { 18 | pub foo: u16, 19 | } 20 | 21 | #[slash_command] 22 | async fn test( 23 | handler: &mut InteractionHandler, 24 | ctx: Context, 25 | ) -> Result { 26 | // Get a mutable reference to MyStruct 27 | let my_struct = handler.data.get_mut::().unwrap(); 28 | 29 | my_struct.foo += 1; 30 | 31 | return ctx 32 | .respond() 33 | .content(format!("Foo is {}", my_struct.foo)) 34 | .finish(); 35 | } 36 | 37 | // The lib uses actix-web 38 | #[actix_web::main] 39 | async fn main() -> std::io::Result<()> { 40 | let my_struct = MyStruct { foo: 0 }; 41 | 42 | env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); 43 | 44 | let mut handle = InteractionHandler::new(APP_ID, PUB_KEY, None); 45 | 46 | handle.add_global_command("summon", test); 47 | 48 | // Add my_struct to the Data map 49 | handle.add_data(my_struct); 50 | 51 | return handle.run(10080).await; 52 | } 53 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2018" -------------------------------------------------------------------------------- /src/handler.rs: -------------------------------------------------------------------------------- 1 | use crate::security::*; 2 | 3 | #[cfg(feature = "extended-handler")] 4 | use crate::types::application::*; 5 | 6 | #[cfg(feature = "handler")] 7 | use crate::types::interaction::*; 8 | 9 | #[cfg(feature = "extended-handler")] 10 | use crate::types::HttpError; 11 | use crate::types::Snowflake; 12 | #[cfg(feature = "extended-handler")] 13 | use crate::{ 14 | expect_specific_api_response, expect_successful_api_response, 15 | expect_successful_api_response_and_return, 16 | }; 17 | use actix_web::http::StatusCode; 18 | use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer, Result}; 19 | use reqwest::header; 20 | use reqwest::Client; 21 | 22 | use log::{debug, error}; 23 | use std::fmt; 24 | 25 | use anymap::{CloneAny, Map}; 26 | 27 | use ed25519_dalek::{PUBLIC_KEY_LENGTH, VerifyingKey}; 28 | 29 | use std::{collections::HashMap, future::Future, pin::Pin, sync::Mutex}; 30 | use std::convert::TryInto; 31 | use hex::FromHex; 32 | use rustls::ServerConfig; 33 | 34 | type AnyMap = Map; 35 | 36 | type HandlerFunction = fn( 37 | &mut InteractionHandler, 38 | Context, 39 | ) -> Pin + Send + '_>>; 40 | 41 | macro_rules! match_handler_response { 42 | ($response:ident) => { 43 | 44 | match $response.r#type { 45 | InteractionResponseType::None => { 46 | Ok(HttpResponse::build(StatusCode::NO_CONTENT).finish()) 47 | } 48 | InteractionResponseType::DefferedChannelMessageWithSource 49 | | InteractionResponseType::DefferedUpdateMessage => { 50 | /* The use of HTTP code 202 is more appropriate when an Interaction is deffered. 51 | If an application is first sending a deffered channel message response, this usually means the system 52 | is still processing whatever it is doing. 53 | See the spec: https://tools.ietf.org/html/rfc7231#section-6.3.3 */ 54 | Ok(HttpResponse::build(StatusCode::ACCEPTED).json($response)) 55 | } 56 | _ => { 57 | // Send out a response to Discord 58 | let r = HttpResponse::build(StatusCode::OK).json($response); 59 | 60 | Ok(r) 61 | } 62 | } 63 | 64 | }; 65 | } 66 | 67 | #[cfg(feature = "handler")] 68 | #[non_exhaustive] 69 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 70 | /// Used for some functions to define which scope should be manipulated. 71 | pub enum ManipulationScope { 72 | /// Only apply changes locally 73 | Local, 74 | /// Apply changes locally and to Discord 75 | All, 76 | /// Only apply changes with Discord 77 | Discord, 78 | } 79 | 80 | #[cfg(feature = "handler")] 81 | #[derive(Clone)] 82 | /// The InteractionHandler is the 'thing' that will handle your incoming interactions. 83 | /// It does interaction validation (as required by Discord) and provides a pre-defined actix-web server 84 | /// with [`InteractionHandler::run`] and [`InteractionHandler::run_ssl`] 85 | pub struct InteractionHandler { 86 | application_id: Snowflake, 87 | 88 | app_public_key: VerifyingKey, 89 | client: Client, 90 | 91 | global_handles: HashMap<&'static str, HandlerFunction>, 92 | component_handles: HashMap<&'static str, HandlerFunction>, 93 | 94 | // These handles are 'forgotten' every time the app is shutdown (whatever the reason may be). 95 | guild_handles: HashMap, 96 | 97 | /// Field to access data 98 | pub data: AnyMap, 99 | } 100 | 101 | #[cfg(feature = "handler")] 102 | // Only here to make Debug less generic, so I can send a reference 103 | impl fmt::Debug for InteractionHandler { 104 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 105 | return f 106 | .debug_struct("InteractionHandler") 107 | .field("app_public_key", &self.app_public_key) 108 | .field("global_handles_len", &self.global_handles.len()) 109 | .field("component_handles_len", &self.component_handles.len()) 110 | .finish(); 111 | } 112 | } 113 | 114 | #[cfg(feature = "handler")] 115 | impl InteractionHandler { 116 | /// Initalizes a new `InteractionHandler` 117 | pub fn new( 118 | app_id: Snowflake, 119 | pbk_str: impl AsRef, 120 | token: Option<&String>, 121 | ) -> InteractionHandler { 122 | let bytes: Vec = FromHex::from_hex(pbk_str.as_ref()) 123 | .expect("Failed to parse the public key from hexadecimal"); 124 | let pbk_bytes: &[u8; PUBLIC_KEY_LENGTH] = &bytes[..PUBLIC_KEY_LENGTH] 125 | .try_into() 126 | .expect("Failed to convert public key to bytes"); 127 | 128 | let app_public_key = 129 | VerifyingKey::from_bytes(pbk_bytes).expect("Failed to parse public key."); 130 | 131 | if let Some(token) = token { 132 | let mut headers = header::HeaderMap::new(); 133 | 134 | // Let it panic if there is no valid value 135 | let auth_value = header::HeaderValue::from_str(token.as_str()); 136 | 137 | assert!(!auth_value.is_err(), "Invalid value given at token"); 138 | 139 | let mut auth_value = auth_value.unwrap(); 140 | 141 | auth_value.set_sensitive(true); 142 | headers.insert(header::AUTHORIZATION, auth_value); 143 | let new_c = Client::builder().default_headers(headers).build().unwrap(); 144 | 145 | InteractionHandler { 146 | application_id: app_id, 147 | app_public_key, 148 | client: new_c, 149 | global_handles: HashMap::new(), 150 | component_handles: HashMap::new(), 151 | guild_handles: HashMap::new(), 152 | data: AnyMap::new(), 153 | } 154 | } else { 155 | InteractionHandler { 156 | application_id: app_id, 157 | app_public_key, 158 | client: Client::new(), 159 | global_handles: HashMap::new(), 160 | component_handles: HashMap::new(), 161 | guild_handles: HashMap::new(), 162 | data: AnyMap::new(), 163 | } 164 | } 165 | } 166 | 167 | /// Add some data. Data can be accessed by handlers with `InteractionHandler.data` 168 | pub fn add_data(&mut self, data: T) 169 | where 170 | T: Send + 'static + Sync, 171 | { 172 | self.data.insert(data); 173 | } 174 | /// Binds an async function to a **global** command. 175 | /// Your function must take a [`Context`] as an argument and must return a [`InteractionResponse`]. 176 | /// Make sure to use the `#[slash_command]` procedural macro to make it usable for the handler. 177 | /// 178 | /// Like: 179 | /// ```ignore 180 | /// # use rusty_interaction::types::interaction::{Context, InteractionResponse}; 181 | /// # use attributes::slash_command; 182 | /// #[slash_command] 183 | /// async fn do_work(ctx: Context) -> InteractionResponse { 184 | /// return todo!("Do work and return a response"); 185 | /// } 186 | /// ``` 187 | /// 188 | /// # Note 189 | /// The handler will first check if a guild-specific handler is available. If not, it will try to match a global command. If that fails too, an error will be returned. 190 | /// 191 | /// # Example 192 | /// ```ignore 193 | /// # use rusty_interaction::types::interaction::{Context, InteractionResponse}; 194 | /// # use rusty_interaction::handler::InteractionHandler; 195 | /// # use attributes::slash_command; 196 | /// const PUB_KEY: &str = "my_public_key"; 197 | /// 198 | /// #[slash_command] 199 | /// async fn pong_handler(ctx: Context) -> InteractionResponse { 200 | /// return ctx.respond() 201 | /// .content("Pong!") 202 | /// .build() 203 | /// .unwrap(); 204 | /// } 205 | /// 206 | /// #[actix_web::main] 207 | /// async fn main() -> std::io::Result<()> { 208 | /// 209 | /// let mut handle = InteractionHandler::new(PUB_KEY); 210 | /// handle.add_command("ping", pong_handler); 211 | /// 212 | /// return handle.run().await; 213 | /// } 214 | /// ``` 215 | pub fn add_global_command(&mut self, name: &'static str, func: HandlerFunction) { 216 | self.global_handles.insert(name, func); 217 | } 218 | 219 | /// Binds an async function to a **component**. 220 | /// Your function must take a [`Context`] as an argument and must return a [`InteractionResponse`]. 221 | /// Use the `#[component_handler]` procedural macro for your own convinence.eprintln! 222 | /// 223 | /// # Example 224 | /// ```ignore 225 | /// use rusty_interaction::handler::InteractionHandler; 226 | /// use rusty_interaction::types::components::*; 227 | /// use rusty_interaction::types::interaction::*; 228 | /// 229 | /// #[component_handler] 230 | /// async fn comp_hand(ctx: Context) -> InteractionResponse { 231 | /// return ctx.respond().content("Some message content").build(); 232 | /// } 233 | /// 234 | /// #[slash_command] 235 | /// async fn spawn_buttons(ctx: Context) -> InteractionResponse { 236 | /// // Let's build our message! 237 | /// let resp = ctx.respond() 238 | /// // Set message content 239 | /// .content("Not edited") 240 | /// // add a component action row using it's builder 241 | /// .add_component_row( 242 | /// ComponentRowBuilder::default() 243 | /// // Add buttons using it's builder 244 | /// .add_button( 245 | /// ComponentButtonBuilder::default() 246 | /// .label("Edit") 247 | /// .custom_id("HEHE") 248 | /// .style(ComponentButtonStyle::Primary) 249 | /// .build() 250 | /// .unwrap() 251 | /// ) 252 | /// .add_button( 253 | /// ComponentButtonBuilder::default() 254 | /// .label("Delete") 255 | /// .custom_id("DELETE") 256 | /// .style(ComponentButtonStyle::Danger) 257 | /// .build() 258 | /// .unwrap() 259 | /// ) 260 | /// .build() 261 | /// .unwrap() 262 | /// ) 263 | /// .build() 264 | /// .unwrap(); 265 | 266 | /// return resp; 267 | /// 268 | /// } 269 | /// #[actix_web::main] 270 | /// async fn main() -> std::io::Result<()> { 271 | /// 272 | /// let mut handle = InteractionHandler::new(PUB_KEY); 273 | /// handle.add_command("ping", pong_handler); 274 | /// handle.add_component_handle("HEHE", comp_hand); 275 | /// return handle.run().await; 276 | /// } 277 | /// ``` 278 | pub fn add_component_handle(&mut self, custom_id: &'static str, func: HandlerFunction) { 279 | self.component_handles.insert(custom_id, func); 280 | } 281 | 282 | pub fn client(&self) -> &Client { 283 | &self.client 284 | } 285 | 286 | #[cfg(feature = "extended-handler")] 287 | #[cfg_attr(docsrs, doc(cfg(feature = "extended-handler")))] 288 | /// Register a guild-specific command with Discord! 289 | /// 290 | /// # NOTE 291 | /// Guild-specific commands are not cached or saved in any way by the handler. 292 | /// This means that between restarts, updates, crashes, or whatever that causes the app to terminate, the handler 'forgets' which command belonged to which handler. 293 | pub async fn register_guild_handle( 294 | &mut self, 295 | guild: impl Into, 296 | cmd: &ApplicationCommand, 297 | func: HandlerFunction, 298 | scope: &ManipulationScope, 299 | ) -> Result { 300 | let g = guild.into(); 301 | match scope { 302 | ManipulationScope::Local => { 303 | self.guild_handles.insert(g, func); 304 | Ok(cmd.clone()) 305 | } 306 | ManipulationScope::Discord | ManipulationScope::All => { 307 | let url = format!( 308 | "{}/applications/{}/guilds/{}/commands", 309 | crate::BASE_URL, 310 | self.application_id, 311 | g 312 | ); 313 | 314 | let r = self.client.post(&url).json(cmd).send().await; 315 | 316 | expect_successful_api_response_and_return!(r, ApplicationCommand, a, { 317 | if let Some(id) = a.id { 318 | if scope == &ManipulationScope::All { 319 | // Already overwrites current key if it exists, so no need to check. 320 | self.guild_handles.insert(id, func); 321 | } 322 | 323 | Ok(a) 324 | } else { 325 | // Pretty bad if this code reaches... 326 | Err(HttpError { 327 | code: 0, 328 | message: "Command registration response did not have an ID." 329 | .to_string(), 330 | }) 331 | } 332 | }) 333 | } 334 | } 335 | } 336 | 337 | #[cfg(feature = "extended-handler")] 338 | #[cfg_attr(docsrs, doc(cfg(feature = "extended-handler")))] 339 | /// Remove a guild handle 340 | pub async fn deregister_guild_handle( 341 | &mut self, 342 | guild: impl Into, 343 | id: impl Into, 344 | scope: &ManipulationScope, 345 | ) -> Result<(), HttpError> { 346 | let i = id.into(); 347 | let g = guild.into(); 348 | 349 | match scope { 350 | ManipulationScope::Local => { 351 | self.guild_handles.remove(&i); 352 | Ok(()) 353 | } 354 | ManipulationScope::All | ManipulationScope::Discord => { 355 | let url = format!( 356 | "{}/applications/{}/guilds/{}/commands/{}", 357 | crate::BASE_URL, 358 | self.application_id, 359 | g, 360 | i 361 | ); 362 | 363 | let r = self.client.delete(&url).send().await; 364 | 365 | expect_specific_api_response!(r, StatusCode::NO_CONTENT, { 366 | if scope == &ManipulationScope::All { 367 | self.guild_handles.remove(&i); 368 | } 369 | 370 | Ok(()) 371 | }) 372 | } 373 | } 374 | } 375 | 376 | #[cfg(feature = "extended-handler")] 377 | #[cfg_attr(docsrs, doc(cfg(feature = "extended-handler")))] 378 | /// Override a bunch of permissions for commands in a guild. 379 | pub async fn override_guild_permissions( 380 | &self, 381 | guild_id: impl Into, 382 | overrides: &[ApplicationCommandPermissionBatch], 383 | ) -> Result<(), HttpError> { 384 | let url = format!( 385 | "{}/applications/{}/guilds/{}/commands/permissions", 386 | crate::BASE_URL, 387 | self.application_id, 388 | guild_id.into() 389 | ); 390 | 391 | let res = self.client.put(&url).json(overrides).send().await; 392 | 393 | expect_successful_api_response!(res, Ok(())) 394 | } 395 | 396 | #[cfg(feature = "extended-handler")] 397 | #[cfg_attr(docsrs, doc(cfg(feature = "extended-handler")))] 398 | /// Add a permission override for a guild command 399 | pub async fn edit_guild_command_permissions( 400 | &self, 401 | guild_id: impl Into, 402 | appcmd_id: impl Into, 403 | permission_override: &ApplicationCommandPermission, 404 | ) -> Result<(), HttpError> { 405 | let url = format!( 406 | "{}/applications/{}/guilds/{}/commands/{}/permissions", 407 | crate::BASE_URL, 408 | self.application_id, 409 | guild_id.into(), 410 | appcmd_id.into(), 411 | ); 412 | 413 | let res = self.client.put(&url).json(permission_override).send().await; 414 | 415 | expect_successful_api_response!(res, Ok(())) 416 | } 417 | 418 | /// Entry point function for handling `Interactions` 419 | pub async fn interaction(&mut self, req: HttpRequest, body: String) -> Result { 420 | // Check for good content type --> must be application/json 421 | 422 | if let Some(ct) = req.headers().get("Content-Type") { 423 | if ct != "application/json" { 424 | debug!( 425 | "Incoming interaction rejected, bad Content-Type specified. Origin: {:?}", 426 | req.connection_info().realip_remote_addr() 427 | ); 428 | return ERROR_RESPONSE!(400, "Bad Content-Type"); 429 | } 430 | } else { 431 | debug!( 432 | "Incoming interaction rejected, no Content-Type specified. Origin: {:?}", 433 | req.connection_info().realip_remote_addr() 434 | ); 435 | return ERROR_RESPONSE!(400, "Bad Content-Type"); 436 | } 437 | 438 | let se = get_header(&req, "X-Signature-Ed25519"); 439 | let st = get_header(&req, "X-Signature-Timestamp"); 440 | 441 | // TODO: Domain check might be a good one. 442 | 443 | if let Some((se, st)) = se.zip(st) { 444 | // Verify timestamp + body against given signature 445 | if !verify_discord_message(self.app_public_key, se, st, &body).is_ok() { 446 | // Verification failed, reject. 447 | // TODO: Switch error response 448 | debug!( 449 | "Incoming interaction rejected, invalid signature. Origin: {:?}", 450 | req.connection_info().realip_remote_addr() 451 | ); 452 | return ERROR_RESPONSE!(401, "Invalid request signature"); 453 | } 454 | } else { 455 | // If proper headers are not present reject. 456 | debug!( 457 | "Incoming interaction rejected, missing headers. Origin: {:?}", 458 | req.connection_info().realip_remote_addr() 459 | ); 460 | return ERROR_RESPONSE!(400, "Bad signature data"); 461 | } 462 | 463 | // Security checks passed, try deserializing request to Interaction. 464 | match serde_json::from_str::(&body) { 465 | Err(e) => { 466 | // It's probably bad on our end if this code is reached. 467 | error!("Failed to decode interaction! Error: {}", e); 468 | debug!("Body sent: {}", body); 469 | return ERROR_RESPONSE!(400, format!("Bad body: {}", e)); 470 | } 471 | Ok(interaction) => { 472 | match interaction.r#type { 473 | InteractionType::Ping => { 474 | let response = 475 | InteractionResponse::new(InteractionResponseType::Pong, None); 476 | debug!("Got a ping, responding with pong."); 477 | return Ok(HttpResponse::build(StatusCode::OK) 478 | .content_type("application/json") 479 | .json(response)); 480 | } 481 | 482 | InteractionType::ApplicationCommand => { 483 | let data = if let Some(ref data) = interaction.data { 484 | data 485 | } else { 486 | error!("Failed to unwrap Interaction!"); 487 | return ERROR_RESPONSE!(500, "Failed to unwrap"); 488 | }; 489 | 490 | // Check for matches in guild handler map. Unwrapping because this should always contain an ID 491 | if let Some(handler) = self.guild_handles.get(data.id.as_ref().unwrap()) { 492 | // construct a Context 493 | let ctx = Context::new(self.client.clone(), interaction); 494 | 495 | // Call the handler 496 | let response = handler(self, ctx).await; 497 | 498 | match_handler_response!(response) 499 | } 500 | // Welp, nothing found. Check for matches in the global map 501 | else if let Some(handler) = self.global_handles.get( 502 | data.name.as_ref().unwrap().as_str(), /* Don't question it */ 503 | ) { 504 | // construct a Context 505 | let ctx = Context::new(self.client.clone(), interaction); 506 | 507 | // Call the handler 508 | let response = handler(self, ctx).await; 509 | 510 | match_handler_response!(response) 511 | } 512 | // Still nothing, return an error 513 | else { 514 | error!( 515 | "No associated handler found for {}", 516 | data.name.as_ref().unwrap().as_str() 517 | ); 518 | ERROR_RESPONSE!(501, "No associated handler found") 519 | } 520 | } 521 | InteractionType::MessageComponent => { 522 | let data = if let Some(ref data) = interaction.data { 523 | data 524 | } else { 525 | error!("Failed to unwrap Interaction!"); 526 | return ERROR_RESPONSE!(500, "Failed to unwrap"); 527 | }; 528 | 529 | if let Some(handler) = self 530 | .component_handles 531 | .get(data.custom_id.as_ref().unwrap().as_str()) 532 | { 533 | // construct a Context 534 | let ctx = Context::new(self.client.clone(), interaction); 535 | 536 | // Call the handler 537 | let response = handler(self, ctx).await; 538 | 539 | match_handler_response!(response) 540 | } else { 541 | error!( 542 | "No associated handler found for {}", 543 | data.custom_id.as_ref().unwrap().as_str() 544 | ); 545 | ERROR_RESPONSE!(501, "No associated handler found") 546 | } 547 | } 548 | } 549 | } 550 | } 551 | } 552 | 553 | /// This is a predefined function that starts an `actix_web::HttpServer` and binds `self.interaction` to `/api/discord/interactions`. 554 | /// Note that you'll eventually have to switch to an HTTPS server. This function does not provide this. 555 | pub async fn run(self, port: u16) -> std::io::Result<()> { 556 | let data = web::Data::new(Mutex::new(self)); 557 | HttpServer::new(move || { 558 | App::new().app_data(data.clone()).route( 559 | "/api/discord/interactions", 560 | web::post().to( 561 | |data: web::Data>, req: HttpRequest, body: String| async move { 562 | data.lock().unwrap().interaction(req, body).await 563 | }, 564 | ), 565 | ) 566 | }) 567 | .bind(format!("0.0.0.0:{}", port))? 568 | .run() 569 | .await 570 | } 571 | 572 | /// Same as [`InteractionHandler::run`] but starts a server with SSL/TLS. 573 | pub async fn run_ssl(self, server_conf: ServerConfig, port: u16) -> std::io::Result<()> { 574 | let data = web::Data::new(Mutex::new(self)); 575 | HttpServer::new(move || { 576 | App::new().app_data(data.clone()).route( 577 | "/api/discord/interactions", 578 | web::post().to( 579 | |data: web::Data>, req: HttpRequest, body: String| async move { 580 | data.lock().unwrap().interaction(req, body).await 581 | }, 582 | ), 583 | ) 584 | }) 585 | .bind_rustls(format!("0.0.0.0:{}", port), server_conf)? 586 | .run() 587 | .await 588 | } 589 | } 590 | 591 | /// Simpler header getter from a HTTP request 592 | fn get_header<'a>(req: &'a HttpRequest, header: &str) -> Option<&'a str> { 593 | req.headers().get(header)?.to_str().ok() 594 | } 595 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn(missing_docs)] 2 | //! Rusty-interaction is a library that allows you to work with Discord's new [Interactions](https://blog.discord.com/slash-commands-are-here-8db0a385d9e6). 3 | //! It can expose types and provides helper functions to validate your Interactions. 4 | //! It can optionally provide a handler that allows you to receive interactions via outgoing webhook. 5 | //! 6 | //! Note that Discord also has [official documentation](https://discord.com/developers/docs/intro). 7 | //! 8 | //! ## Examples 9 | //! See the [`examples`](https://github.com/hugopilot/rusty-interaction/tree/main/examples) directory. 10 | 11 | #[macro_use] 12 | mod macros; 13 | 14 | #[allow(dead_code)] 15 | pub const BASE_URL: &str = "https://discord.com/api/v10"; 16 | 17 | #[cfg(feature = "types")] 18 | /// Exposes useful data models 19 | pub mod types; 20 | 21 | /// Provides a helper function to validate Discord interactions. 22 | #[cfg(feature = "security")] 23 | pub mod security; 24 | 25 | /// Provides an entire handler to handle Discord interactions. 26 | #[cfg(any(feature = "handler", feature = "extended-handler"))] 27 | #[cfg_attr(docsrs, doc(cfg(feature = "handler")))] 28 | pub mod handler; 29 | #[cfg(any(feature = "handler", feature = "extended-handler"))] 30 | #[cfg_attr(docsrs, doc(cfg(feature = "handler")))] 31 | pub use actix; 32 | 33 | #[cfg(any(feature = "handler", feature = "extended-handler"))] 34 | #[cfg_attr(docsrs, doc(cfg(feature = "handler")))] 35 | pub use log; 36 | 37 | #[cfg(any(feature = "handler", feature = "extended-handler"))] 38 | #[cfg_attr(docsrs, doc(cfg(feature = "handler")))] 39 | pub use attributes::*; 40 | 41 | #[cfg(all(test, feature = "security"))] 42 | mod tests; 43 | 44 | /// A trait for defining builder patterns. 45 | pub trait Builder { 46 | /// Associated error type to return 47 | type Error: std::error::Error; 48 | 49 | /// Build the given type 50 | fn build(self) -> Result; 51 | } 52 | 53 | // ===== USEFUL MACROS ===== 54 | #[macro_export] 55 | #[doc(hidden)] 56 | macro_rules! expect_successful_api_response { 57 | ($response:ident, $succret:expr) => { 58 | match $response { 59 | Err(e) => { 60 | debug!("Discord API request failed: {:#?}", e); 61 | Err(HttpError { 62 | code: 0, 63 | message: format!("{:#?}", e), 64 | }) 65 | } 66 | Ok(r) => { 67 | let st = r.status(); 68 | if !st.is_success() { 69 | let e = format!("{:#?}", r.text().await); 70 | debug!("Discord API returned an error: {:#?}", e); 71 | Err(HttpError { 72 | code: st.as_u16(), 73 | message: e, 74 | }) 75 | } else { 76 | $succret 77 | } 78 | } 79 | } 80 | }; 81 | } 82 | #[macro_export] 83 | #[doc(hidden)] 84 | macro_rules! expect_specific_api_response { 85 | ($response:ident, $expres:expr, $succret:expr) => { 86 | match $response { 87 | Err(e) => { 88 | debug!("Discord API request failed: {:#?}", e); 89 | 90 | Err(HttpError { 91 | code: 0, 92 | message: format!("{:#?}", e), 93 | }) 94 | } 95 | Ok(r) => { 96 | let st = r.status(); 97 | if st != $expres { 98 | let e = format!("{:#?}", r.text().await); 99 | debug!("Discord API returned an error: {:#?}", e); 100 | Err(HttpError { 101 | code: st.as_u16(), 102 | message: e, 103 | }) 104 | } else { 105 | $succret 106 | } 107 | } 108 | } 109 | }; 110 | } 111 | 112 | #[macro_export] 113 | #[doc(hidden)] 114 | macro_rules! expect_successful_api_response_and_return { 115 | ($response:ident, $struc:ident, $retval:ident, $succret:expr) => { 116 | match $response { 117 | Err(e) => { 118 | debug!("Discord API request failed: {:#?}", e); 119 | Err(HttpError { 120 | code: 0, 121 | message: format!("{:#?}", e), 122 | }) 123 | } 124 | Ok(r) => { 125 | let st = r.status(); 126 | let text = r.text().await.unwrap(); 127 | if !st.is_success() { 128 | let e = format!("{:#?}", &text); 129 | debug!("Discord API returned an error: {:#?}", e); 130 | Err(HttpError { 131 | code: st.as_u16(), 132 | message: e, 133 | }) 134 | } else { 135 | let a: Result<$struc, serde_json::Error> = serde_json::from_str(&text); 136 | 137 | match a { 138 | Err(e) => { 139 | debug!("Failed to decode response: {:#?}", e); 140 | debug!("Original response: {:#?}", &text); 141 | Err(HttpError { 142 | code: 500, 143 | message: format!("{:?}", e), 144 | }) 145 | } 146 | Ok($retval) => $succret, 147 | } 148 | } 149 | } 150 | } 151 | }; 152 | } 153 | -------------------------------------------------------------------------------- /src/macros.rs: -------------------------------------------------------------------------------- 1 | /// Macro that generates an `HttpResponse` containing a message serialized in JSON 2 | #[macro_export] 3 | #[doc(hidden)] 4 | macro_rules! ERROR_RESPONSE { 5 | ($status:expr, $message:expr) => {{ 6 | let emsg = $crate::types::MessageError::new(::std::string::String::from($message)); 7 | 8 | Ok(::actix_web::HttpResponse::build( 9 | ::actix_web::http::StatusCode::from_u16($status).unwrap(), 10 | ) 11 | .json(emsg)) 12 | }}; 13 | } 14 | -------------------------------------------------------------------------------- /src/security.rs: -------------------------------------------------------------------------------- 1 | use ed25519_dalek::{Verifier, VerifyingKey, Signature}; 2 | 3 | /// If verification fails, it will return the `ValidationError` enum. 4 | pub enum ValidationError { 5 | /// For anything related to conversion errors 6 | KeyConversionError { 7 | /// What error? 8 | name: &'static str, 9 | }, 10 | /// For invalid keys 11 | InvalidSignatureError, 12 | } 13 | 14 | /// Verifies an incoming Interaction. 15 | /// This verification is mandatory for every incoming Interaction. 16 | /// See [the developer docs](https://discord.com/developers/docs/interactions/slash-commands#security-and-authorization) for more info 17 | pub fn verify_discord_message( 18 | public_key: VerifyingKey, 19 | signature: &str, 20 | timestamp: &str, 21 | body: &str, 22 | ) -> Result<(), ValidationError> { 23 | let signature_bytes = 24 | hex::decode(signature).map_err(|_| ValidationError::KeyConversionError { 25 | name: "Hex conversion error", 26 | })?; 27 | 28 | let signature = Signature::from_slice(signature_bytes.as_slice()).map_err(|_| { 29 | ValidationError::KeyConversionError { 30 | name: "From bytes conversion error", 31 | } 32 | })?; 33 | 34 | // Format the data to verify (Timestamp + body) 35 | let msg = format!("{}{}", timestamp, body); 36 | 37 | public_key 38 | .verify(msg.as_bytes(), &signature) 39 | .map_err(|_| ValidationError::InvalidSignatureError) 40 | } 41 | -------------------------------------------------------------------------------- /src/tests.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryFrom; 2 | use crate::security::*; 3 | #[cfg(feature = "handler")] 4 | use crate::handler::InteractionHandler; 5 | 6 | #[cfg(feature = "handler")] 7 | use crate::types::interaction::{ 8 | Context, InteractionResponse, InteractionResponseBuilder, InteractionResponseType, 9 | }; 10 | #[cfg(feature = "handler")] 11 | use crate::*; 12 | 13 | #[cfg(feature = "handler")] 14 | use actix_web::test; 15 | use ed25519_dalek::VerifyingKey; 16 | 17 | #[cfg(feature = "handler")] 18 | use log::error; 19 | 20 | const TEST_PUB_KEY: &str = "82d8d97fe0641e68a1b0b11220f05e9ea0539a0cdc002119d4a9e9e025aba1e9"; 21 | /*------------------------------ 22 | SECURITY TESTS 23 | */ 24 | #[test] 25 | // Discord interaction verification test OK 1 26 | fn crypto_verify_test_ok() { 27 | let bytes = hex::decode(TEST_PUB_KEY).expect("Failed to decode public key."); 28 | let pbk = VerifyingKey::try_from(bytes.as_slice()).expect("Failed to convert public key."); 29 | 30 | let res = verify_discord_message(pbk, 31 | "c41278a0cf22bf8f3061756063cd7ef548a3df23d0ffc5496209aa0ad4d9593343801bf11e099f41bca1afcac2c70734eebafede3dec7aac1caa5d8fade5af0c", 32 | "1616343571", 33 | &String::from("{\"type\" : 1}")); 34 | 35 | match res { 36 | Err(ValidationError::KeyConversionError { name }) => panic!( 37 | "One of the keys failed to convert to proper types! Key: {}", 38 | name 39 | ), 40 | Err(ValidationError::InvalidSignatureError) => { 41 | panic!("Unexpected invalidation of signature") 42 | } 43 | Ok(_) => { 44 | // Good! 45 | } 46 | } 47 | } 48 | 49 | #[test] 50 | #[should_panic(expected = "Unexpected invalidation of signature")] 51 | // Discord interacton verification test invalid 1 52 | fn crypto_verify_test_fail() { 53 | let bytes = hex::decode(TEST_PUB_KEY).expect("Failed to decode public key."); 54 | let pbk = VerifyingKey::try_from(bytes.as_slice()).expect("Failed to convert public key."); 55 | 56 | let res = verify_discord_message(pbk, 57 | "69696969696969696696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696696969", 58 | "1616343571", 59 | &String::from("{\"type\" : 1}")); 60 | 61 | match res { 62 | Err(ValidationError::KeyConversionError { name }) => panic!( 63 | "One of the keys failed to convert to proper types! Key: {}", 64 | name 65 | ), 66 | Err(ValidationError::InvalidSignatureError) => { 67 | panic!("Unexpected invalidation of signature") 68 | } // This is what it should be! 69 | 70 | Ok(_) => { 71 | // Good! 72 | } 73 | } 74 | } 75 | /*------------------------------- 76 | Discord Interactions API tests (endpoint: /api/discord/interactions) 77 | */ 78 | #[cfg(feature = "handler")] 79 | macro_rules! interaction_app_init { 80 | ($ih: ident) => { 81 | 82 | test::init_service(App::new().app_data($ih.clone()).route( 83 | "/api/discord/interactions", 84 | web::post().to( 85 | |data: web::Data>, req: HttpRequest, body: String| async move { 86 | data.lock().unwrap().interaction(req, body).await 87 | }, 88 | ), 89 | )) 90 | .await; 91 | }; 92 | } 93 | #[cfg(all(feature = "handler", not(feature = "extended-handler")))] 94 | macro_rules! init_handler { 95 | () => { 96 | InteractionHandler::new(0, TEST_PUB_KEY, None) 97 | }; 98 | } 99 | 100 | #[cfg(feature = "extended-handler")] 101 | macro_rules! init_handler { 102 | () => { 103 | InteractionHandler::new(0, TEST_PUB_KEY, Some(&String::new())) 104 | }; 105 | } 106 | 107 | #[cfg(feature = "handler")] 108 | #[actix_rt::test] 109 | // Request with bad content with no Content-Type header present 110 | // Expected result: Return 400 without panicking 111 | async fn interactions_no_content_type_header_test() { 112 | let ih = init_handler!(); 113 | 114 | let data = web::Data::new(Mutex::new(ih)); 115 | let mut app = interaction_app_init!(data); 116 | 117 | let req = test::TestRequest::post() 118 | .uri("/api/discord/interactions") 119 | .set_payload("This is some malformed text { the system : can't really handle }") 120 | .to_request(); 121 | 122 | let res: types::MessageError = test::read_response_json(&mut app, req).await; 123 | 124 | assert_eq!(res.message, "Bad Content-Type"); 125 | } 126 | 127 | #[cfg(feature = "handler")] 128 | #[actix_rt::test] 129 | // Request with bad content with no Content-Type header present 130 | // Expected result: Return 400 without panicking 131 | async fn interactions_bad_content_type_header_test() { 132 | let ih = init_handler!(); 133 | 134 | let data = web::Data::new(Mutex::new(ih)); 135 | let mut app = interaction_app_init!(data); 136 | 137 | let req = test::TestRequest::post() 138 | .uri("/api/discord/interactions") 139 | .header("Content-Type", "plain/text") 140 | .set_payload("This is some malformed text { the system : can't really handle }") 141 | .to_request(); 142 | 143 | let res: types::MessageError = test::read_response_json(&mut app, req).await; 144 | 145 | assert_eq!(res.message, "Bad Content-Type"); 146 | } 147 | 148 | #[cfg(feature = "handler")] 149 | #[actix_rt::test] 150 | // Request with missing X-Signature-Ed25519 Header 151 | // Expected result: Return 400 without panicking 152 | async fn interactions_no_signature_header_test() { 153 | let ih = init_handler!(); 154 | 155 | let data = web::Data::new(Mutex::new(ih)); 156 | let mut app = interaction_app_init!(data); 157 | 158 | let req = test::TestRequest::post() 159 | .uri("/api/discord/interactions") 160 | .header("Content-Type", "application/json") 161 | .header("X-Signature-Timestamp", "1229349") 162 | .set_payload("This is some malformed text { the system : can't really handle }") 163 | .to_request(); 164 | 165 | let res: types::MessageError = test::read_response_json(&mut app, req).await; 166 | 167 | assert_eq!(res.message, "Bad signature data"); 168 | } 169 | 170 | #[cfg(feature = "handler")] 171 | #[actix_rt::test] 172 | // Request with missing X-Signature-Timestamp Header 173 | // Expected result: Return 400 without panicking 174 | async fn interactions_no_timestamp_header_test() { 175 | let ih = init_handler!(); 176 | 177 | let data = web::Data::new(Mutex::new(ih)); 178 | let mut app = interaction_app_init!(data); 179 | 180 | let req = test::TestRequest::post() 181 | .uri("/api/discord/interactions") 182 | .header("Content-Type", "application/json") 183 | .header("X-Signature-Ed25519", "69696969696969696696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696696969") 184 | .set_payload("This is some malformed text { the system : can't really handle }") 185 | .to_request(); 186 | 187 | let res: types::MessageError = test::read_response_json(&mut app, req).await; 188 | 189 | assert_eq!(res.message, "Bad signature data"); 190 | } 191 | 192 | #[cfg(feature = "handler")] 193 | #[actix_rt::test] 194 | // Request with missing a signature that is too short (< 512 bits) 195 | // Expected result: Return 400 without panicking 196 | async fn interactions_bad_signature_length_short_test() { 197 | let ih = init_handler!(); 198 | 199 | let data = web::Data::new(Mutex::new(ih)); 200 | let mut app = interaction_app_init!(data); 201 | 202 | let req = test::TestRequest::post() 203 | .uri("/api/discord/interactions") 204 | .header("Content-Type", "application/json") 205 | .header( 206 | "X-Signature-Ed25519", 207 | "69696969696969696696969696969696969696969696969696969696969696969", 208 | ) 209 | .set_payload("This is some malformed text { the system : can't really handle }") 210 | .to_request(); 211 | 212 | let res: types::MessageError = test::read_response_json(&mut app, req).await; 213 | 214 | assert_eq!(res.message, "Bad signature data"); 215 | } 216 | 217 | #[cfg(feature = "handler")] 218 | #[actix_rt::test] 219 | // Request with missing a signature that is too long (> 512 bits) 220 | // Expected result: Return 400 without panicking 221 | async fn interactions_bad_signature_length_too_long_test() { 222 | let ih = init_handler!(); 223 | 224 | let data = web::Data::new(Mutex::new(ih)); 225 | let mut app = interaction_app_init!(data); 226 | 227 | let req = test::TestRequest::post() 228 | .uri("/api/discord/interactions") 229 | .header("Content-Type", "application/json") 230 | .header("X-Signature-Ed25519", "6969696969696969669696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969669696969696969696969696696969696969696969696969696969696969696969") 231 | .set_payload("This is some malformed text { the system : can't really handle }") 232 | .to_request(); 233 | 234 | let res: types::MessageError = test::read_response_json(&mut app, req).await; 235 | 236 | assert_eq!(res.message, "Bad signature data"); 237 | } 238 | 239 | #[cfg(feature = "handler")] 240 | #[actix_rt::test] 241 | // Normal ping request 242 | // Expected result: Return 200 with payload 243 | async fn interactions_ping_test() { 244 | let ih = init_handler!(); 245 | 246 | let data = web::Data::new(Mutex::new(ih)); 247 | let mut app = interaction_app_init!(data); 248 | 249 | let req = test::TestRequest::post() 250 | .uri("/api/discord/interactions") 251 | .header("Content-Type", "application/json") 252 | .header("X-Signature-Ed25519", "c41278a0cf22bf8f3061756063cd7ef548a3df23d0ffc5496209aa0ad4d9593343801bf11e099f41bca1afcac2c70734eebafede3dec7aac1caa5d8fade5af0c") 253 | .header("X-Signature-Timestamp", "1616343571") 254 | .set_payload("{\"type\" : 1}") 255 | .to_request(); 256 | 257 | let res: types::interaction::InteractionResponse = 258 | test::read_response_json(&mut app, req).await; 259 | 260 | assert_eq!( 261 | res.r#type, 262 | types::interaction::InteractionResponseType::Pong 263 | ); 264 | } 265 | 266 | #[cfg(feature = "handler")] 267 | #[actix_rt::test] 268 | // Bad content but OK signature test 269 | // Expected result: Return 400 with error, don't panic 270 | async fn interactions_bad_body_test() { 271 | let ih = init_handler!(); 272 | 273 | let data = web::Data::new(Mutex::new(ih)); 274 | let mut app = interaction_app_init!(data); 275 | 276 | let req = test::TestRequest::post() 277 | .uri("/api/discord/interactions") 278 | .header("Content-Type", "application/json") 279 | .header("X-Signature-Ed25519", "51c5defa19cc2471a361c00c87a7f380d9e9d6cd21f05b65d3c223aac0b7d258277a09d0a016108e0be1338d985ed4ce0dae55e5ac93db5957a37ce31d007505") 280 | .header("X-Signature-Timestamp", "1616343571") 281 | .set_payload("this is some malformed {\"data\" : cant handle}") 282 | .to_request(); 283 | 284 | let res = test::call_service(&mut app, req).await; 285 | 286 | assert_eq!(res.status(), http::StatusCode::BAD_REQUEST); 287 | } 288 | #[cfg(feature = "handler")] 289 | #[allow(unused_must_use)] 290 | #[slash_command] 291 | async fn normal_handle_test(ctx: Context) -> InteractionResponse { 292 | return ctx.respond().content("TEST").finish(); 293 | } 294 | #[cfg(feature = "handler")] 295 | #[allow(unused_must_use)] 296 | #[slash_command] 297 | async fn normal_handle_value_test(ctx: Context) -> InteractionResponse { 298 | let response = ctx.respond().content("TEST").finish(); 299 | return response; 300 | } 301 | #[cfg(feature = "handler")] 302 | #[allow(unused_must_use)] 303 | #[slash_command] 304 | async fn normal_handle_direct_test(_ctx: Context) -> InteractionResponse { 305 | return InteractionResponseBuilder::default() 306 | .content("TEST") 307 | .finish(); 308 | } 309 | #[cfg(feature = "handler")] 310 | #[actix_rt::test] 311 | async fn interactions_normal_handle_test() { 312 | let mut ih = init_handler!(); 313 | 314 | ih.add_global_command("test", normal_handle_test); 315 | 316 | let data = web::Data::new(Mutex::new(ih)); 317 | let mut app = interaction_app_init!(data); 318 | 319 | let req = test::TestRequest::post() 320 | .uri("/api/discord/interactions") 321 | .header("Content-Type", "application/json") 322 | .header("X-Signature-Ed25519", "a27ed2fd0e91da58667bec63d14406e5274a0427edad9530b7d95e9d2b0fc4ee17f74e8a6bd3acd6623a05f1bde9e598fa37f3eedfe479da0a00da7827595e0b") 323 | .header("X-Signature-Timestamp", "1616343571") 324 | .set_payload("{\"type\":2,\"token\":\"awQabcabc\",\"member\":{\"user\":{\"id\":\"317209107000066050\",\"username\":\"C0der\",\"avatar\":\"a_d5efa99b3eeaa7dd43acca82f5692432\",\"discriminator\":\"1337\",\"public_flags\":131141},\"roles\":[],\"premium_since\":null,\"permissions\":\"2147483647\",\"pending\":false,\"nick\":null,\"mute\":false,\"joined_at\":\"2017-03-13T19:19:14.040000+00:00\",\"is_pending\":false,\"deaf\":false},\"id\":\"786008729715212338\",\"guild_id\":\"290926798626357999\",\"data\":{\"name\":\"test\",\"id\":\"771825006014889984\"},\"channel_id\":\"645027906669510667\"}") 325 | .to_request(); 326 | 327 | let res: types::interaction::InteractionResponse = 328 | test::read_response_json(&mut app, req).await; 329 | 330 | let expected_data = InteractionResponseBuilder::default() 331 | .content("TEST") 332 | .finish(); 333 | 334 | //let expected_res = HttpResponse::build(StatusCode::OK).json(expected_data); 335 | 336 | assert_eq!(res, expected_data); 337 | } 338 | #[cfg(feature = "handler")] 339 | #[actix_rt::test] 340 | async fn interactions_normal_from_value_handle_test() { 341 | let mut ih = init_handler!(); 342 | 343 | ih.add_global_command("test", normal_handle_direct_test); 344 | 345 | let data = web::Data::new(Mutex::new(ih)); 346 | let mut app = interaction_app_init!(data); 347 | 348 | let req = test::TestRequest::post() 349 | .uri("/api/discord/interactions") 350 | .header("Content-Type", "application/json") 351 | .header("X-Signature-Ed25519", "a27ed2fd0e91da58667bec63d14406e5274a0427edad9530b7d95e9d2b0fc4ee17f74e8a6bd3acd6623a05f1bde9e598fa37f3eedfe479da0a00da7827595e0b") 352 | .header("X-Signature-Timestamp", "1616343571") 353 | .set_payload("{\"type\":2,\"token\":\"awQabcabc\",\"member\":{\"user\":{\"id\":\"317209107000066050\",\"username\":\"C0der\",\"avatar\":\"a_d5efa99b3eeaa7dd43acca82f5692432\",\"discriminator\":\"1337\",\"public_flags\":131141},\"roles\":[],\"premium_since\":null,\"permissions\":\"2147483647\",\"pending\":false,\"nick\":null,\"mute\":false,\"joined_at\":\"2017-03-13T19:19:14.040000+00:00\",\"is_pending\":false,\"deaf\":false},\"id\":\"786008729715212338\",\"guild_id\":\"290926798626357999\",\"data\":{\"name\":\"test\",\"id\":\"771825006014889984\"},\"channel_id\":\"645027906669510667\"}") 354 | .to_request(); 355 | 356 | let res: types::interaction::InteractionResponse = 357 | test::read_response_json(&mut app, req).await; 358 | 359 | let expected_data = InteractionResponseBuilder::default() 360 | .content("TEST") 361 | .finish(); 362 | 363 | //let expected_res = HttpResponse::build(StatusCode::OK).json(expected_data); 364 | 365 | assert_eq!(res, expected_data); 366 | } 367 | #[cfg(feature = "handler")] 368 | #[actix_rt::test] 369 | async fn interactions_normal_from_direct_call_handle_test() { 370 | let mut ih = init_handler!(); 371 | 372 | ih.add_global_command("test", normal_handle_value_test); 373 | 374 | let data = web::Data::new(Mutex::new(ih)); 375 | let mut app = interaction_app_init!(data); 376 | 377 | let req = test::TestRequest::post() 378 | .uri("/api/discord/interactions") 379 | .header("Content-Type", "application/json") 380 | .header("X-Signature-Ed25519", "a27ed2fd0e91da58667bec63d14406e5274a0427edad9530b7d95e9d2b0fc4ee17f74e8a6bd3acd6623a05f1bde9e598fa37f3eedfe479da0a00da7827595e0b") 381 | .header("X-Signature-Timestamp", "1616343571") 382 | .set_payload("{\"type\":2,\"token\":\"awQabcabc\",\"member\":{\"user\":{\"id\":\"317209107000066050\",\"username\":\"C0der\",\"avatar\":\"a_d5efa99b3eeaa7dd43acca82f5692432\",\"discriminator\":\"1337\",\"public_flags\":131141},\"roles\":[],\"premium_since\":null,\"permissions\":\"2147483647\",\"pending\":false,\"nick\":null,\"mute\":false,\"joined_at\":\"2017-03-13T19:19:14.040000+00:00\",\"is_pending\":false,\"deaf\":false},\"id\":\"786008729715212338\",\"guild_id\":\"290926798626357999\",\"data\":{\"name\":\"test\",\"id\":\"771825006014889984\"},\"channel_id\":\"645027906669510667\"}") 383 | .to_request(); 384 | 385 | let res: types::interaction::InteractionResponse = 386 | test::read_response_json(&mut app, req).await; 387 | 388 | let expected_data = InteractionResponseBuilder::default() 389 | .content("TEST") 390 | .finish(); 391 | 392 | //let expected_res = HttpResponse::build(StatusCode::OK).json(expected_data); 393 | 394 | assert_eq!(res, expected_data); 395 | } 396 | 397 | #[cfg(feature = "handler")] 398 | use crate::types::interaction::WebhookMessage; 399 | #[cfg(feature = "handler")] 400 | #[slash_command_test] 401 | #[defer] 402 | async fn deffered_handle_test(ctx: Context) -> InteractionResponse { 403 | return ctx.respond().content("TEST").finish(); 404 | } 405 | #[cfg(feature = "handler")] 406 | #[slash_command_test] 407 | #[defer] 408 | async fn deffered_handle_value_test(ctx: Context) -> InteractionResponse { 409 | let response = ctx.respond().content("TEST").finish(); 410 | return response; 411 | } 412 | #[cfg(feature = "handler")] 413 | #[slash_command_test] 414 | #[defer] 415 | async fn deffered_handle_direct_test(_ctx: Context) -> InteractionResponse { 416 | return InteractionResponseBuilder::default() 417 | .content("TEST") 418 | .finish(); 419 | } 420 | #[cfg(feature = "handler")] 421 | #[actix_rt::test] 422 | async fn interactions_deffered_handle_test() { 423 | let mut ih = init_handler!(); 424 | 425 | ih.add_global_command("test", deffered_handle_test); 426 | 427 | let data = web::Data::new(Mutex::new(ih)); 428 | let mut app = interaction_app_init!(data); 429 | 430 | let req = test::TestRequest::post() 431 | .uri("/api/discord/interactions") 432 | .header("Content-Type", "application/json") 433 | .header("X-Signature-Ed25519", "a27ed2fd0e91da58667bec63d14406e5274a0427edad9530b7d95e9d2b0fc4ee17f74e8a6bd3acd6623a05f1bde9e598fa37f3eedfe479da0a00da7827595e0b") 434 | .header("X-Signature-Timestamp", "1616343571") 435 | .set_payload("{\"type\":2,\"token\":\"awQabcabc\",\"member\":{\"user\":{\"id\":\"317209107000066050\",\"username\":\"C0der\",\"avatar\":\"a_d5efa99b3eeaa7dd43acca82f5692432\",\"discriminator\":\"1337\",\"public_flags\":131141},\"roles\":[],\"premium_since\":null,\"permissions\":\"2147483647\",\"pending\":false,\"nick\":null,\"mute\":false,\"joined_at\":\"2017-03-13T19:19:14.040000+00:00\",\"is_pending\":false,\"deaf\":false},\"id\":\"786008729715212338\",\"guild_id\":\"290926798626357999\",\"data\":{\"name\":\"test\",\"id\":\"771825006014889984\"},\"channel_id\":\"645027906669510667\"}") 436 | .to_request(); 437 | 438 | let res: types::interaction::InteractionResponse = 439 | test::read_response_json(&mut app, req).await; 440 | 441 | let expected_data = InteractionResponse { 442 | r#type: InteractionResponseType::DefferedChannelMessageWithSource, 443 | data: None, 444 | }; 445 | 446 | //let expected_res = HttpResponse::build(StatusCode::OK).json(expected_data); 447 | 448 | assert_eq!(res, expected_data); 449 | } 450 | #[cfg(feature = "handler")] 451 | #[actix_rt::test] 452 | async fn interactions_deffered_from_value_handle_test() { 453 | let mut ih = init_handler!(); 454 | 455 | ih.add_global_command("test", deffered_handle_value_test); 456 | let data = web::Data::new(Mutex::new(ih)); 457 | let mut app = interaction_app_init!(data); 458 | 459 | let req = test::TestRequest::post() 460 | .uri("/api/discord/interactions") 461 | .header("Content-Type", "application/json") 462 | .header("X-Signature-Ed25519", "a27ed2fd0e91da58667bec63d14406e5274a0427edad9530b7d95e9d2b0fc4ee17f74e8a6bd3acd6623a05f1bde9e598fa37f3eedfe479da0a00da7827595e0b") 463 | .header("X-Signature-Timestamp", "1616343571") 464 | .set_payload("{\"type\":2,\"token\":\"awQabcabc\",\"member\":{\"user\":{\"id\":\"317209107000066050\",\"username\":\"C0der\",\"avatar\":\"a_d5efa99b3eeaa7dd43acca82f5692432\",\"discriminator\":\"1337\",\"public_flags\":131141},\"roles\":[],\"premium_since\":null,\"permissions\":\"2147483647\",\"pending\":false,\"nick\":null,\"mute\":false,\"joined_at\":\"2017-03-13T19:19:14.040000+00:00\",\"is_pending\":false,\"deaf\":false},\"id\":\"786008729715212338\",\"guild_id\":\"290926798626357999\",\"data\":{\"name\":\"test\",\"id\":\"771825006014889984\"},\"channel_id\":\"645027906669510667\"}") 465 | .to_request(); 466 | 467 | let res: types::interaction::InteractionResponse = 468 | test::read_response_json(&mut app, req).await; 469 | 470 | let expected_data = InteractionResponse { 471 | r#type: InteractionResponseType::DefferedChannelMessageWithSource, 472 | data: None, 473 | }; 474 | 475 | //let expected_res = HttpResponse::build(StatusCode::OK).json(expected_data); 476 | 477 | assert_eq!(res, expected_data); 478 | } 479 | #[cfg(feature = "handler")] 480 | #[actix_rt::test] 481 | async fn interactions_deffered_from_direct_value_handle_test() { 482 | let mut ih = init_handler!(); 483 | 484 | ih.add_global_command("test", deffered_handle_direct_test); 485 | let data = web::Data::new(Mutex::new(ih)); 486 | let mut app = interaction_app_init!(data); 487 | 488 | let req = test::TestRequest::post() 489 | .uri("/api/discord/interactions") 490 | .header("Content-Type", "application/json") 491 | .header("X-Signature-Ed25519", "a27ed2fd0e91da58667bec63d14406e5274a0427edad9530b7d95e9d2b0fc4ee17f74e8a6bd3acd6623a05f1bde9e598fa37f3eedfe479da0a00da7827595e0b") 492 | .header("X-Signature-Timestamp", "1616343571") 493 | .set_payload("{\"type\":2,\"token\":\"awQabcabc\",\"member\":{\"user\":{\"id\":\"317209107000066050\",\"username\":\"C0der\",\"avatar\":\"a_d5efa99b3eeaa7dd43acca82f5692432\",\"discriminator\":\"1337\",\"public_flags\":131141},\"roles\":[],\"premium_since\":null,\"permissions\":\"2147483647\",\"pending\":false,\"nick\":null,\"mute\":false,\"joined_at\":\"2017-03-13T19:19:14.040000+00:00\",\"is_pending\":false,\"deaf\":false},\"id\":\"786008729715212338\",\"guild_id\":\"290926798626357999\",\"data\":{\"name\":\"test\",\"id\":\"771825006014889984\"},\"channel_id\":\"645027906669510667\"}") 494 | .to_request(); 495 | 496 | let res: types::interaction::InteractionResponse = 497 | test::read_response_json(&mut app, req).await; 498 | 499 | let expected_data = InteractionResponse { 500 | r#type: InteractionResponseType::DefferedChannelMessageWithSource, 501 | data: None, 502 | }; 503 | 504 | //let expected_res = HttpResponse::build(StatusCode::OK).json(expected_data); 505 | 506 | assert_eq!(res, expected_data); 507 | } 508 | -------------------------------------------------------------------------------- /src/types/application.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use serde_with::*; 5 | 6 | #[cfg(feature = "builder")] 7 | use crate::Builder; 8 | 9 | use super::components::ComponentType; 10 | use super::user::*; 11 | use super::Snowflake; 12 | use serde_repr::*; 13 | use crate::types::attachment::Attachment; 14 | 15 | #[serde_as] 16 | #[skip_serializing_none] 17 | #[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] 18 | /// AKA a 'slash command'. 19 | pub struct ApplicationCommand { 20 | #[serde_as(as = "Option")] 21 | #[serde(default)] 22 | /// ID of command 23 | pub id: Option, 24 | 25 | /// the type of command, defaults `1` if not set 26 | pub r#type: Option, 27 | 28 | #[serde_as(as = "Option")] 29 | #[serde(default)] 30 | application_id: Option, 31 | 32 | #[serde_as(as = "Option")] 33 | #[serde(default)] 34 | /// guild id of the command, if not global 35 | pub guild_id: Option, 36 | 37 | /// Command name 38 | pub name: String, 39 | /// Command description 40 | description: String, 41 | /// Command options 42 | options: Option>, 43 | 44 | /// Whether the command is enabled by default when the app is added to a guild 45 | default_permission: Option, 46 | } 47 | 48 | #[derive(Clone, Serialize_repr, Deserialize_repr, Debug, PartialEq)] 49 | #[repr(u8)] 50 | #[non_exhaustive] 51 | /// Type of `ApplicationCommand` 52 | pub enum ApplicationCommandType { 53 | /// Slash commands; a text-based command that shows up when a user types `/` 54 | ChatInput = 1, 55 | /// A UI-based command that shows up when you right click or tap on a user 56 | User = 2, 57 | /// A UI-based command that shows up when you right click or tap on a messages 58 | Message = 3, 59 | } 60 | 61 | impl Default for ApplicationCommand { 62 | fn default() -> Self { 63 | Self { 64 | id: None, 65 | r#type: Some(ApplicationCommandType::ChatInput), 66 | application_id: None, 67 | guild_id: None, 68 | name: String::new(), 69 | description: String::new(), 70 | options: None, 71 | default_permission: Some(true), 72 | } 73 | } 74 | } 75 | 76 | #[derive(Clone, Copy, Serialize_repr, Deserialize_repr, Debug, PartialEq)] 77 | #[repr(u8)] 78 | #[non_exhaustive] 79 | /// Type of permission override 80 | pub enum ApplicationCommandPermissionType { 81 | /// A guild role 82 | Role = 1, 83 | /// A user 84 | User = 2, 85 | } 86 | 87 | #[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] 88 | /// Used for specifying a batch of [`ApplicationCommandPermission`]s 89 | pub struct ApplicationCommandPermissionBatch { 90 | /// ID of the command 91 | pub id: Snowflake, 92 | /// Permissions (see [`ApplicationCommandPermission`]) 93 | pub permissions: Vec, 94 | } 95 | 96 | #[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] 97 | /// A permission override for a [`ApplicationCommand`] 98 | pub struct ApplicationCommandPermission { 99 | /// Role or user ID 100 | pub id: Snowflake, 101 | /// Type of override. See [`ApplicationCommandPermissionType`] 102 | pub r#type: ApplicationCommandPermissionType, 103 | 104 | /// Allow or disallow for this override 105 | pub permission: bool, 106 | } 107 | 108 | #[serde_as] 109 | #[skip_serializing_none] 110 | #[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] 111 | /// Command option 112 | pub struct ApplicationCommandOption { 113 | r#type: ApplicationCommandOptionType, 114 | #[serde_as(as = "Option")] 115 | #[serde(default)] 116 | name: Option, 117 | #[serde_as(as = "Option")] 118 | #[serde(default)] 119 | description: Option, 120 | #[serde_as(as = "Option<_>")] 121 | #[serde(default)] 122 | required: Option, 123 | #[serde_as(as = "Option>")] 124 | #[serde(default)] 125 | choices: Option>, 126 | 127 | #[serde_as(as = "Option>")] 128 | #[serde(default)] 129 | options: Option>, 130 | } 131 | 132 | impl Default for ApplicationCommandOption { 133 | fn default() -> Self { 134 | Self { 135 | r#type: ApplicationCommandOptionType::String, 136 | name: None, 137 | description: None, 138 | required: None, 139 | choices: None, 140 | options: None, 141 | } 142 | } 143 | } 144 | 145 | impl ApplicationCommandOption { 146 | /// Set the type 147 | pub fn option_type(mut self, ty: &ApplicationCommandOptionType) -> Self { 148 | self.r#type = ty.clone(); 149 | self 150 | } 151 | /// Set the option name 152 | /// 153 | /// This can only be lower case and may not contain spaces and special characters 154 | pub fn name(mut self, name: impl Into) -> Self { 155 | self.name = Some(name.into()); 156 | self 157 | } 158 | 159 | /// Set the option description 160 | pub fn description(mut self, desc: impl Into) -> Self { 161 | self.description = Some(desc.into()); 162 | self 163 | } 164 | 165 | /// Sets whether this option is required to be filled in 166 | pub fn required(mut self, req: &bool) -> Self { 167 | self.required = Some(*req); 168 | self 169 | } 170 | 171 | /// Add a choice 172 | pub fn add_choice(mut self, choice: &ApplicationCommandOptionChoice) -> Self { 173 | match self.choices.as_mut() { 174 | None => { 175 | self.choices = Some(vec![choice.clone()]); 176 | } 177 | Some(o) => { 178 | o.push(choice.clone()); 179 | } 180 | } 181 | self 182 | } 183 | 184 | /// Add another option 185 | /// 186 | /// Can only be used with the `SubCommand` and `SubCommandGroup` types. 187 | pub fn add_option(mut self, opt: &ApplicationCommandOption) -> Self { 188 | match self.options.as_mut() { 189 | None => { 190 | self.options = Some(vec![opt.clone()]); 191 | } 192 | Some(o) => { 193 | o.push(opt.clone()); 194 | } 195 | } 196 | self 197 | } 198 | } 199 | 200 | #[derive(Clone, Serialize_repr, Deserialize_repr, Debug, PartialEq)] 201 | #[repr(u8)] 202 | #[non_exhaustive] 203 | /// Representing a type of [`ApplicationCommandOption`] 204 | pub enum ApplicationCommandOptionType { 205 | /// A subcommand 206 | SubCommand = 1, 207 | /// A group of subcommands 208 | SubCommandGroup = 2, 209 | /// A string 210 | String = 3, 211 | /// An integer 212 | Integer = 4, 213 | /// A boolean 214 | Boolean = 5, 215 | /// A user 216 | User = 6, 217 | /// A channel 218 | Channel = 7, 219 | /// A role 220 | Role = 8, 221 | /// Includes users and roles 222 | Mentionable = 9, 223 | /// A number 224 | Number = 10, 225 | /// A file attachment 226 | Attachment = 11, 227 | } 228 | 229 | #[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] 230 | /// Command option choice 231 | pub struct ApplicationCommandOptionChoice { 232 | /// Name 233 | pub name: String, 234 | // This can be int 235 | /// Value 236 | pub value: String, 237 | } 238 | 239 | #[serde_as] 240 | #[skip_serializing_none] 241 | #[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] 242 | /// Stripped down version of ResolvedData 243 | pub struct ResolvedData { 244 | /// User map 245 | #[serde_as(as = "Option>")] 246 | pub users: Option>, 247 | /// Member map 248 | #[serde_as(as = "Option>")] 249 | pub members: Option>, 250 | 251 | #[serde_as(as = "Option>")] 252 | pub attachments: Option> 253 | } 254 | 255 | #[serde_as] 256 | #[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] 257 | /// Representing a slash command 258 | pub struct ApplicationCommandInteractionData { 259 | /// The unique id of the command 260 | #[serde_as(as = "Option")] 261 | #[serde(default)] 262 | pub id: Option, 263 | /// The name of the command 264 | pub name: Option, 265 | 266 | /// The type of the invoked command 267 | pub r#type: Option, 268 | 269 | /// An array of [`ApplicationCommandInteractionDataOption`] 270 | pub options: Option>, 271 | 272 | /// converted users + roles + channels 273 | // Not including this yet 274 | pub resolved: Option, 275 | 276 | /// For components, the component type 277 | pub component_type: Option, 278 | 279 | /// For components, the custom identifier for the developer 280 | pub custom_id: Option, 281 | 282 | /// For Select Menus, the selected values 283 | pub values: Option>, 284 | 285 | #[serde_as(as = "Option")] 286 | #[serde(default)] 287 | /// For User- and Message Commands, the id of the user or message targeted. 288 | pub target_id: Option, 289 | } 290 | 291 | #[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] 292 | /// Representing a bunch of options for slash commands 293 | pub struct ApplicationCommandInteractionDataOption { 294 | /// Name of the option 295 | pub name: String, 296 | /// Value of the option 297 | pub value: String, 298 | /// More options 299 | pub options: Option>, 300 | } 301 | 302 | #[derive(Clone, Debug, Default)] 303 | #[cfg(feature = "builder")] 304 | #[cfg_attr(docsrs, doc(cfg(feature = "builder")))] 305 | /// Simple builder for defining SlashCommands 306 | pub struct SlashCommandDefinitionBuilder { 307 | obj: ApplicationCommand, 308 | } 309 | 310 | #[cfg(feature = "builder")] 311 | impl SlashCommandDefinitionBuilder { 312 | /// Name of the application command 313 | pub fn name(mut self, name: impl ToString) -> Self { 314 | let n = name.to_string(); 315 | 316 | self.obj.name = n; 317 | self 318 | } 319 | 320 | /// Sets the type of command you're defining. See [`ApplicationCommandType`] 321 | pub fn command_type(mut self, c_type: ApplicationCommandType) -> Self { 322 | self.obj.r#type = Some(c_type); 323 | self 324 | } 325 | 326 | /// Command description 327 | pub fn description(mut self, desc: impl ToString) -> Self { 328 | let d = desc.to_string(); 329 | 330 | self.obj.description = d; 331 | self 332 | } 333 | 334 | /// Adds an option ([`ApplicationCommandOption`]) to the slash command definition 335 | pub fn add_option(mut self, opt: ApplicationCommandOption) -> Self { 336 | match self.obj.options.as_mut() { 337 | None => { 338 | self.obj.options = Some(vec![opt]); 339 | } 340 | Some(o) => { 341 | o.push(opt); 342 | } 343 | } 344 | self 345 | } 346 | 347 | /// Sets the default permission. If false, this command can't be used unless some 348 | /// permission override is set. 349 | pub fn default_permission(mut self, permission: bool) -> Self { 350 | self.obj.default_permission = Some(permission); 351 | self 352 | } 353 | 354 | #[deprecated(since = "0.1.9", note = "Use the `build()` function instead")] 355 | /// Finish building slash command 356 | pub fn finish(self) -> ApplicationCommand { 357 | self.obj 358 | } 359 | } 360 | 361 | #[cfg(feature = "builder")] 362 | impl Builder for SlashCommandDefinitionBuilder { 363 | type Error = std::convert::Infallible; 364 | 365 | fn build(self) -> Result { 366 | Ok(self.obj) 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /src/types/attachment.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use serde_with::*; 3 | 4 | use crate::types::Snowflake; 5 | 6 | #[serde_as] 7 | #[skip_serializing_none] 8 | #[derive(Clone, Serialize, Deserialize, Debug)] 9 | pub struct Attachment { 10 | #[serde_as(as = "DisplayFromStr")] 11 | pub id: Snowflake, 12 | pub filename: String, 13 | pub description: Option, 14 | pub content_type: Option, 15 | pub size: usize, 16 | pub url: String, 17 | pub proxy_url: String, 18 | pub height: Option, 19 | pub width: Option, 20 | pub ephemeral: Option, 21 | pub duration_secs: Option, 22 | pub waveform: Option, 23 | pub flags: Option, 24 | } 25 | 26 | impl PartialEq for Attachment { 27 | fn eq(&self, other: &Self) -> bool { 28 | self.id == other.id 29 | } 30 | } -------------------------------------------------------------------------------- /src/types/components.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "builder")] 2 | use std::error; 3 | #[cfg(feature = "builder")] 4 | use std::fmt::{self, Display}; 5 | 6 | #[cfg(feature = "builder")] 7 | use log::warn; 8 | use serde::{Deserialize, Serialize}; 9 | 10 | #[cfg(feature = "builder")] 11 | use crate::Builder; 12 | 13 | use serde_repr::*; 14 | use serde_with::*; 15 | 16 | #[serde_as] 17 | #[skip_serializing_none] 18 | #[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] 19 | /// Message components are a framework for adding interactive elements to the messages your app or bot sends. They're accessible, customizable, and easy to use. 20 | pub struct MessageComponent { 21 | /// Type of component 22 | r#type: ComponentType, 23 | style: Option, 24 | label: Option, 25 | emoji: Option, 26 | custom_id: Option, 27 | url: Option, 28 | disabled: Option, 29 | options: Option>, 30 | placeholder: Option, 31 | min_values: Option, 32 | max_values: Option, 33 | components: Option>, 34 | 35 | min_length: Option, 36 | max_length: Option, 37 | required: Option, 38 | value: Option, 39 | } 40 | 41 | impl Default for MessageComponent { 42 | fn default() -> Self { 43 | Self { 44 | r#type: ComponentType::ActionRow, 45 | style: None, 46 | label: None, 47 | custom_id: None, 48 | url: None, 49 | disabled: None, 50 | emoji: None, 51 | options: None, 52 | placeholder: None, 53 | max_values: None, 54 | min_values: None, 55 | components: None, 56 | min_length: None, 57 | max_length: None, 58 | required: None, 59 | value: None, 60 | } 61 | } 62 | } 63 | 64 | #[derive(Clone, Serialize_repr, Deserialize_repr, PartialEq, Debug)] 65 | #[repr(u8)] 66 | #[non_exhaustive] 67 | /// Represents a type of component 68 | pub enum ComponentType { 69 | /// A container for other components 70 | ActionRow = 1, 71 | /// A clickable button 72 | Button = 2, 73 | /// A select menu for picking from choices 74 | SelectMenu = 3, 75 | /// A text input object 76 | TextInput = 4, 77 | } 78 | 79 | #[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] 80 | /// A partial Emoji structure for Select Menu Options 81 | pub struct PartialEmoji { 82 | id: u64, 83 | name: String, 84 | animated: bool, 85 | } 86 | 87 | #[derive(Clone, Default, Serialize, Deserialize, PartialEq, Debug)] 88 | /// An option for select menu options 89 | pub struct ComponentSelectOption { 90 | label: String, 91 | value: String, 92 | description: Option, 93 | emoji: Option, 94 | default: Option, 95 | } 96 | 97 | impl ComponentSelectOption { 98 | /// Sets the option label 99 | pub fn label(mut self, lab: impl Into) -> Self { 100 | self.label = lab.into(); 101 | self 102 | } 103 | /// Sets the option value 104 | pub fn value(mut self, value: impl Into) -> Self { 105 | self.value = value.into(); 106 | self 107 | } 108 | /// Sets the option description 109 | pub fn description(mut self, des: impl Into) -> Self { 110 | self.description = Some(des.into()); 111 | self 112 | } 113 | /// Sets the default checked 114 | pub fn set_default(mut self, default: bool) -> Self { 115 | self.default = Some(default); 116 | self 117 | } 118 | } 119 | 120 | #[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] 121 | /// A select menu 122 | pub struct ComponentSelectMenu { 123 | custom_id: String, 124 | options: Vec, 125 | placeholder: Option, 126 | min_values: u8, 127 | max_values: u8, 128 | } 129 | 130 | #[cfg(feature = "builder")] 131 | impl Default for ComponentSelectMenu { 132 | fn default() -> Self { 133 | Self { 134 | custom_id: String::new(), 135 | options: Vec::new(), 136 | placeholder: None, 137 | 138 | // documented defaults 139 | min_values: 1, 140 | max_values: 1, 141 | } 142 | } 143 | } 144 | 145 | #[cfg(feature = "builder")] 146 | impl From for MessageComponent { 147 | fn from(t: ComponentSelectMenu) -> Self { 148 | MessageComponent { 149 | r#type: ComponentType::SelectMenu, 150 | custom_id: Some(t.custom_id), 151 | options: Some(t.options), 152 | placeholder: t.placeholder, 153 | min_values: Some(t.min_values), 154 | max_values: Some(t.max_values), 155 | ..Default::default() 156 | } 157 | } 158 | } 159 | 160 | #[cfg(feature = "builder")] 161 | #[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] 162 | /// A button 163 | pub struct ComponentButton { 164 | style: Option, 165 | label: Option, 166 | emoji: Option, 167 | custom_id: Option, 168 | url: Option, 169 | disabled: Option, 170 | } 171 | #[cfg(feature = "builder")] 172 | impl Default for ComponentButton { 173 | fn default() -> Self { 174 | Self { 175 | style: Some(ComponentButtonStyle::Secondary), 176 | label: None, 177 | emoji: None, 178 | custom_id: None, 179 | url: None, 180 | disabled: None, 181 | } 182 | } 183 | } 184 | #[cfg(feature = "builder")] 185 | impl From for MessageComponent { 186 | fn from(t: ComponentButton) -> Self { 187 | MessageComponent { 188 | r#type: ComponentType::Button, 189 | style: t.style, 190 | label: t.label, 191 | emoji: t.emoji, 192 | custom_id: t.custom_id, 193 | url: t.url, 194 | disabled: t.disabled, 195 | ..Default::default() 196 | } 197 | } 198 | } 199 | 200 | #[derive(Clone, Serialize_repr, Deserialize_repr, PartialEq, Debug)] 201 | #[repr(u8)] 202 | #[non_exhaustive] 203 | /// How a button looks 204 | pub enum ComponentButtonStyle { 205 | /// blurple 206 | Primary = 1, 207 | /// grey 208 | Secondary = 2, 209 | /// green 210 | Success = 3, 211 | /// red 212 | Danger = 4, 213 | /// grey with outgoing link icon 214 | Link = 5, 215 | } 216 | 217 | /// Builder for creating a Component Action Row 218 | 219 | #[cfg(feature = "builder")] 220 | #[derive(Clone, Default, PartialEq, Debug)] 221 | pub struct ComponentRowBuilder { 222 | obj: MessageComponent, 223 | } 224 | #[cfg(feature = "builder")] 225 | impl Builder for ComponentRowBuilder { 226 | type Error = std::convert::Infallible; 227 | 228 | fn build(self) -> Result { 229 | Ok(self.obj) 230 | } 231 | } 232 | 233 | #[cfg(feature = "builder")] 234 | impl ComponentRowBuilder { 235 | /// Add a button 236 | pub fn add_button(mut self, button: ComponentButton) -> Self { 237 | match self.obj.components.as_mut() { 238 | None => { 239 | self.obj.components = Some(vec![button.into()]); 240 | } 241 | Some(c) => { 242 | c.push(button.into()); 243 | } 244 | } 245 | self 246 | } 247 | 248 | /// Add a select menu to the row 249 | pub fn add_select_menu(mut self, menu: ComponentSelectMenu) -> Self { 250 | match self.obj.components.as_mut() { 251 | None => { 252 | self.obj.components = Some(vec![menu.into()]); 253 | } 254 | Some(c) => { 255 | c.push(menu.into()); 256 | } 257 | } 258 | self 259 | } 260 | 261 | #[deprecated(since = "0.1.9", note = "Use the `build()` function instead")] 262 | /// Finish building this row (returns a [`MessageComponent`]) 263 | pub fn finish(self) -> MessageComponent { 264 | self.obj 265 | } 266 | } 267 | #[cfg(feature = "builder")] 268 | #[derive(Clone, PartialEq, Debug)] 269 | /// Builder for making an button component 270 | pub struct ComponentButtonBuilder { 271 | obj: ComponentButton, 272 | } 273 | 274 | #[allow(clippy::field_reassign_with_default)] 275 | #[cfg(feature = "builder")] 276 | impl Default for ComponentButtonBuilder { 277 | fn default() -> Self { 278 | let ob = ComponentButton::default(); 279 | Self { obj: ob } 280 | } 281 | } 282 | #[cfg(feature = "builder")] 283 | impl ComponentButtonBuilder { 284 | /// Finish building this button 285 | #[deprecated(since = "0.1.9", note = "Use the `build()` function instead")] 286 | pub fn finish(self) -> ComponentButton { 287 | match self.obj.clone().style.unwrap() { 288 | ComponentButtonStyle::Link => { 289 | if self.obj.url.is_none() { 290 | warn!("The button style is set to 'Link', but no url was specified."); 291 | } 292 | } 293 | _ => { 294 | if self.obj.custom_id.is_none() { 295 | warn!("No custom_id was supplied for this button!") 296 | } 297 | } 298 | } 299 | self.obj 300 | } 301 | 302 | /// Set the button style. Takes a [`ComponentButtonStyle`] 303 | pub fn style(mut self, s: &ComponentButtonStyle) -> Self { 304 | self.obj.style = Some(s.clone()); 305 | self 306 | } 307 | /// Set the button label 308 | pub fn label(mut self, l: impl Into) -> Self { 309 | let lab = l.into(); 310 | 311 | self.obj.label = Some(lab); 312 | self 313 | } 314 | /// Set a custom id (required for all styles except `ComponentButtonStyle::Link`) 315 | pub fn custom_id(mut self, id: impl Into) -> Self { 316 | let i = id.into(); 317 | 318 | self.obj.custom_id = Some(i); 319 | self 320 | } 321 | /// Set a URL (required if style is set to `ComponentButtonStyle::Link`) 322 | pub fn url(mut self, url: impl Into) -> Self { 323 | let u = url.into(); 324 | 325 | self.obj.url = Some(u); 326 | self 327 | } 328 | /// Disables/deactivates a button 329 | pub fn disabled(mut self, disabled: bool) -> Self { 330 | self.obj.disabled = Some(disabled); 331 | self 332 | } 333 | } 334 | 335 | #[cfg(feature = "builder")] 336 | #[derive(Debug)] 337 | /// An error that occurred when building a Component 338 | pub enum ComponentBuilderError { 339 | /// The component had no specified style 340 | NoStyle, 341 | /// The component was a Link without a specified URL 342 | LinkWithoutUrl, 343 | /// The component was a Button without a specified custom ID 344 | NoCustomId, 345 | } 346 | 347 | #[cfg(feature = "builder")] 348 | impl Display for ComponentBuilderError { 349 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 350 | match self { 351 | ComponentBuilderError::NoStyle => write!(f, "style is none"), 352 | ComponentBuilderError::LinkWithoutUrl => write!( 353 | f, 354 | "the button style is set to 'Link', but no url was specified" 355 | ), 356 | ComponentBuilderError::NoCustomId => { 357 | write!(f, "no custom ID specified for this button") 358 | } 359 | } 360 | } 361 | } 362 | 363 | #[cfg(feature = "builder")] 364 | impl error::Error for ComponentBuilderError {} 365 | 366 | #[cfg(feature = "builder")] 367 | impl Builder for ComponentButtonBuilder { 368 | type Error = ComponentBuilderError; 369 | 370 | fn build(self) -> Result { 371 | let style = self 372 | .obj 373 | .style 374 | .as_ref() 375 | .ok_or(ComponentBuilderError::NoStyle)?; 376 | 377 | match style { 378 | ComponentButtonStyle::Link => { 379 | if self.obj.url.is_none() { 380 | return Err(ComponentBuilderError::LinkWithoutUrl); 381 | } 382 | } 383 | _ => { 384 | if self.obj.custom_id.is_none() { 385 | return Err(ComponentBuilderError::NoCustomId); 386 | } 387 | } 388 | } 389 | Ok(self.obj) 390 | } 391 | } 392 | 393 | #[cfg(feature = "builder")] 394 | #[derive(Clone, Debug, Default)] 395 | /// Builder pattern for creating menu components. 396 | pub struct ComponentSelectMenuBuilder { 397 | obj: ComponentSelectMenu, 398 | } 399 | 400 | #[cfg(feature = "builder")] 401 | impl ComponentSelectMenuBuilder { 402 | /// The custom developer identifier. **SETTING THIS IS MANDATORY!** 403 | pub fn custom_id(mut self, id: impl Into) -> Self { 404 | self.obj.custom_id = id.into(); 405 | self 406 | } 407 | 408 | /// custom placeholder text if nothing is selected, max 100 characters 409 | pub fn placeholder(mut self, ph: impl Into) -> Self { 410 | let p = ph.into(); 411 | 412 | if p.chars().count() > 100 { 413 | warn!("Menu placeholder exceeded 100 characters, ignoring"); 414 | return self; 415 | } 416 | self.obj.placeholder = Some(p); 417 | self 418 | } 419 | 420 | /// Add a menu choice 421 | pub fn add_option(mut self, opt: ComponentSelectOption) -> Self { 422 | if self.obj.options.len() >= 25 { 423 | warn!("This menu already contains 25 elements, ignoring"); 424 | return self; 425 | } 426 | self.obj.options.push(opt); 427 | self 428 | } 429 | 430 | /// The minimum number of items that must be chosen; default 1, min 0, max 25 431 | pub fn min_values(mut self, min: impl Into) -> Self { 432 | self.obj.min_values = min.into(); 433 | self 434 | } 435 | 436 | /// the maximum number of items that can be chosen; default 1, max 25 437 | pub fn max_values(mut self, max: impl Into) -> Self { 438 | self.obj.max_values = max.into(); 439 | self 440 | } 441 | } 442 | 443 | #[cfg(feature = "builder")] 444 | #[derive(Debug)] 445 | /// Represents an error that occurred when building a ComponentSelectMenu 446 | pub enum ComponentSelectMenuBuilderError { 447 | /// There was no Custom ID supplied with this menu 448 | EmptyCustomId, 449 | /// There were over 25 options supplied for this menu 450 | Over25MenuOptions, 451 | /// There were over 25 min_values supplied for this menu 452 | Over25MinValues, 453 | /// There were over 25 max_values supplied for this menu 454 | Over25MaxValues, 455 | } 456 | 457 | #[cfg(feature = "builder")] 458 | impl Display for ComponentSelectMenuBuilderError { 459 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 460 | match self { 461 | ComponentSelectMenuBuilderError::EmptyCustomId => write!(f, "custom_id is empty"), 462 | ComponentSelectMenuBuilderError::Over25MenuOptions => { 463 | write!(f, "over 25 menu options supplied") 464 | } 465 | ComponentSelectMenuBuilderError::Over25MinValues => { 466 | write!(f, "over 25 min_values options supplied") 467 | } 468 | ComponentSelectMenuBuilderError::Over25MaxValues => { 469 | write!(f, "over 25 max_values options supplied") 470 | } 471 | } 472 | } 473 | } 474 | 475 | #[cfg(feature = "builder")] 476 | impl error::Error for ComponentSelectMenuBuilderError {} 477 | 478 | #[cfg(feature = "builder")] 479 | impl Builder for ComponentSelectMenuBuilder { 480 | type Error = ComponentSelectMenuBuilderError; 481 | 482 | fn build(self) -> Result { 483 | if self.obj.custom_id.is_empty() { 484 | return Err(ComponentSelectMenuBuilderError::EmptyCustomId); 485 | } 486 | if self.obj.options.len() > 25 { 487 | return Err(ComponentSelectMenuBuilderError::Over25MenuOptions); 488 | } 489 | if self.obj.min_values > 25 { 490 | return Err(ComponentSelectMenuBuilderError::Over25MinValues); 491 | } 492 | if self.obj.max_values > 25 { 493 | return Err(ComponentSelectMenuBuilderError::Over25MaxValues); 494 | } 495 | Ok(self.obj) 496 | } 497 | } 498 | -------------------------------------------------------------------------------- /src/types/embed.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use ::chrono::{DateTime, Utc}; 4 | use serde_with::*; 5 | 6 | #[cfg(feature = "builder")] 7 | use crate::Builder; 8 | #[cfg(feature = "builder")] 9 | use log::warn; 10 | // ======== Structures ========= 11 | #[serde_as] 12 | #[skip_serializing_none] 13 | #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] 14 | /// An embed in Discord is a way to display rich content in messages 15 | pub struct Embed { 16 | /// Title of the embed 17 | pub title: Option, 18 | /// Description of the embed 19 | pub description: Option, 20 | // Type field is not implemented since it's considered deprecated 21 | /// url of embed 22 | pub url: Option, 23 | #[serde(default)] 24 | /// Timestamp of embed content 25 | pub timestamp: Option>, 26 | /// Color code of embed 27 | pub color: Option, 28 | /// Footer information 29 | pub footer: Option, 30 | /// Image information 31 | pub image: Option, 32 | /// Thumbnail information 33 | pub thumbnail: Option, 34 | /// Video information 35 | pub video: Option, 36 | /// Provider information 37 | pub provider: Option, 38 | /// Author information 39 | pub author: Option, 40 | /// Fields of the embed 41 | pub fields: Option>, 42 | } 43 | 44 | #[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)] 45 | /// Representing a Thumbnail for an [`Embed`] 46 | pub struct EmbedThumbnail { 47 | /// Url of the thumbnail 48 | pub url: Option, 49 | /// Proxied url of the thumbnail 50 | pub proxy_url: Option, 51 | /// Height of the image 52 | pub height: Option, 53 | /// Width of the image 54 | pub width: Option, 55 | } 56 | 57 | #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] 58 | /// Representing video information in an [`Embed`] 59 | pub struct EmbedVideo { 60 | url: String, 61 | proxy_url: String, 62 | height: i32, 63 | witdh: i32, 64 | } 65 | 66 | #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Default)] 67 | /// Representing image information in an [`Embed`] 68 | pub struct EmbedImage { 69 | pub url: String, 70 | #[serde(skip_serializing_if = "Option::is_none")] 71 | pub proxy_url: Option, 72 | pub height: i32, 73 | pub width: i32, 74 | } 75 | 76 | #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] 77 | /// Representing provider information in an [`Embed`] 78 | pub struct EmbedProvider { 79 | name: String, 80 | url: String, 81 | } 82 | 83 | #[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)] 84 | /// Representing the author of an [`Embed`] 85 | pub struct EmbedAuthor { 86 | /// Name of author 87 | pub name: Option, 88 | /// Url of author 89 | pub url: Option, 90 | /// Url of author icon (only supports http(s) and attachments) 91 | pub icon_url: Option, 92 | /// A proxied url of author icon 93 | pub proxy_icon_url: Option, 94 | } 95 | 96 | #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] 97 | /// Representing the footer of an [`Embed`] 98 | pub struct EmbedFooter { 99 | /// Footer text 100 | text: String, 101 | /// Url of footer icon (only supports http(s) and attachments) 102 | icon_url: Option, 103 | /// A proxied url of footer icon 104 | proxy_icon_url: Option, 105 | } 106 | 107 | #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] 108 | /// Representing a field of an [`Embed`] 109 | pub struct EmbedField { 110 | /// Name of the field 111 | name: String, 112 | /// Value of the field 113 | value: String, 114 | /// Whether or not this field should display inline 115 | #[serde(skip_serializing_if = "Option::is_none")] 116 | inline: Option, 117 | } 118 | #[cfg(feature = "builder")] 119 | #[derive(Clone, Default, Debug, PartialEq)] 120 | /// Builder to construct an [`Embed`] 121 | pub struct EmbedBuilder { 122 | obj: Embed, 123 | } 124 | #[derive(Clone, Copy, Debug, PartialEq)] 125 | /// Representing RGB colors. 126 | /// 127 | /// Each color is an 8bit unsigned integer. 128 | pub struct Color { 129 | /// Red value 130 | pub red: u8, 131 | /// Green value 132 | pub green: u8, 133 | /// Blue value 134 | pub blue: u8, 135 | } 136 | 137 | // ========== IMPLS =========== 138 | impl Default for Color { 139 | fn default() -> Self { 140 | Color { 141 | // ;) 142 | red: 222, 143 | green: 165, 144 | blue: 132, 145 | } 146 | } 147 | } 148 | 149 | impl From for Color { 150 | fn from(a: u32) -> Color { 151 | Color { 152 | red: ((a >> 16) & 0xff) as u8, 153 | green: ((a >> 8) & 0xff) as u8, 154 | blue: (a & 0xff) as u8, 155 | } 156 | } 157 | } 158 | 159 | impl From for u32 { 160 | fn from(c: Color) -> u32 { 161 | ((c.red as u32) << 16) | ((c.green as u32) << 8) | c.blue as u32 162 | } 163 | } 164 | 165 | impl Default for Embed { 166 | fn default() -> Self { 167 | Self { 168 | title: None, 169 | description: None, 170 | url: None, 171 | timestamp: None, 172 | color: Some(Color::default().into()), 173 | footer: None, 174 | image: None, 175 | thumbnail: None, 176 | video: None, 177 | provider: None, 178 | author: None, 179 | fields: None, 180 | } 181 | } 182 | } 183 | #[cfg(feature = "builder")] 184 | impl EmbedBuilder { 185 | /// Set the title of this embed 186 | pub fn title(mut self, title: impl ToString) -> Self { 187 | let t = title.to_string(); 188 | // wish this could be checked at compile time :( 189 | if t.len() > 256 { 190 | panic!("Embed title length is more than 256 characters.") 191 | } 192 | self.obj.title = Some(t); 193 | self 194 | } 195 | 196 | /// Add a description to the embed 197 | pub fn description(mut self, description: impl ToString) -> Self { 198 | let d = description.to_string(); 199 | 200 | self.obj.description = Some(d); 201 | self 202 | } 203 | 204 | /// Set the url of the title of this embed 205 | pub fn url(mut self, url: &str) -> Self { 206 | self.obj.url = Some(String::from(url)); 207 | self 208 | } 209 | /// Set the color of this embed 210 | pub fn color(mut self, color: impl Into) -> Self { 211 | self.obj.color = Some(color.into()); 212 | self 213 | } 214 | 215 | /// Set the timestamp of this embed 216 | pub fn timestamp(mut self, timestamp: DateTime) -> Self { 217 | self.obj.timestamp = Some(timestamp); 218 | self 219 | } 220 | 221 | /// Set the embed's footer 222 | pub fn footer(mut self, a: EmbedFooter) -> Self { 223 | self.obj.footer = Some(a); 224 | self 225 | } 226 | 227 | /// Set the embed author 228 | pub fn author(mut self, author: EmbedAuthor) -> Self { 229 | self.obj.author = Some(author); 230 | self 231 | } 232 | 233 | pub fn image(mut self, image: EmbedImage) -> Self { 234 | self.obj.image = Some(image); 235 | self 236 | } 237 | 238 | pub fn thumbnail(mut self, thumbnail: EmbedThumbnail) -> Self { 239 | self.obj.thumbnail = Some(thumbnail); 240 | self 241 | } 242 | 243 | /// Add an [`EmbedField`] to this embed. 244 | pub fn add_field(mut self, field: EmbedField) -> Self { 245 | match self.obj.fields { 246 | None => { 247 | let nf = Some(vec![field]); 248 | self.obj.fields = nf; 249 | } 250 | Some(ref mut f) => { 251 | if f.len() >= 25 { 252 | warn!("Field limit reached. Ignoring"); 253 | } else { 254 | f.push(field); 255 | } 256 | } 257 | } 258 | self 259 | } 260 | 261 | #[deprecated(since = "0.1.9", note = "Use the `build()` function instead")] 262 | /// Build the embed. You can't use the function after this anymore 263 | pub fn finish(self) -> Embed { 264 | self.obj 265 | } 266 | } 267 | 268 | #[cfg(feature = "builder")] 269 | impl Builder for EmbedBuilder { 270 | type Error = std::convert::Infallible; 271 | 272 | fn build(self) -> Result { 273 | Ok(self.obj) 274 | } 275 | } 276 | 277 | impl Default for EmbedFooter { 278 | fn default() -> Self { 279 | Self { 280 | text: String::from(""), 281 | icon_url: None, 282 | proxy_icon_url: None, 283 | } 284 | } 285 | } 286 | 287 | impl EmbedFooter { 288 | /// Set the footers text 289 | pub fn text(mut self, text: impl ToString) -> Self { 290 | let t = text.to_string(); 291 | if t.len() > 2048 { 292 | panic!("Footer text exceeded 2048 characters") 293 | } 294 | self.text = t; 295 | self 296 | } 297 | 298 | /// Sets the url to the footer icon 299 | pub fn icon_url(mut self, url: impl ToString) -> Self { 300 | let n = url.to_string(); 301 | 302 | self.icon_url = Some(n); 303 | self 304 | } 305 | 306 | /// Sets a proxied url to the footer icon 307 | pub fn proxy_url(mut self, url: impl ToString) -> Self { 308 | let u = url.to_string(); 309 | 310 | self.proxy_icon_url = Some(u); 311 | self 312 | } 313 | } 314 | 315 | impl Default for EmbedField { 316 | fn default() -> Self { 317 | Self { 318 | value: String::from(""), 319 | name: String::from(""), 320 | inline: None, 321 | } 322 | } 323 | } 324 | 325 | impl EmbedField { 326 | /// Set the field name 327 | pub fn name(mut self, name: impl ToString) -> Self { 328 | let n = name.to_string(); 329 | if n.len() > 256 { 330 | panic!("Field name is above 256 characters.") 331 | } 332 | self.name = n; 333 | self 334 | } 335 | 336 | /// Set the text of this field 337 | pub fn value(mut self, text: impl ToString) -> Self { 338 | let t = text.to_string(); 339 | 340 | if t.len() > 1024 { 341 | panic!("Field value is above 1024 characters") 342 | } 343 | self.value = t; 344 | self 345 | } 346 | /// Set if the field should display inline 347 | pub fn inline(mut self, inline: bool) -> Self { 348 | self.inline = Some(inline); 349 | self 350 | } 351 | } 352 | 353 | impl EmbedAuthor { 354 | /// Set the author name 355 | pub fn name(mut self, name: impl ToString) -> Self { 356 | let n = name.to_string(); 357 | self.name = Some(n); 358 | self 359 | } 360 | 361 | /// Sets the URL users can click on. 362 | pub fn url(mut self, url: impl ToString) -> Self { 363 | let n = url.to_string(); 364 | self.url = Some(n); 365 | self 366 | } 367 | 368 | /// Add an icon to the embed 369 | pub fn icon_url(mut self, url: impl ToString) -> Self { 370 | let u = url.to_string(); 371 | 372 | self.icon_url = Some(u); 373 | self 374 | } 375 | 376 | /// Set the proxy url for the icon 377 | pub fn proxy_url(mut self, url: impl ToString) -> Self { 378 | let u = url.to_string(); 379 | 380 | self.proxy_icon_url = Some(u); 381 | self 382 | } 383 | } 384 | 385 | impl EmbedThumbnail { 386 | /// Sets the URL of the thumbnail 387 | pub fn url(mut self, url: impl ToString) -> Self { 388 | let u = url.to_string(); 389 | self.url = Some(u); 390 | self 391 | } 392 | 393 | /// Sets a proxied url for the thumbnail 394 | pub fn proxy_url(mut self, url: impl ToString) -> Self { 395 | let u = url.to_string(); 396 | self.url = Some(u); 397 | self 398 | } 399 | 400 | /// Sets the dimensions of the thumbnail 401 | pub fn dimensions(mut self, height: impl Into, width: impl Into) -> Self { 402 | let x = width.into(); 403 | let y = height.into(); 404 | 405 | self.height = Some(y); 406 | self.width = Some(x); 407 | 408 | self 409 | } 410 | } 411 | -------------------------------------------------------------------------------- /src/types/guild.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use serde_with::*; 3 | 4 | use super::Snowflake; 5 | 6 | #[serde_as] 7 | #[skip_serializing_none] 8 | #[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] 9 | /// A guild (also known as a 'server') in Discord 10 | pub struct Guild { 11 | /// The ID of the guild 12 | #[serde_as(as = "DisplayFromStr")] 13 | pub id: Snowflake, 14 | /// Name of this guild 15 | pub name: String, 16 | 17 | /// Icon hash 18 | pub icon: Option, 19 | /// Icon hash, returned when in the template object 20 | pub icon_hash: Option, 21 | 22 | /// Splash hash 23 | pub splash: Option, 24 | /// discovery splash hash; only present for guilds with the `DISCOVERABLE` feature 25 | pub discovery_splash: Option, 26 | /// id of owner 27 | #[serde_as(as = "DisplayFromStr")] 28 | pub owner_id: Snowflake, 29 | /// voice region id for the guild 30 | pub region: String, 31 | #[serde_as(as = "Option")] 32 | #[serde(default)] 33 | /// Id of afk channel 34 | pub afk_channel_id: Option, 35 | /// AFK timeout in seconds 36 | pub afk_timeout: u32, 37 | /// true if widget is enabled 38 | pub widget_enabled: Option, 39 | /// The channel id that the widget will generate an invite to, or null if set to no invite 40 | #[serde_as(as = "Option")] 41 | #[serde(default)] 42 | pub widget_channel_id: Option, 43 | /// [Verfication level](https://discord.com/developers/docs/resources/guild#guild-object-verification-level) required for the guild 44 | pub verfication_level: Option, 45 | /// default [message notifications level](https://discord.com/developers/docs/resources/guild#guild-object-default-message-notification-level) 46 | pub default_message_notifications: Option, 47 | /// [Explicit content filter level](https://discord.com/developers/docs/resources/guild#guild-object-explicit-content-filter-level) 48 | pub explicit_content_filter: Option, 49 | 50 | /// Roles in this guild 51 | pub roles: Vec, 52 | 53 | /// The required [MFA level](https://discord.com/developers/docs/resources/guild#guild-object-mfa-level) in this guild 54 | pub mfa_level: u8, 55 | 56 | #[serde_as(as = "Option")] 57 | #[serde(default)] 58 | /// application id of the guild creator if it is bot-created 59 | pub application_id: Option, 60 | #[serde_as(as = "Option")] 61 | #[serde(default)] 62 | /// the id of the channel where guild notices such as welcome messages and boost events are posted 63 | pub system_channel_id: Option, 64 | /// system channel flags 65 | pub system_channel_flags: Option, 66 | /// the id of the channel where Community guilds can display rules and/or guidelines 67 | pub rules_channel_id: Option, 68 | 69 | /// the vanity url code for the guild 70 | pub vanity_url_code: Option, 71 | /// the description for the guild, if the guild is discoverable 72 | pub description: Option, 73 | 74 | /// premium tier (Server Boost level) 75 | pub premium_tier: u8, 76 | /// the number of boosts this guild currently has 77 | pub premium_tier_subscription_count: Option, 78 | 79 | /// the preferred locale of a Community guild; used in server discovery and notices from Discord; defaults to "en-US" 80 | pub preffered_locale: Option, 81 | /// the maximum amount of users in a video channel 82 | pub max_video_channel_users: Option, 83 | /// approximate number of members in this guild 84 | pub approximate_member_count: u32, 85 | /// approximate number of non-offline members in this guild 86 | pub approximate_presence_count: u32, 87 | /// true if this guild is [designated as NSFW](https://support.discord.com/hc/en-us/articles/1500005389362-NSFW-Server-Designation) 88 | pub nsfw: bool, 89 | } 90 | 91 | impl From for Snowflake { 92 | fn from(g: Guild) -> Snowflake { 93 | g.id 94 | } 95 | } 96 | 97 | #[serde_as] 98 | #[skip_serializing_none] 99 | /// A role is a way to group people in a Guild and assign certain permissions to them. 100 | #[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] 101 | pub struct Role { 102 | #[serde_as(as = "DisplayFromStr")] 103 | /// The id of the role 104 | pub id: Snowflake, 105 | 106 | /// The name of the role 107 | pub name: String, 108 | /// Role color 109 | pub color: u32, 110 | 111 | /// If this role is pinned in the user listing 112 | pub hoist: bool, 113 | 114 | /// Position of the role 115 | pub position: u16, 116 | 117 | /// Permission bit set 118 | pub permissions: String, 119 | 120 | /// Whether this role is managed by an integration 121 | pub managed: bool, 122 | 123 | /// Whether this role is mentionable 124 | pub mentionable: bool, 125 | 126 | /// The tags this role has 127 | pub tags: Option, 128 | } 129 | #[serde_as] 130 | #[skip_serializing_none] 131 | /// Role tags 132 | #[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] 133 | pub struct RoleTag { 134 | #[serde_as(as = "Option")] 135 | #[serde(default)] 136 | /// the id of the bot this role belongs to 137 | pub bot_id: Option, 138 | #[serde_as(as = "Option")] 139 | #[serde(default)] 140 | /// the id of the integration this role belongs to 141 | pub integration_id: Option, 142 | /// whether this is the guild's premium subscriber role. 143 | pub premium_subscriber: Option, 144 | } 145 | -------------------------------------------------------------------------------- /src/types/interaction.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "extended-handler")] 2 | use crate::expect_successful_api_response_and_return; 3 | 4 | #[cfg(feature = "handler")] 5 | use crate::{expect_specific_api_response, expect_successful_api_response}; 6 | 7 | use serde::{Deserialize, Serialize}; 8 | 9 | use serde_with::*; 10 | 11 | use serde_repr::*; 12 | 13 | use super::application::*; 14 | use super::components::*; 15 | use super::embed::*; 16 | #[cfg(feature = "extended-handler")] 17 | use super::guild::*; 18 | use super::user::*; 19 | #[cfg(feature = "handler")] 20 | use super::HttpError; 21 | use super::Snowflake; 22 | #[cfg(feature = "handler")] 23 | use ::chrono::{DateTime, Utc}; 24 | #[cfg(feature = "handler")] 25 | use log::{debug, error}; 26 | #[cfg(any(feature = "handler", feature = "extended-handler"))] 27 | use reqwest::{Client, StatusCode}; 28 | 29 | // ====================== 30 | 31 | #[cfg(any(feature = "handler", feature = "extended-handler"))] 32 | #[derive(Clone, Debug)] 33 | /// A context contains relevant information and useful functions you can use when handling Interactions. 34 | pub struct Context { 35 | client: Client, 36 | 37 | /// Resolved user ID of author 38 | pub author_id: Option, 39 | 40 | /// The [`Interaction`] sent by Discord. 41 | pub interaction: Interaction, 42 | } 43 | 44 | #[serde_as] 45 | #[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] 46 | /// The base Interaction structure. When Interactions are received, this structure is wrapped inside a [`Context`] 47 | /// and can be used to get information about the Interaction. 48 | pub struct Interaction { 49 | #[serde_as(as = "Option")] 50 | #[serde(default)] 51 | /// The application id of your applicaton 52 | pub application_id: Option, 53 | 54 | #[serde_as(as = "Option")] 55 | #[serde(default)] 56 | /// Unique id identifying the interaction 57 | pub id: Option, 58 | /// The type of interaction 59 | pub r#type: InteractionType, 60 | /// Interaction data, if applicable 61 | pub data: Option, 62 | #[serde_as(as = "Option")] 63 | #[serde(default)] 64 | /// The ID of the guild where the Interaction took place (None if in DM) 65 | pub guild_id: Option, 66 | #[serde_as(as = "Option")] 67 | #[serde(default)] 68 | /// The channel ID where the Interaction took place 69 | pub channel_id: Option, 70 | /// The [`Member`] who invoked the command (None if in DM, use [`User`] instead) 71 | pub member: Option, 72 | /// The [`User`] who invoked the command (None if in guild, use [`Member`] instead) 73 | pub user: Option, 74 | /// Unique token used for editing messages and managing follow-up messages 75 | pub token: Option, 76 | /// The locale the client is set to. 77 | pub locale: Option, 78 | /// Read-only. Always `1` 79 | pub version: Option, 80 | } 81 | 82 | #[derive(Clone, Serialize_repr, Deserialize_repr, PartialEq, Debug)] 83 | #[repr(u8)] 84 | #[non_exhaustive] 85 | /// Represents the type of interaction that comes in. 86 | pub enum InteractionType { 87 | /// Discord requested a ping 88 | Ping = 1, 89 | /// A slash command 90 | ApplicationCommand = 2, 91 | 92 | /// A message component 93 | MessageComponent = 3, 94 | } 95 | 96 | #[serde_as] 97 | #[skip_serializing_none] 98 | #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] 99 | /// Struct repesenting an Interaction response 100 | /// 101 | /// This is used to respond to incoming interactions. 102 | pub struct InteractionResponse { 103 | /// Type of response 104 | pub r#type: InteractionResponseType, 105 | 106 | /// Optional data field 107 | pub data: Option, 108 | } 109 | 110 | #[cfg(feature = "handler")] 111 | #[derive(Clone, Debug)] 112 | /// Builder for making a [`InteractionResponse`] 113 | 114 | pub struct InteractionResponseBuilder { 115 | #[doc(hidden)] 116 | pub r#type: InteractionResponseType, 117 | #[doc(hidden)] 118 | pub data: Option, 119 | } 120 | 121 | impl InteractionResponse { 122 | /// Creates a new InteractionResponse 123 | pub fn new( 124 | rtype: InteractionResponseType, 125 | data: Option, 126 | ) -> InteractionResponse { 127 | InteractionResponse { 128 | r#type: rtype, 129 | data, 130 | } 131 | } 132 | } 133 | 134 | #[cfg(feature = "handler")] 135 | impl Default for InteractionResponseBuilder { 136 | /// This will default to responding with the `InteractionResponseType::CHANNEL_MESSAGE_WITH_SOURCE` response type and no data. 137 | /// Adding data yourself is expected. 138 | fn default() -> Self { 139 | Self { 140 | r#type: InteractionResponseType::ChannelMessageWithSource, 141 | data: None, 142 | } 143 | } 144 | } 145 | 146 | #[cfg(feature = "handler")] 147 | impl InteractionResponseBuilder { 148 | fn ret(self) -> InteractionResponse { 149 | InteractionResponse { 150 | r#type: self.r#type, 151 | data: self.data, 152 | } 153 | } 154 | 155 | /// Return a pong with no data. Use with caution 156 | pub fn pong(mut self) -> InteractionResponse { 157 | self.r#type = InteractionResponseType::Pong; 158 | self.data = None; 159 | self.finish() 160 | } 161 | 162 | /// Return without any data. Use with caution 163 | pub fn none(mut self) -> InteractionResponse { 164 | self.r#type = InteractionResponseType::None; 165 | self.data = None; 166 | self.finish() 167 | } 168 | 169 | /// Sets the [`InteractionResponseType`] 170 | pub fn respond_type(mut self, t: InteractionResponseType) -> Self { 171 | self.r#type = t; 172 | self 173 | } 174 | 175 | /// Fills the [`InteractionResponse`] with some [`InteractionApplicationCommandCallbackData`] 176 | /// This returns an `InteractionResponse` and consumes itself. 177 | pub fn data(mut self, d: &InteractionApplicationCommandCallbackData) -> InteractionResponse { 178 | self.data = Some(d.clone()); 179 | self.ret() 180 | } 181 | 182 | /// Sets the Text-To-Speech value of this `InteractionResponse`. 183 | pub fn tts(mut self, enable: &bool) -> Self { 184 | // Does data exist? 185 | if self.data.is_none() { 186 | let mut d = InteractionApplicationCommandCallbackData::new(); 187 | d.tts = Some(*enable); 188 | self.data = Some(d); 189 | } else { 190 | self.data.as_mut().unwrap().tts = Some(*enable); 191 | } 192 | self 193 | } 194 | 195 | /// This sets the `content` for an `InteractionResponse` 196 | pub fn content(mut self, c: impl ToString) -> Self { 197 | match self.data.as_mut() { 198 | None => { 199 | let mut d = InteractionApplicationCommandCallbackData::new(); 200 | d.content = Some(c.to_string()); 201 | self.data = Some(d); 202 | } 203 | Some(d) => { 204 | d.content = Some(c.to_string()); 205 | } 206 | } 207 | self 208 | } 209 | 210 | /// Sets the `content` for an `InteractionResponse`. Alias for `content()` 211 | pub fn message(self, c: impl ToString) -> Self { 212 | self.content(c) 213 | } 214 | 215 | /// Sets the 'ephermeral' message flag. This will cause the message to show for its recipient only. 216 | pub fn is_ephemeral(mut self, e: bool) -> Self { 217 | match self.data.as_mut() { 218 | None => { 219 | let mut d = InteractionApplicationCommandCallbackData::new(); 220 | if e { 221 | d.flags = Some(1 << 6); 222 | } 223 | self.data = Some(d); 224 | } 225 | Some(d) => { 226 | if let Some(mut flag) = d.flags { 227 | if e { 228 | flag |= 1 << 6; 229 | } else { 230 | flag |= 0 << 6; 231 | } 232 | d.flags = Some(flag); 233 | } else if e { 234 | d.flags = Some(1 << 6); 235 | } else { 236 | d.flags = Some(0); 237 | } 238 | } 239 | } 240 | 241 | self 242 | } 243 | 244 | /// Add an [`Embed`] to the response. 245 | /// You can add up to 10 embeds. 246 | pub fn add_embed(mut self, e: &Embed) -> Self { 247 | match self.data.as_mut() { 248 | None => { 249 | let mut d = InteractionApplicationCommandCallbackData::new(); 250 | d.embeds = Some(vec![e.clone()]); 251 | self.data = Some(d); 252 | } 253 | Some(d) => { 254 | if d.embeds.is_none() { 255 | d.embeds = Some(vec![e.clone()]); 256 | } else { 257 | let v = d.embeds.as_mut().unwrap(); 258 | // Check if this will exceed the embed limit 259 | if v.len() <= 9 { 260 | v.push(e.clone()); 261 | } else { 262 | // Log an error for now. 263 | error!("Tried to add embed while embed limit (max. 10 embeds) was already reached. Ignoring") 264 | } 265 | } 266 | } 267 | } 268 | self 269 | } 270 | 271 | /// Add components to response 272 | pub fn add_component_row(mut self, comp: impl Into) -> Self { 273 | let component = comp.into(); 274 | match self.data.as_mut() { 275 | None => { 276 | let mut d = InteractionApplicationCommandCallbackData::new(); 277 | d.components = Some(vec![component]); 278 | self.data = Some(d); 279 | } 280 | Some(d) => { 281 | if d.components.is_none() { 282 | d.components = Some(vec![component]); 283 | } else { 284 | let comp = d.components.as_mut().unwrap(); 285 | 286 | comp.push(component); 287 | } 288 | } 289 | } 290 | self 291 | } 292 | 293 | /// Returns an `InteractionResponse`, consuming itself. 294 | /// You can't use the builder anymore after you called this function. 295 | pub fn finish(self) -> InteractionResponse { 296 | self.ret() 297 | } 298 | } 299 | 300 | #[derive(Clone, Serialize_repr, Deserialize_repr, Debug, PartialEq)] 301 | #[repr(u8)] 302 | 303 | /// Representing the type of response to an [`Interaction`] 304 | pub enum InteractionResponseType { 305 | /// Non-standard None type. Should not be manually used 306 | None = 0, 307 | 308 | /// ACK a PING 309 | Pong = 1, 310 | 311 | /// Respond to an [`Interaction`] with a message 312 | ChannelMessageWithSource = 4, 313 | /// ACK an interaction and edit a response later, the user sees a loading state 314 | DefferedChannelMessageWithSource = 5, 315 | 316 | /// For components, ACK an interaction and edit the original message later. The user does not see a loading state 317 | DefferedUpdateMessage = 6, 318 | 319 | /// For components, edit the message the component was attached to 320 | UpdateMessage = 7, 321 | } 322 | 323 | #[serde_as] 324 | #[skip_serializing_none] 325 | #[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)] 326 | /// Representing the data used to respond to an [`Interaction`] 327 | pub struct InteractionApplicationCommandCallbackData { 328 | tts: Option, 329 | content: Option, 330 | embeds: Option>, 331 | allowed_mentions: Option, 332 | flags: Option, 333 | components: Option>, 334 | } 335 | 336 | impl InteractionApplicationCommandCallbackData { 337 | /// Creates a new [`InteractionApplicationCommandCallbackData`] 338 | pub fn new() -> Self { 339 | Self::default() 340 | } 341 | } 342 | 343 | #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] 344 | /// Representing the allowed mention type 345 | pub enum AllowedMentionTypes { 346 | /// Role mentions 347 | Roles, 348 | /// User mentions 349 | Users, 350 | /// @everyone mentions 351 | Everyone, 352 | } 353 | 354 | #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Default)] 355 | /// Representing the AllowedMentions data model 356 | pub struct AllowedMentions { 357 | pub parse: Vec, 358 | pub roles: Vec, 359 | pub users: Vec, 360 | pub replied_user: bool, 361 | } 362 | #[serde_as] 363 | #[skip_serializing_none] 364 | #[derive(Clone, Default, Serialize, Deserialize, Debug)] 365 | /// Representing a webhook message 366 | pub struct WebhookMessage { 367 | pub username: Option, 368 | pub avatar_url: Option, 369 | /// The message contents 370 | pub content: Option, 371 | /// Embeds in the message (max 10) 372 | pub embeds: Option>, 373 | /// Components in the message 374 | pub components: Option>, 375 | /// Used for files. 376 | pub payload_json: Option, 377 | pub allowed_mentions: Option, 378 | } 379 | #[cfg(feature = "handler")] 380 | impl WebhookMessage { 381 | /// Add text to this WebhookMessage 382 | pub fn content(mut self, content: impl ToString) -> Self { 383 | self.content = Some(content.to_string()); 384 | self 385 | } 386 | 387 | /// Add an embed to this WebhookMessage 388 | pub fn add_embed(mut self, embed: Embed) -> Self { 389 | match self.embeds.as_mut() { 390 | None => { 391 | self.embeds = Some(vec![embed]); 392 | } 393 | Some(e) => { 394 | // Check if this will exceed the embed limit 395 | if e.len() <= 9 { 396 | e.push(embed); 397 | } else { 398 | // Log an error for now. 399 | error!("Tried to add embed while embed limit (max. 10 embeds) was already reached. Ignoring") 400 | } 401 | } 402 | } 403 | self 404 | } 405 | } 406 | 407 | impl From for WebhookMessage { 408 | fn from(o: InteractionResponse) -> WebhookMessage { 409 | let data = o.data.unwrap(); 410 | 411 | WebhookMessage { 412 | content: data.content, 413 | embeds: data.embeds, 414 | components: data.components, 415 | ..Default::default() 416 | } 417 | } 418 | } 419 | 420 | #[serde_as] 421 | #[skip_serializing_none] 422 | #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] 423 | /// Reference to a message. Contains useful identifiers. 424 | pub struct MessageReference { 425 | #[serde_as(as = "DisplayFromStr")] 426 | message_id: Snowflake, 427 | #[serde_as(as = "Option")] 428 | #[serde(default)] 429 | guild_id: Option, 430 | #[serde_as(as = "Option")] 431 | #[serde(default)] 432 | channel_id: Option, 433 | } 434 | 435 | impl MessageReference { 436 | /// Get the message id of this message 437 | pub fn message_id(&self) -> Snowflake { 438 | self.message_id 439 | } 440 | /// Get the guild id of this message 441 | /// 442 | /// `None` if message is in DM 443 | pub fn guild_id(&self) -> Option { 444 | self.guild_id 445 | } 446 | 447 | /// Get the channel ID of this message 448 | /// 449 | /// `None` if message is in DM 450 | pub fn channel_id(&self) -> Option { 451 | self.channel_id 452 | } 453 | } 454 | #[cfg(feature = "handler")] 455 | #[serde_as] 456 | #[skip_serializing_none] 457 | #[derive(Clone, Serialize, Deserialize, Debug)] 458 | /// Read-only struct representing a Followup message sent by some application. 459 | pub struct FollowupMessage { 460 | #[serde_as(as = "DisplayFromStr")] 461 | id: Snowflake, 462 | r#type: u8, 463 | content: Option, 464 | embeds: Vec, 465 | #[serde_as(as = "Option")] 466 | #[serde(default)] 467 | channel_id: Option, 468 | author: Option, 469 | tts: bool, 470 | #[serde_as(as = "DisplayFromStr")] 471 | timestamp: DateTime, 472 | #[serde_as(as = "Option")] 473 | #[serde(default)] 474 | edited_timestamp: Option>, 475 | flags: u32, 476 | #[serde_as(as = "DisplayFromStr")] 477 | application_id: Snowflake, 478 | #[serde_as(as = "DisplayFromStr")] 479 | webhook_id: Snowflake, 480 | message_reference: MessageReference, 481 | 482 | #[serde(skip)] 483 | interaction_token: String, 484 | #[serde(skip)] 485 | client: Client, 486 | } 487 | #[cfg(feature = "handler")] 488 | /// Getter functions 489 | impl FollowupMessage { 490 | /// Get the ID of this follow up 491 | pub fn id(&self) -> Snowflake { 492 | self.id 493 | } 494 | /// Get the type of message of this follow up 495 | pub fn get_type(&self) -> u8 { 496 | self.r#type 497 | } 498 | 499 | /// Get the embeds of this follow up 500 | pub fn embeds(&self) -> Vec { 501 | self.embeds.clone() 502 | } 503 | 504 | /// Gets the contents of this followup message 505 | pub fn get_content(&self) -> Option { 506 | self.content.clone() 507 | } 508 | /// Get the creation time of this followup message 509 | pub fn timestamp(&self) -> DateTime { 510 | self.timestamp 511 | } 512 | /// Get the time when this message was edited 513 | /// 514 | /// `None` if message was never edited 515 | pub fn edited_timestamp(&self) -> Option> { 516 | self.edited_timestamp 517 | } 518 | 519 | /// Get the message flags of this message 520 | pub fn flags(&self) -> u32 { 521 | self.flags 522 | } 523 | 524 | /// Get the application id of the application that made this message 525 | pub fn app_id(&self) -> Snowflake { 526 | self.application_id 527 | } 528 | 529 | /// Get the webhook id associated with this message 530 | pub fn webhook_id(&self) -> Snowflake { 531 | self.webhook_id 532 | } 533 | 534 | /// Get the message reference of this message 535 | /// 536 | /// The [`MessageReference`] contains the message ID, aswell as the channel and guild id. 537 | pub fn message_reference(&self) -> MessageReference { 538 | self.message_reference.clone() 539 | } 540 | } 541 | 542 | #[cfg(feature = "handler")] 543 | /// 'Do' functions 544 | impl FollowupMessage { 545 | /// Edit this followup message 546 | pub async fn edit_message(&mut self, new_content: &WebhookMessage) -> Result<(), HttpError> { 547 | let url = format!( 548 | "/webhooks/{:?}/{:?}/messages/{:?}", 549 | self.application_id, self.interaction_token, self.id 550 | ); 551 | 552 | let exec = self.client.post(&url).json(new_content).send().await; 553 | 554 | expect_successful_api_response!(exec, { 555 | // TODO: Update edited fields 556 | Ok(()) 557 | }) 558 | } 559 | 560 | /// Delete this followup message. 561 | /// 562 | /// If the deletion succeeded, you'll get an `Ok(())`. However, if this somehow fails, it will return `Err(Self)`. 563 | /// That means that if the deletion did not succeed, this reference does not go out of scope. 564 | /// 565 | /// Errors get printed using the [`debug!`] macro 566 | pub async fn delete_message(self) -> Result<(), Self> { 567 | let url = format!( 568 | "{}/webhooks/{:?}/{}/messages/{:?}", 569 | crate::BASE_URL, 570 | self.application_id, 571 | self.interaction_token, 572 | self.id 573 | ); 574 | 575 | let exec = self.client.delete(&url).send().await; 576 | 577 | match exec { 578 | Err(e) => { 579 | debug!("Discord API returned an error: {:#?}", e); 580 | Err(self) 581 | } 582 | Ok(r) => { 583 | if r.status() != StatusCode::NO_CONTENT { 584 | let e = format!("{:#?}", r.text().await); 585 | debug!( 586 | "Discord API request did not return {}: {:#?}", 587 | StatusCode::NO_CONTENT, 588 | e 589 | ); 590 | Err(self) 591 | } else { 592 | Ok(()) 593 | } 594 | } 595 | } 596 | } 597 | } 598 | 599 | #[cfg(feature = "handler")] 600 | impl Context { 601 | /// Creates a new [`Context`] 602 | pub fn new(c: Client, i: Interaction) -> Self { 603 | let mut user_id = None; 604 | 605 | if i.user.is_none() { 606 | // Try to unwrap member 607 | if let Some(member) = &i.member { 608 | user_id = Some(member.user.id); 609 | } 610 | } else { 611 | // Try to unwrap user 612 | if let Some(u) = &i.user { 613 | user_id = Some(u.id); 614 | } 615 | } 616 | 617 | Self { 618 | client: c, 619 | interaction: i, 620 | author_id: user_id, 621 | } 622 | } 623 | 624 | /// Respond to an Interaction 625 | /// 626 | /// This returns an [`InteractionResponseBuilder`] which you can use to build an [`InteractionResponse`] 627 | /// 628 | /// # Example 629 | /// ```ignore 630 | /// let response = ctx.respond() 631 | /// .content("Example message") 632 | /// .tts(true) 633 | /// .finish(); 634 | /// ``` 635 | pub fn respond(&self) -> InteractionResponseBuilder { 636 | let mut b = InteractionResponseBuilder::default(); 637 | 638 | // Default to UpdateMessage response type if InteractionType is MessageComponent 639 | if self.interaction.r#type == InteractionType::MessageComponent { 640 | b.r#type = InteractionResponseType::UpdateMessage; 641 | } 642 | 643 | b 644 | } 645 | 646 | /// Edit the original interaction response 647 | /// 648 | /// This takes an [`WebhookMessage`]. You can convert an [`InteractionResponse`] using [`WebhookMessage::from`]. 649 | pub async fn edit_original(&self, new_content: &WebhookMessage) -> Result<(), HttpError> { 650 | let url = format!( 651 | "{}/webhooks/{:?}/{}/messages/@original", 652 | crate::BASE_URL, 653 | self.interaction.application_id.unwrap(), 654 | self.interaction.token.as_ref().unwrap() 655 | ); 656 | let c = self.client.patch(&url).json(new_content).send().await; 657 | 658 | expect_successful_api_response!(c, Ok(())) 659 | } 660 | 661 | /// Delete the original interaction response 662 | pub async fn delete_original(&self) -> Result<(), HttpError> { 663 | let url = format!( 664 | "{}/webhooks/{:?}/{}/messages/@original", 665 | crate::BASE_URL, 666 | self.interaction.application_id.unwrap(), 667 | self.interaction.token.as_ref().unwrap() 668 | ); 669 | let c = self.client.delete(&url).send().await; 670 | 671 | expect_specific_api_response!(c, StatusCode::NO_CONTENT, Ok(())) 672 | } 673 | 674 | /// Create a follow-up message 675 | pub async fn create_followup( 676 | &self, 677 | content: &WebhookMessage, 678 | ) -> Result { 679 | let url = format!( 680 | "{}/webhooks/{:?}/{}?wait=true", 681 | crate::BASE_URL, 682 | self.interaction.application_id.unwrap(), 683 | self.interaction.token.as_ref().unwrap() 684 | ); 685 | 686 | let c = self.client.post(&url).json(content).send().await; 687 | 688 | match c { 689 | Err(e) => { 690 | debug!("Discord API request failed: {:#?}", e); 691 | Err(HttpError { 692 | code: 0, 693 | message: format!("{:#?}", e), 694 | }) 695 | } 696 | Ok(r) => { 697 | let st = r.status(); 698 | if !st.is_success() { 699 | let e = format!("{:#?}", r.text().await); 700 | debug!("Discord API returned an error: {:#?}", e); 701 | Err(HttpError { 702 | code: st.as_u16(), 703 | message: e, 704 | }) 705 | } else { 706 | let a: Result = 707 | serde_json::from_str(&r.text().await.unwrap()); 708 | 709 | match a { 710 | Err(e) => { 711 | debug!("Failed to decode response: {:#?}", e); 712 | Err(HttpError { 713 | code: 500, 714 | message: format!("{:?}", e), 715 | }) 716 | } 717 | Ok(mut f) => { 718 | f.interaction_token = 719 | self.interaction.token.as_ref().unwrap().to_string(); 720 | Ok(f) 721 | } 722 | } 723 | } 724 | } 725 | } 726 | } 727 | } 728 | 729 | #[cfg(feature = "extended-handler")] 730 | /// Getter functions 731 | impl Context { 732 | /// Get a [`Guild`] from an ID 733 | pub async fn get_guild>(&self, id: I) -> Result { 734 | let url = format!( 735 | "{}/guilds/{:?}?with_counts=true", 736 | crate::BASE_URL, 737 | id.into() 738 | ); 739 | 740 | let r = self.client.get(&url).send().await; 741 | expect_successful_api_response_and_return!(r, Guild, g, Ok(g)) 742 | } 743 | 744 | /// Get a [`Member`] from a [`Guild`] 745 | pub async fn get_guild_member( 746 | &self, 747 | guild_id: impl Into, 748 | user_id: impl Into, 749 | ) -> Result { 750 | let url = format!( 751 | "{}/guilds/{:?}/members/{:?}", 752 | crate::BASE_URL, 753 | guild_id.into(), 754 | user_id.into() 755 | ); 756 | 757 | let r = self.client.get(&url).send().await; 758 | expect_successful_api_response_and_return!(r, Member, m, Ok(m)) 759 | } 760 | } 761 | -------------------------------------------------------------------------------- /src/types/mod.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | // async_trait::async_trait; 4 | 5 | /// Module containing the embed structures 6 | pub mod embed; 7 | 8 | /// Module containing all structs for defining application commands 9 | pub mod application; 10 | /// Module containing structures for interactions 11 | pub mod interaction; 12 | 13 | /// Module containing structures for members/users. 14 | pub mod user; 15 | 16 | /// Module containing structures for using [Message Components](https://discord.com/developers/docs/interactions/message-components#what-are-components) 17 | pub mod components; 18 | 19 | /// Module containing structures for guilds 20 | pub mod guild; 21 | //use interaction::{InteractionResponse, Interaction}; 22 | 23 | pub mod modal; 24 | mod attachment; 25 | 26 | /// Discord's 'snowflake'. It's a 64bit unsigned integer that is mainly used for identifying anything Discord. 27 | pub type Snowflake = u64; 28 | 29 | #[doc(hidden)] 30 | #[derive(Clone, Serialize, Deserialize, Debug)] 31 | pub struct HttpError { 32 | pub code: u16, 33 | pub message: String, 34 | } 35 | #[doc(hidden)] 36 | impl HttpError { 37 | pub fn new(code: u16, message: String) -> HttpError { 38 | HttpError { code, message } 39 | } 40 | } 41 | #[doc(hidden)] 42 | #[derive(Clone, Serialize, Deserialize)] 43 | pub struct MessageError { 44 | pub message: String, 45 | } 46 | #[doc(hidden)] 47 | impl MessageError { 48 | pub fn new(message: String) -> MessageError { 49 | MessageError { message } 50 | } 51 | } 52 | #[doc(hidden)] 53 | impl From for MessageError { 54 | fn from(HttpError { message, .. }: HttpError) -> MessageError { 55 | MessageError { message } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/types/modal.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use super::components::MessageComponent; 4 | use serde_with::*; 5 | 6 | #[derive(Clone, Debug, Serialize, Deserialize)] 7 | pub struct Modal { 8 | custom_id: String, 9 | title: String, 10 | components: Vec, 11 | } 12 | -------------------------------------------------------------------------------- /src/types/user.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use ::chrono::{DateTime, Utc}; 4 | use serde_with::*; 5 | 6 | use super::Snowflake; 7 | 8 | // ======= STRUCTS ======= 9 | 10 | #[serde_as] 11 | #[derive(Clone, Serialize, Deserialize, Debug)] 12 | /// A Discord user 13 | pub struct User { 14 | #[serde_as(as = "DisplayFromStr")] 15 | /// User id 16 | pub id: Snowflake, 17 | /// The username 18 | pub username: String, 19 | /// The discriminator. (Ex. `#1337`) 20 | pub discriminator: String, 21 | /// URL to their avatar/profile picture 22 | pub avatar: Option, 23 | /// Is it a bot? 24 | pub bot: Option, 25 | /// Is it a system user? 26 | pub system: Option, 27 | /// Do they have 2FA enabled? 28 | pub mfa_enabled: Option, 29 | /// User set locale 30 | pub locale: Option, 31 | /// Email verified? 32 | pub verified: Option, 33 | /// User's email address 34 | pub email: Option, 35 | /// Flags set on user 36 | pub flags: Option, 37 | #[serde_as(as = "Option")] 38 | #[serde(default)] 39 | /// Type of nitro subscription 40 | pub premium_type: Option, 41 | 42 | /// Public flags for user 43 | pub public_flags: Option, 44 | } 45 | 46 | impl PartialEq for User { 47 | fn eq(&self, other: &Self) -> bool { 48 | self.id == other.id 49 | } 50 | } 51 | 52 | #[serde_as] 53 | #[skip_serializing_none] 54 | #[derive(Clone, Serialize, Deserialize, Debug)] 55 | /// Representing a Member in a guild. 56 | pub struct Member { 57 | /// The user associated with this member 58 | pub user: User, 59 | /// The member's nickname, if any 60 | pub nick: Option, 61 | #[serde_as(as = "Vec")] 62 | /// The member's assigned roles 63 | pub roles: Vec, 64 | /// When this user joined 65 | pub joined_at: DateTime, 66 | 67 | /// When the member started boosting the server, if boosting 68 | pub premium_since: Option>, 69 | /// Is this member server deafened? 70 | pub deaf: bool, 71 | /// Is this member server muted (voice)? 72 | pub mute: bool, 73 | /// Pending status 74 | pub pending: bool, 75 | /// Permission overrides? 76 | pub permissions: Option, 77 | } 78 | 79 | impl From for User { 80 | fn from(member: Member) -> User { 81 | member.user 82 | } 83 | } 84 | 85 | impl PartialEq for Member { 86 | fn eq(&self, other: &Self) -> bool { 87 | self.user.id == other.user.id 88 | } 89 | } 90 | --------------------------------------------------------------------------------