use std::{net::SocketAddr, str::FromStr}; use axum::{ http::{HeaderMap, StatusCode}, routing::post, Json, Router, }; use base64::{engine, alphabet, Engine}; use ed25519_dalek::{Signature, VerifyingKey}; use serde::Deserialize; use twilight_http::Client; use twilight_interactions::command::{CommandModel, CreateCommand, CommandInputData}; use twilight_model::{ application::{ interaction::{Interaction, InteractionType, InteractionData}, }, http::interaction::{InteractionResponse, InteractionResponseType, InteractionResponseData}, id::Id, }; #[derive(CommandModel, CreateCommand)] #[command(name="save_fact", desc="Quietly save a fact")] struct SaveFactCommand { #[command(rename="name", desc="Fact name")] fact_name: String, #[command(rename="value", desc="Fact value")] fact_value: String, } #[tokio::main] async fn main() { let port = 4635; let app = Router::new().route("/", post(post_interaction)); let addr = SocketAddr::from(([127, 0, 0, 1], port)); dotenvy::dotenv().ok(); register_command().await; axum::Server::bind(&addr) .serve(app.into_make_service()) .await .unwrap(); } type InteractionResult = Result<(StatusCode, Json), (StatusCode, String)>; async fn post_interaction(headers: HeaderMap, body: String) -> InteractionResult { let Ok(interaction): Result = serde_json::from_str(&body) else { return Err((StatusCode::BAD_REQUEST, "request contained invalid json".to_string())) }; let Some(sig) = headers.get("x-signature-ed25519") else { return Err((StatusCode::BAD_REQUEST, "requrest did not include signature header".to_string())) }; let Ok(sig) = hex::decode(sig) else { return Err((StatusCode::BAD_REQUEST, "requrest signature is invalid hex".to_string())) }; let Ok(sig) = Signature::from_slice(&sig) else { return Err((StatusCode::BAD_REQUEST, "request signature is malformed".to_string())) }; let Some(signed_buf) = headers.get("x-signature-timestamp") else { return Err((StatusCode::BAD_REQUEST, "requrest did not include signature timestamp header".to_string())) }; let mut signed_buf = signed_buf.as_bytes().to_owned(); signed_buf.extend(body.as_bytes()); let pub_key = discord_pub_key(); let Ok(()) = pub_key.verify_strict(&signed_buf, &sig) else { return Err((StatusCode::UNAUTHORIZED, "interaction failed signature verification".to_string())) }; match interaction.kind { InteractionType::Ping => { let pong = InteractionResponse { kind: InteractionResponseType::Pong, data: None, }; Ok((StatusCode::OK, Json(pong))) } InteractionType::ApplicationCommand => { let Some(InteractionData::ApplicationCommand(data)) = interaction.data else { return not_found(); }; let command_input_data = CommandInputData::from(*data.clone()); match &*data.name { "save_fact" => { let Ok(command_data) = SaveFactCommand::from_interaction(command_input_data) else { return Err((StatusCode::BAD_REQUEST, "invalid save fact command".to_string())); }; let reply = InteractionResponse { kind: InteractionResponseType::ChannelMessageWithSource, data: Some(InteractionResponseData { content: Some(format!("Set {0} to {1}", command_data.fact_name, command_data.fact_value)), ..Default::default() }) }; Ok((StatusCode::OK, Json(reply))) } _ => not_found(), } } _ => not_found(), } } fn not_found() -> InteractionResult { Err(( StatusCode::NOT_FOUND, "requested interaction not found".to_string(), )) } fn discord_pub_key_bytes() -> Vec { hex::decode(std::env::var("DISCORD_PUB_KEY").unwrap()).unwrap() } fn discord_pub_key() -> VerifyingKey { let pub_key_bytes: [u8; 32] = discord_pub_key_bytes().try_into().unwrap(); VerifyingKey::from_bytes(&pub_key_bytes).unwrap() } async fn register_command() { discord_client() .interaction(Id::from_str(&discord_client_id()).unwrap()) .set_global_commands(&[SaveFactCommand::create_command().into()]) .await .unwrap(); } #[derive(Deserialize)] struct ClientCredentialsResponse { access_token: String, } fn authorization() -> String { let engine = engine::GeneralPurpose::new(&alphabet::STANDARD, engine::general_purpose::PAD); let auth = format!( "{}:{}", discord_client_id(), discord_client_secret(), ); engine.encode(auth) } fn client_credentials_grant() -> ClientCredentialsResponse { ureq::post("https://discord.com/api/v10/oauth2/token") .set("Authorization", &format!("Basic {}", authorization())) .send_form(&[ ("grant_type", "client_credentials"), ("scope", "applications.commands.update"), ]).unwrap().into_json().unwrap() } fn discord_client_id() -> String { std::env::var("DISCORD_CLIENT_ID").unwrap() } fn discord_client_secret() -> String { std::env::var("DISCORD_CLIENT_SECRET").unwrap() } fn discord_client() -> Client { let token = client_credentials_grant().access_token; Client::new(format!("Bearer {token}")) }