From cfee9fdd0f5cf5bffec6b070a102dfa75190f4ff Mon Sep 17 00:00:00 2001 From: "NGnius (Graham)" Date: Mon, 22 Feb 2021 22:05:16 -0500 Subject: [PATCH] Add gitea release command --- src/command_definitions.rs | 28 ++++- src/discord.rs | 242 +++++++++++++++++++++++++++++++++++-- src/gitea.rs | 64 ++++++++++ src/gitea_command.rs | 86 +++++++++++++ src/main.rs | 22 +++- 5 files changed, 424 insertions(+), 18 deletions(-) create mode 100644 src/gitea.rs create mode 100644 src/gitea_command.rs diff --git a/src/command_definitions.rs b/src/command_definitions.rs index 0aaf1d1..f7574f1 100644 --- a/src/command_definitions.rs +++ b/src/command_definitions.rs @@ -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, }), } -} \ No newline at end of file +} + +// gitea-release command definition +pub fn def_gitea_release() -> (discord::ApplicationCommand, Option) { + (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())) +} + diff --git a/src/discord.rs b/src/discord.rs index e2ef65e..6e870fd 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -133,7 +133,14 @@ pub struct User { pub struct ApplicationCommandInteractionData { pub id: String, pub name: String, - pub options: Option>, + pub options: Option>, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct ApplicationCommandInteractionDataOption { + pub name: String, + pub value: Option, // FIXME this could be bool, integer, or sub-command as well + pub options: Option>, } pub enum InteractionResponse { @@ -176,12 +183,91 @@ pub struct InteractionResponseRaw { pub struct InteractionApplicationCommandCallbackData { pub tts: bool, pub content: String, - //pub embeds: Option>, + pub embeds: Option>, pub allowed_mentions: Option, } -// slash command management structures +// embed and sub-objects +#[derive(Serialize, Deserialize, Clone)] +pub struct Embed { + pub title: Option, + #[serde(rename = "type")] + pub type_: Option, + pub description: Option, + pub url: Option, + pub timestamp: Option, + pub color: Option, + pub footer: Option, + pub image: Option, + pub thumbnail: Option, + pub video: Option, + pub provider: Option, + pub author: Option, + pub fields: Option>, +} + +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, + pub proxy_icon_url: Option, +} + #[derive(Serialize, Deserialize, Clone)] +pub struct EmbedImage { + pub url: Option, + pub proxy_url: Option, + pub height: Option, + pub width: Option, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct EmbedThumbnail { + pub url: Option, + pub proxy_url: Option, + pub height: Option, + pub width: Option, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct EmbedVideo { + pub url: Option, + pub proxy_url: Option, + pub height: Option, + pub width: Option, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct EmbedProvider { + pub name: Option, + pub url: Option, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct EmbedAuthor { + pub name: Option, + pub url: Option, + pub icon_url: Option, + pub proxy_icon_url: Option, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct EmbedField { + pub name: String, + pub value: String, + pub inline: Option, +} + +// slash command management structures +#[derive(Clone)] pub struct ApplicationCommand { pub id: Option, pub application_id: Option, @@ -190,56 +276,86 @@ pub struct ApplicationCommand { pub options: Option>, } +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, + pub application_id: Option, + pub name: String, + pub description: String, + pub options: Option>, +} + +#[derive(Clone)] pub enum ApplicationCommandOption { - #[serde(rename = "1")] + //#[serde(rename = "1")] SubCommand { name: String, description: String, required: bool, options: Option>, }, - #[serde(rename = "2")] + //#[serde(rename = "2")] SubCommandGroup { name: String, description: String, required: bool, options: Option>, }, - #[serde(rename = "3")] + //#[serde(rename = "3")] String { name: String, description: String, required: bool, choices: Option>, }, - #[serde(rename = "4")] + //#[serde(rename = "4")] Integer { name: String, description: String, required: bool, choices: Option>, }, - #[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>, + pub choices: Option>, +} + #[derive(Serialize, Deserialize, Clone)] pub struct ApplicationCommandOptionChoice { pub name: String, diff --git a/src/gitea.rs b/src/gitea.rs new file mode 100644 index 0000000..cec4784 --- /dev/null +++ b/src/gitea.rs @@ -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, 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::>() { + 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>, +} + +#[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, +} \ No newline at end of file diff --git a/src/gitea_command.rs b/src/gitea_command.rs new file mode 100644 index 0000000..3dc8b81 --- /dev/null +++ b/src/gitea_command.rs @@ -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 + }) + } + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 2bac7f1..334f54d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { 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 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 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());