@@ -22,6 +22,32 @@ pub fn hello_world(interaction: &Interaction) -> InteractionResponse { | |||
tts: false, | |||
content: format!("Hello {}!", cmd.member.nick.unwrap_or(cmd.member.user.unwrap().username)), | |||
allowed_mentions: None, | |||
embeds: None, | |||
}), | |||
} | |||
} | |||
} | |||
// gitea-release command definition | |||
pub fn def_gitea_release() -> (discord::ApplicationCommand, Option<String>) { | |||
(discord::ApplicationCommand { | |||
id: None, | |||
application_id: None, | |||
name: "gitea-release".to_string(), | |||
description: "".to_string(), | |||
options: Some(vec![ | |||
discord::ApplicationCommandOption::String { | |||
name: "username".to_string(), | |||
description: "Gitea username".to_string(), | |||
required: true, | |||
choices: None | |||
}, | |||
discord::ApplicationCommandOption::String { | |||
name: "repo".to_string(), | |||
description: "Gitea repository".to_string(), | |||
required: true, | |||
choices: None | |||
}, | |||
]), | |||
}, Some("616329232389505055".to_string())) | |||
} | |||
@@ -133,7 +133,14 @@ pub struct User { | |||
pub struct ApplicationCommandInteractionData { | |||
pub id: String, | |||
pub name: String, | |||
pub options: Option<Vec<ApplicationCommandInteractionData>>, | |||
pub options: Option<Vec<ApplicationCommandInteractionDataOption>>, | |||
} | |||
#[derive(Serialize, Deserialize, Clone)] | |||
pub struct ApplicationCommandInteractionDataOption { | |||
pub name: String, | |||
pub value: Option<String>, // FIXME this could be bool, integer, or sub-command as well | |||
pub options: Option<Vec<ApplicationCommandInteractionDataOption>>, | |||
} | |||
pub enum InteractionResponse { | |||
@@ -176,12 +183,91 @@ pub struct InteractionResponseRaw { | |||
pub struct InteractionApplicationCommandCallbackData { | |||
pub tts: bool, | |||
pub content: String, | |||
//pub embeds: Option<Vec<Embed>>, | |||
pub embeds: Option<Vec<Embed>>, | |||
pub allowed_mentions: Option<String>, | |||
} | |||
// slash command management structures | |||
// embed and sub-objects | |||
#[derive(Serialize, Deserialize, Clone)] | |||
pub struct Embed { | |||
pub title: Option<String>, | |||
#[serde(rename = "type")] | |||
pub type_: Option<String>, | |||
pub description: Option<String>, | |||
pub url: Option<String>, | |||
pub timestamp: Option<String>, | |||
pub color: Option<usize>, | |||
pub footer: Option<EmbedFooter>, | |||
pub image: Option<EmbedImage>, | |||
pub thumbnail: Option<EmbedThumbnail>, | |||
pub video: Option<EmbedVideo>, | |||
pub provider: Option<EmbedProvider>, | |||
pub author: Option<EmbedAuthor>, | |||
pub fields: Option<Vec<EmbedField>>, | |||
} | |||
pub const EMBED_TYPE_RICH: &str = "rich"; | |||
pub const EMBED_TYPE_IMAGE: &str = "image"; | |||
pub const EMBED_TYPE_VIDEO: &str = "video"; | |||
pub const EMBED_TYPE_GIFV: &str = "gifv"; | |||
pub const EMBED_TYPE_ARTICLE: &str = "article"; | |||
pub const EMBED_TYPE_LINK: &str = "link"; | |||
#[derive(Serialize, Deserialize, Clone)] | |||
pub struct EmbedFooter { | |||
pub text: String, | |||
pub icon_url: Option<String>, | |||
pub proxy_icon_url: Option<String>, | |||
} | |||
#[derive(Serialize, Deserialize, Clone)] | |||
pub struct EmbedImage { | |||
pub url: Option<String>, | |||
pub proxy_url: Option<String>, | |||
pub height: Option<usize>, | |||
pub width: Option<usize>, | |||
} | |||
#[derive(Serialize, Deserialize, Clone)] | |||
pub struct EmbedThumbnail { | |||
pub url: Option<String>, | |||
pub proxy_url: Option<String>, | |||
pub height: Option<usize>, | |||
pub width: Option<usize>, | |||
} | |||
#[derive(Serialize, Deserialize, Clone)] | |||
pub struct EmbedVideo { | |||
pub url: Option<String>, | |||
pub proxy_url: Option<String>, | |||
pub height: Option<usize>, | |||
pub width: Option<usize>, | |||
} | |||
#[derive(Serialize, Deserialize, Clone)] | |||
pub struct EmbedProvider { | |||
pub name: Option<String>, | |||
pub url: Option<String>, | |||
} | |||
#[derive(Serialize, Deserialize, Clone)] | |||
pub struct EmbedAuthor { | |||
pub name: Option<String>, | |||
pub url: Option<String>, | |||
pub icon_url: Option<String>, | |||
pub proxy_icon_url: Option<String>, | |||
} | |||
#[derive(Serialize, Deserialize, Clone)] | |||
pub struct EmbedField { | |||
pub name: String, | |||
pub value: String, | |||
pub inline: Option<bool>, | |||
} | |||
// slash command management structures | |||
#[derive(Clone)] | |||
pub struct ApplicationCommand { | |||
pub id: Option<String>, | |||
pub application_id: Option<String>, | |||
@@ -190,56 +276,86 @@ pub struct ApplicationCommand { | |||
pub options: Option<Vec<ApplicationCommandOption>>, | |||
} | |||
impl ApplicationCommand { | |||
pub fn raw(&self) -> ApplicationCommandRaw { | |||
let mut options = None; | |||
if let Some(opts) = &self.options { | |||
let mut options_raw = Vec::with_capacity(opts.len()); | |||
for i in opts { | |||
options_raw.push(i.raw()); | |||
} | |||
if !options_raw.is_empty() { | |||
options = Some(options_raw); | |||
} | |||
} | |||
ApplicationCommandRaw { | |||
id: self.id.clone(), | |||
application_id: self.application_id.clone(), | |||
name: self.name.to_string(), | |||
description: self.description.to_string(), | |||
options, | |||
} | |||
} | |||
} | |||
#[derive(Serialize, Deserialize, Clone)] | |||
#[serde(tag = "type")] | |||
pub struct ApplicationCommandRaw { | |||
pub id: Option<String>, | |||
pub application_id: Option<String>, | |||
pub name: String, | |||
pub description: String, | |||
pub options: Option<Vec<ApplicationCommandOptionRaw>>, | |||
} | |||
#[derive(Clone)] | |||
pub enum ApplicationCommandOption { | |||
#[serde(rename = "1")] | |||
//#[serde(rename = "1")] | |||
SubCommand { | |||
name: String, | |||
description: String, | |||
required: bool, | |||
options: Option<Vec<ApplicationCommandOption>>, | |||
}, | |||
#[serde(rename = "2")] | |||
//#[serde(rename = "2")] | |||
SubCommandGroup { | |||
name: String, | |||
description: String, | |||
required: bool, | |||
options: Option<Vec<ApplicationCommandOption>>, | |||
}, | |||
#[serde(rename = "3")] | |||
//#[serde(rename = "3")] | |||
String { | |||
name: String, | |||
description: String, | |||
required: bool, | |||
choices: Option<Vec<ApplicationCommandOptionChoice>>, | |||
}, | |||
#[serde(rename = "4")] | |||
//#[serde(rename = "4")] | |||
Integer { | |||
name: String, | |||
description: String, | |||
required: bool, | |||
choices: Option<Vec<ApplicationCommandOptionChoice>>, | |||
}, | |||
#[serde(rename = "5")] | |||
//#[serde(rename = "5")] | |||
Boolean { | |||
name: String, | |||
description: String, | |||
required: bool, | |||
}, | |||
#[serde(rename = "6")] | |||
//#[serde(rename = "6")] | |||
User { | |||
name: String, | |||
description: String, | |||
required: bool, | |||
}, | |||
#[serde(rename = "7")] | |||
//#[serde(rename = "7")] | |||
Channel { | |||
name: String, | |||
description: String, | |||
required: bool, | |||
}, | |||
#[serde(rename = "8")] | |||
//#[serde(rename = "8")] | |||
Role { | |||
name: String, | |||
description: String, | |||
@@ -247,6 +363,108 @@ pub enum ApplicationCommandOption { | |||
}, | |||
} | |||
impl ApplicationCommandOption { | |||
pub fn raw(&self) -> ApplicationCommandOptionRaw { | |||
match self { | |||
ApplicationCommandOption::SubCommand { name, description, required, options} => { | |||
let mut options_opt = None; | |||
if let Some(opts) = options { | |||
let mut options_raw = Vec::with_capacity(opts.len()); | |||
for i in opts { | |||
options_raw.push(i.raw()); | |||
} | |||
options_opt = Some(options_raw); | |||
} | |||
ApplicationCommandOptionRaw { | |||
type_: 1, | |||
name: name.to_string(), | |||
description: description.to_string(), | |||
required: *required, | |||
options: options_opt, | |||
choices: None, | |||
} | |||
}, | |||
ApplicationCommandOption::SubCommandGroup { name, description, required, options} => { | |||
let mut options_opt = None; | |||
if let Some(opts) = options { | |||
let mut options_raw = Vec::with_capacity(opts.len()); | |||
for i in opts { | |||
options_raw.push(i.raw()); | |||
} | |||
options_opt = Some(options_raw); | |||
} | |||
ApplicationCommandOptionRaw { | |||
type_: 2, | |||
name: name.to_string(), | |||
description: description.to_string(), | |||
required: *required, | |||
options: options_opt, | |||
choices: None, | |||
} | |||
}, | |||
ApplicationCommandOption::String { name, description, required, choices} => ApplicationCommandOptionRaw { | |||
type_: 3, | |||
name: name.to_string(), | |||
description: description.to_string(), | |||
required: *required, | |||
options: None, | |||
choices: Some(choices.clone().unwrap()), | |||
}, | |||
ApplicationCommandOption::Integer { name, description, required, choices} => ApplicationCommandOptionRaw { | |||
type_: 4, | |||
name: name.to_string(), | |||
description: description.to_string(), | |||
required: *required, | |||
options: None, | |||
choices: Some(choices.clone().unwrap()), | |||
}, | |||
ApplicationCommandOption::Boolean { name, description, required} => ApplicationCommandOptionRaw { | |||
type_: 5, | |||
name: name.to_string(), | |||
description: description.to_string(), | |||
required: *required, | |||
options: None, | |||
choices: None | |||
}, | |||
ApplicationCommandOption::User { name, description, required} => ApplicationCommandOptionRaw { | |||
type_: 6, | |||
name: name.to_string(), | |||
description: description.to_string(), | |||
required: *required, | |||
options: None, | |||
choices: None | |||
}, | |||
ApplicationCommandOption::Channel { name, description, required} => ApplicationCommandOptionRaw { | |||
type_: 7, | |||
name: name.to_string(), | |||
description: description.to_string(), | |||
required: *required, | |||
options: None, | |||
choices: None | |||
}, | |||
ApplicationCommandOption::Role { name, description, required} => ApplicationCommandOptionRaw { | |||
type_: 8, | |||
name: name.to_string(), | |||
description: description.to_string(), | |||
required: *required, | |||
options: None, | |||
choices: None | |||
}, | |||
} | |||
} | |||
} | |||
#[derive(Serialize, Deserialize, Clone)] | |||
pub struct ApplicationCommandOptionRaw { | |||
#[serde(rename = "type")] | |||
pub type_: usize, | |||
pub name: String, | |||
pub description: String, | |||
pub required: bool, | |||
pub options: Option<Vec<ApplicationCommandOptionRaw>>, | |||
pub choices: Option<Vec<ApplicationCommandOptionChoice>>, | |||
} | |||
#[derive(Serialize, Deserialize, Clone)] | |||
pub struct ApplicationCommandOptionChoice { | |||
pub name: String, | |||
@@ -0,0 +1,64 @@ | |||
use serde::{Deserialize, Serialize}; | |||
use reqwest::blocking; | |||
pub const GITEA_API_URL: &str = "https://git.exmods.org/api/v1"; | |||
// API endpoints | |||
pub fn get_releases(owner: &str, repo: &str) -> Result<Vec<Release>, String> { | |||
let client = blocking::Client::new(); | |||
let url = format!("{}/{}/{}/releases", GITEA_API_URL, owner, repo); | |||
let result = client.get(&url).send(); | |||
if let Ok(resp) = result { | |||
if let Ok(data) = resp.json::<Vec<Release>>() { | |||
return Ok(data); | |||
} else { | |||
return Err("Invalid JSON payload".to_string()); | |||
} | |||
} | |||
Err("Invalid Result".to_string()) | |||
} | |||
// API structures | |||
#[derive(Serialize, Deserialize, Clone)] | |||
pub struct Release { | |||
pub id: usize, | |||
pub tag_name: String, | |||
pub target_commitish: String, | |||
pub name: String, | |||
pub body: String, | |||
pub url: String, | |||
pub tarball_url: String, | |||
pub zipball_url: String, | |||
pub draft: bool, | |||
pub prerelease: bool, | |||
pub created_at: String, | |||
pub published_at: String, | |||
pub author: Author, | |||
pub assets: Option<Vec<Asset>>, | |||
} | |||
#[derive(Serialize, Deserialize, Clone)] | |||
pub struct Author { | |||
pub id: usize, | |||
pub login: String, | |||
pub full_name: String, | |||
pub email: String, | |||
pub avatar_url: String, | |||
pub language: String, | |||
pub is_admin: bool, | |||
pub last_login: String, | |||
pub created: String, | |||
pub username: String, | |||
} | |||
#[derive(Serialize, Deserialize, Clone)] | |||
pub struct Asset { | |||
pub id: usize, | |||
pub name: String, | |||
pub size: usize, | |||
pub download_count: usize, | |||
pub created_at: String, | |||
pub uuid: String, | |||
pub browser_download_url: String, | |||
} |
@@ -0,0 +1,86 @@ | |||
use crate::gitea::get_releases; | |||
use crate::discord::{Interaction, InteractionResponse, InteractionApplicationCommandCallbackData, Embed, EmbedFooter, EmbedAuthor, EmbedField}; | |||
pub fn gitea_release(interaction: &Interaction) -> InteractionResponse { | |||
let cmd = interaction.cmd().unwrap(); | |||
let mut username = String::new(); | |||
let mut repo_name = String::new(); | |||
for opt in &cmd.data.options.unwrap() { | |||
match &opt.name as &str { | |||
"username" => username = opt.value.clone().unwrap(), | |||
"repo" => repo_name = opt.value.clone().unwrap(), | |||
_ => {} | |||
} | |||
} | |||
let res = get_releases(&username, &repo_name); | |||
if let Ok(resp) = res { | |||
if resp.is_empty() { | |||
// no release in this repo | |||
return InteractionResponse::ChannelMessage { | |||
data: Some(InteractionApplicationCommandCallbackData { | |||
tts: false, | |||
content: format!("No releases found for {}'s {} repository", &username, &repo_name), | |||
embeds: None, | |||
allowed_mentions: None | |||
}) | |||
} | |||
} | |||
// releases found, use most recent (0th index) | |||
let release = &resp[0]; | |||
// build downloads field | |||
let mut asset_str = String::new(); | |||
if let Some(assets) = &release.assets { | |||
for f in assets { | |||
asset_str += &format!("[{}]({})\n", f.name, f.browser_download_url); | |||
} | |||
} | |||
// assemble embed | |||
let embed = Embed { | |||
title: Some(format!("{} {}", &repo_name, &release.tag_name)), | |||
type_: None, | |||
description: Some(release.body.clone()), | |||
url: None, | |||
timestamp: None, | |||
color: Some(0x00C800), // Colour::from_rgb(0, 200, 0) | |||
footer: Some(EmbedFooter { | |||
text: release.author.login.clone(), | |||
icon_url: Some(release.author.avatar_url.clone()), | |||
proxy_icon_url: None | |||
}), | |||
image: None, | |||
thumbnail: None, | |||
video: None, | |||
provider: None, | |||
author: Some(EmbedAuthor { | |||
name: Some(release.name.clone()), | |||
url: Some(format!("https://git.exmods.org/{}/{}", &username, &repo_name)), | |||
icon_url: None, | |||
proxy_icon_url: None | |||
}), | |||
fields: Some(vec![ | |||
EmbedField { | |||
name: "Download".to_string(), | |||
value: asset_str, | |||
inline: Some(true) | |||
} | |||
]) | |||
}; | |||
return InteractionResponse::ChannelMessage { | |||
data: Some(InteractionApplicationCommandCallbackData { | |||
tts: false, | |||
content: "".to_string(), | |||
embeds: Some(vec![embed]), | |||
allowed_mentions: None | |||
}) | |||
} | |||
} else { | |||
return InteractionResponse::ChannelMessageWithSource { | |||
data: Some(InteractionApplicationCommandCallbackData { | |||
tts: false, | |||
content: format!("Gitea API error: `{}`", res.err().unwrap()), | |||
embeds: None, | |||
allowed_mentions: None | |||
}) | |||
} | |||
} | |||
} |
@@ -3,6 +3,8 @@ | |||
mod auth_tools; | |||
mod discord; | |||
mod command_definitions; | |||
mod gitea; | |||
mod gitea_command; | |||
#[macro_use] extern crate rocket; | |||
use lazy_static::lazy_static; | |||
@@ -10,8 +12,9 @@ use lazy_static::lazy_static; | |||
use rocket_contrib::json::Json; | |||
use std::sync::RwLock; | |||
use crate::auth_tools::{AuthenticatedInteraction}; | |||
use crate::discord::{Interaction, InteractionResponse, /*ApplicationCommand, */InteractionResponseRaw}; | |||
use crate::command_definitions::{hello_world, def_hello_world}; | |||
use crate::discord::{Interaction, InteractionResponse, InteractionResponseRaw, InteractionApplicationCommandCallbackData}; | |||
use crate::command_definitions::{hello_world, def_hello_world, def_gitea_release}; | |||
use crate::gitea_command::gitea_release; | |||
static GLOBAL_COMMAND_KEY: &str = "GLOBAL command KEY"; | |||
@@ -29,7 +32,15 @@ fn root_post(interaction: AuthenticatedInteraction) -> Json<InteractionResponseR | |||
Interaction::Command {data, ..} => { | |||
match &data.name as &str { | |||
"hello-world" => hello_world(&interaction.interaction), | |||
_ => InteractionResponse::AcknowledgeWithSource {}, | |||
"gitea-release" => gitea_release(&interaction.interaction), | |||
_ => InteractionResponse::ChannelMessageWithSource { | |||
data: Some(InteractionApplicationCommandCallbackData { | |||
tts: false, | |||
content: "Oops, that's not implemented yet!".to_string(), | |||
embeds: None, | |||
allowed_mentions: None | |||
}) | |||
}, | |||
} | |||
}, | |||
_ => InteractionResponse::AcknowledgeWithSource {}, | |||
@@ -65,6 +76,7 @@ fn main() { | |||
let req_client = reqwest::blocking::Client::new(); | |||
// TODO add more commands | |||
register_command(&def_hello_world, &req_client); | |||
register_command(&def_gitea_release, &req_client); | |||
// start web server | |||
rocket::ignite().mount("/", routes![root_post, hello_get]).launch(); | |||
} | |||
@@ -77,7 +89,7 @@ fn register_command(f: &dyn Fn() -> (discord::ApplicationCommand, Option<String> | |||
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) | |||
.json(&payload.raw()) | |||
.send(); | |||
if let Ok(d) = res { | |||
println!("Registered/updated `{}` ({}) GUILD:{}", &payload.name, &d.status().as_str(), &guild_id); | |||
@@ -88,7 +100,7 @@ fn register_command(f: &dyn Fn() -> (discord::ApplicationCommand, Option<String> | |||
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) | |||
.json(&payload.raw()) | |||
.send(); | |||
if let Ok(d) = res { | |||
println!("Registered/updated `{}` ({})", &payload.name, &d.status().as_str()); | |||