Browse Source

Initial Discord Slash Command functionality

master
NGnius (Graham) 3 years ago
commit
5c61e9df96
7 changed files with 2375 additions and 0 deletions
  1. +2
    -0
      .gitignore
  2. +1989
    -0
      Cargo.lock
  3. +21
    -0
      Cargo.toml
  4. +73
    -0
      src/auth_tools.rs
  5. +13
    -0
      src/command_definitions.rs
  6. +128
    -0
      src/discord.rs
  7. +149
    -0
      src/main.rs

+ 2
- 0
.gitignore View File

@@ -0,0 +1,2 @@
/target
/.idea

+ 1989
- 0
Cargo.lock
File diff suppressed because it is too large
View File


+ 21
- 0
Cargo.toml View File

@@ -0,0 +1,21 @@
[package]
name = "leo2"
version = "0.1.0"
authors = ["NGnius (Graham) <ngniusness@gmail.com>"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
rocket = "0.4.7"
reqwest = { version = "0.11.1", features = ["json", "blocking"]}
ed25519-dalek = "1.0.1"
lazy_static = "1.4.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0" }
hex = "0.4.2"
# futures = "0.3.12"

[dependencies.rocket_contrib]
default-features = false
features = ["json"]

+ 73
- 0
src/auth_tools.rs View File

@@ -0,0 +1,73 @@
use rocket::data::{self, FromDataSimple};
use rocket::http::{Status};
use rocket::{Request, Data, Outcome};
use std::io::Read;
use ed25519_dalek::{Verifier, Signature};
use crate::discord::Interaction;

pub struct AuthenticatedInteraction {
signature: String,
timestamp: String,
pub interaction: Interaction,
}

impl AuthenticatedInteraction {
pub fn get_signature(&self) -> &str {
&self.signature
}

pub fn get_timestamp(&self) -> &str {
&self.timestamp
}
}

impl FromDataSimple for AuthenticatedInteraction {
type Error = ();

fn from_data(request: &Request, data: Data) -> data::Outcome<Self, Self::Error> {
let signature_opt = request.headers().get_one("x-signature-ed25519");
let timestamp_opt = request.headers().get_one("x-signature-timestamp");
if signature_opt.is_some() && timestamp_opt.is_some() {
let signature = signature_opt.unwrap().to_owned();
let timestamp = timestamp_opt.unwrap().to_owned();
println!("signature: `{}` time: `{}`", &signature, &timestamp); // debug
if let Ok(hex_data) = hex::decode(signature.clone()) {
let mut buffer = [0; 64];
for i in 0..64 { // copy hex decode into static-size buffer
if i >= hex_data.len() { break; }
buffer[i] = hex_data[i];
}
let sign = Signature::new(buffer); // requires [u8; 64]
// validate authentication
let mut string_buf = String::new();
if let Ok(string_len) = data.open().take(1_000_000).read_to_string(&mut string_buf) {
let msg = format!("{}{}", &timestamp, &string_buf[..string_len]);
let verif_result = crate::VERIFICATION_KEY.read()
.unwrap()
.unwrap()
.verify(msg.as_bytes(), &sign);
if verif_result.is_err() {
println!("Signature validation error");
return Outcome::Failure((Status::Unauthorized, ()))
}
if let Ok(payload) = serde_json::from_str(&string_buf[..string_len]) {
let auth = AuthenticatedInteraction {
signature: signature.clone(),
timestamp: timestamp.clone(),
interaction: payload,
};
return Outcome::Success(auth);
}
println!("Invalid json payload");
return Outcome::Failure((Status::Unauthorized, ()))
}
println!("Invalid body");
return Outcome::Failure((Status::Unauthorized, ()))
}
println!("Invalid signature headers");
return Outcome::Failure((Status::Unauthorized, ()))
}
println!("Missing signature headers");
Outcome::Failure((Status::Unauthorized, ()))
}
}

+ 13
- 0
src/command_definitions.rs View File

@@ -0,0 +1,13 @@
use crate::discord;

// fn foo() -> (definition, private?)

pub fn hello_world() -> (discord::ApplicationCommand, Option<String>) {
(discord::ApplicationCommand {
id: None,
application_id: None,
name: "hello-world".to_string(),
description: "Hello World".to_string(),
options: None,
}, None)//Some("616329232389505055".to_string()))
}

+ 128
- 0
src/discord.rs View File

@@ -0,0 +1,128 @@
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Clone)]
#[serde(tag = "type")]
pub enum Interaction {
#[serde(rename = "1")]
Ping {},
#[serde(rename = "2")]
Command {
token: String,
// member: Member,
id: String,
guild_id: String,
// data: Command,
channel_id: String,
//version: usize,
},
}

impl Interaction {
pub fn is_ping(&self) -> bool {
match self {
Self::Ping {..} => true,
Self::Command {..} => false,
}
}
}

#[derive(Serialize, Deserialize, Clone)]
#[serde(tag = "type")]
pub enum InteractionResponse {
#[serde(rename = "1")]
Pong {},
#[serde(rename = "2")]
Acknowledge {},
#[serde(rename = "3")]
ChannelMessage {
data: Option<InteractionApplicationCommandCallbackData>,
},
#[serde(rename = "4")]
ChannelMessageWithSource {
data: Option<InteractionApplicationCommandCallbackData>,
},
#[serde(rename = "5")]
AcknowledgeWithSource {},
}

#[derive(Serialize, Deserialize, Clone)]
pub struct InteractionApplicationCommandCallbackData {
pub tts: bool,
pub content: String,
//pub embeds: Option<Vec<Embed>>,
pub allowed_mentions: Option<String>,
}

// slash command management structures
#[derive(Serialize, Deserialize, Clone)]
pub struct ApplicationCommand {
pub id: Option<String>,
pub application_id: Option<String>,
pub name: String,
pub description: String,
pub options: Option<Vec<ApplicationCommandOption>>,
}

#[derive(Serialize, Deserialize, Clone)]
#[serde(tag = "type")]
pub enum ApplicationCommandOption {
#[serde(rename = "1")]
SubCommand {
name: String,
description: String,
required: bool,
options: Option<Vec<ApplicationCommandOption>>,
},
#[serde(rename = "2")]
SubCommandGroup {
name: String,
description: String,
required: bool,
options: Option<Vec<ApplicationCommandOption>>,
},
#[serde(rename = "3")]
String {
name: String,
description: String,
required: bool,
choices: Option<Vec<ApplicationCommandOptionChoice>>,
},
#[serde(rename = "4")]
Integer {
name: String,
description: String,
required: bool,
choices: Option<Vec<ApplicationCommandOptionChoice>>,
},
#[serde(rename = "5")]
Boolean {
name: String,
description: String,
required: bool,
},
#[serde(rename = "6")]
User {
name: String,
description: String,
required: bool,
},
#[serde(rename = "7")]
Channel {
name: String,
description: String,
required: bool,
},
#[serde(rename = "8")]
Role {
name: String,
description: String,
required: bool,
},
}

#[derive(Serialize, Deserialize, Clone)]
pub struct ApplicationCommandOptionChoice {
pub name: String,
pub value: String,
}


+ 149
- 0
src/main.rs View File

@@ -0,0 +1,149 @@
#![feature(proc_macro_hygiene, decl_macro)]

mod auth_tools;
mod discord;
mod command_definitions;

#[macro_use] extern crate rocket;
use lazy_static::lazy_static;

use rocket_contrib::json::Json;
use std::sync::RwLock;
use crate::auth_tools::{AuthenticatedInteraction};
use crate::discord::{InteractionResponse, InteractionApplicationCommandCallbackData, ApplicationCommand};
use crate::command_definitions::hello_world;
use std::collections::{HashMap, HashSet};

static GLOBAL_COMMAND_KEY: &str = "GLOBAL command KEY";

lazy_static! {
pub static ref VERIFICATION_KEY: RwLock<Option<ed25519_dalek::PublicKey>> = RwLock::new(None);
pub static ref APPLICATION_ID: RwLock<Option<String>> = RwLock::new(None);
pub static ref BOT_TOKEN: RwLock<Option<String>> = RwLock::new(None);
}

#[post("/", data = "<interaction>")]
fn root_post(interaction: AuthenticatedInteraction) -> Json<InteractionResponse> {
if interaction.interaction.is_ping() {
return Json(InteractionResponse::Pong {})
}
let resp = InteractionResponse::ChannelMessage {
data: Some(InteractionApplicationCommandCallbackData {
tts: false,
content: "Hello".to_string(),
allowed_mentions: None,
}),
};
Json(resp)
}

#[get("/")]
fn hello_get() -> &'static str {
"Hello, why are you here?"
}

fn main() {
// Init verification key
let public_key = std::env::var("DISCORD_PUBLIC_KEY")
.expect("Environment variable DISCORD_PUBLIC_KEY not found");
println!("Discord Pub Key {}", &public_key);
*VERIFICATION_KEY.write().unwrap() = Some(ed25519_dalek::PublicKey::from_bytes(
&hex::decode(public_key)
.expect("Invalid hex string")).expect("Invalid public key"));
// get application ID for sending API requests
let app_id = std::env::var("DISCORD_APP_ID")
.expect("Environment variable DISCORD_APP_ID not found");
println!("Discord App Id {}", &app_id);
*APPLICATION_ID.write().unwrap() = Some(app_id.to_string());
// get bot token for sending API requests
let token = std::env::var("DISCORD_TOKEN")
.expect("Environment variable DISCORD_TOKEN not found");
println!("Discord App Id {}", &app_id);
*BOT_TOKEN.write().unwrap() = Some(token.to_string());

// send API requests to bootstrap commands
let req_client = reqwest::blocking::Client::new();
let mut seen_cmds = HashMap::<String, HashSet<String>>::new();
// TODO
register_command(&hello_world, &req_client, &mut seen_cmds);
// start web server
rocket::ignite().mount("/", routes![root_post, hello_get]).launch();
}

fn register_command(f: &dyn Fn() -> (discord::ApplicationCommand, Option<String>),
client: &reqwest::blocking::Client,
registered: &mut HashMap<String, HashSet<String>>) {
let (payload, guild_opt) = f();
if let Some(guild_id) = guild_opt {
if !registered.contains_key(&guild_id) {
let mut seen_guild_cmds = HashSet::new();
let cmds = get_guild_commands(client, &guild_id);
for c in cmds {
println!("Found app command {} ID:{} App:{}", &c.name, &c.id.unwrap(), guild_id);
seen_guild_cmds.insert(c.name);
}
registered.insert(guild_id.clone(), seen_guild_cmds);
}
if !registered.get(&guild_id).unwrap().contains(&payload.name) {
// create new command
let url = format!("https://discord.com/api/v8/applications/{}/guilds/{}/commands", APPLICATION_ID.read().unwrap().as_ref().unwrap().clone(), &guild_id);
let res = client.post(&url)
.header("Authorization", format!("Bot {}", BOT_TOKEN.read().unwrap().as_ref().unwrap().clone()))
.json(&payload)
.send();
if let Ok(d) = res {
println!("`{}` status {}", &payload.name, &d.status().as_str());
println!("{}", &d.text().unwrap());
registered.get_mut(&guild_id).unwrap().insert(payload.name);
}
}
} else {
if !registered.contains_key(GLOBAL_COMMAND_KEY) {
let mut seen_cmds = HashSet::new();
let cmds = get_commands(client);
for c in cmds {
println!("Found global command {} ID:{}", &c.name, &c.id.unwrap());
seen_cmds.insert(c.name);
}
registered.insert(GLOBAL_COMMAND_KEY.to_string(), seen_cmds);
}
if !registered.get(GLOBAL_COMMAND_KEY).unwrap().contains(&payload.name) {
// create new command
let url = format!("https://discord.com/api/v8/applications/{}/commands", APPLICATION_ID.read().unwrap().as_ref().unwrap().clone());
let res = client.post(&url)
.header("Authorization", format!("Bot {}", BOT_TOKEN.read().unwrap().as_ref().unwrap().clone()))
.json(&payload)
.send();
if let Ok(d) = res {
println!("`{}` status {}", &payload.name, &d.status().as_str());
println!("{}", &d.text().unwrap());
registered.get_mut(GLOBAL_COMMAND_KEY.clone()).unwrap().insert(payload.name);
}
}

}
}

fn get_commands(client: &reqwest::blocking::Client) -> Vec<ApplicationCommand> {
let url = format!("https://discord.com/api/v8/applications/{}/commands", APPLICATION_ID.read().unwrap().as_ref().unwrap().clone());
let res = client.get(&url)
.header("Authorization", format!("Bot {}", BOT_TOKEN.read().unwrap().as_ref().unwrap().clone()))
.send();
if let Ok(d) = res {
println!("Commands GET status {}", &d.status().as_str());
return d.json().unwrap();
}
return Vec::new();
}

fn get_guild_commands(client: &reqwest::blocking::Client, guild_id: &str) -> Vec<ApplicationCommand> {
let url = format!("https://discord.com/api/v8/applications/{}/guilds/{}/commands", APPLICATION_ID.read().unwrap().as_ref().unwrap().clone(), guild_id);
let res = client.get(&url)
.header("Authorization", format!("Bot {}", BOT_TOKEN.read().unwrap().as_ref().unwrap().clone()))
.send();
if let Ok(d) = res {
println!("Commands GET status {}", &d.status().as_str());
return d.json().unwrap();
}
return Vec::new();
}

Loading…
Cancel
Save