@@ -0,0 +1,2 @@ | |||
/target | |||
/.idea |
@@ -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"] |
@@ -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, ×tamp); // 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!("{}{}", ×tamp, &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, ())) | |||
} | |||
} |
@@ -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())) | |||
} |
@@ -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, | |||
} | |||
@@ -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(); | |||
} |