diff --git a/Cargo.lock b/Cargo.lock index 8111779..4eda5b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -584,9 +584,9 @@ checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" [[package]] name = "libc" -version = "0.2.132" +version = "0.2.147" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8371e4e5341c3a96db127eb2465ac681ced4c433e01dd0e938adbef26ba93ba5" +checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" [[package]] name = "lock_api" diff --git a/src/cmd_check_missing.rs b/src/cmd_check_missing.rs index ce57ffd..b42c9f2 100644 --- a/src/cmd_check_missing.rs +++ b/src/cmd_check_missing.rs @@ -13,6 +13,7 @@ pub async fn check_missing(args: CheckMissingArgs) { .sticker_sets .iter() .map(|pack_id| { + println!("{}", pack_id); return (pack_id, config.sticker_sets.get(pack_id).unwrap()); }) .collect(); diff --git a/src/cmd_deploy.rs b/src/cmd_deploy.rs index 128f95b..bf0372a 100644 --- a/src/cmd_deploy.rs +++ b/src/cmd_deploy.rs @@ -1,8 +1,9 @@ -use crate::creds::Creds; use crate::deploy_discord::deploy_discord; +use crate::deploy_mastodon::deploy_mastodon; use crate::deploy_telegram::deploy_telegram; use crate::sticker_config::StickerConfig; +use std::collections::HashMap; use std::fs::File; use std::path::PathBuf; @@ -16,9 +17,16 @@ pub async fn deploy(args: crate::args::DeployArgs) { let config: StickerConfig = serde_yaml::from_reader(config_file).expect("could not parse stickers.yml"); - let creds: Creds = serde_yaml::from_reader(creds_file).expect("could not parse creds.yml"); + let creds: HashMap = + serde_yaml::from_reader(creds_file).expect("could not parse creds.yml"); - for deploy_id in args.deploy_ids.into_iter() { + let mut deploy_ids = args.deploy_ids; + + if deploy_ids.is_empty() { + deploy_ids = config.deploy_where.keys().cloned().collect::>(); + } + + for deploy_id in deploy_ids.into_iter() { let sticker_config = config.clone(); match sticker_config @@ -48,6 +56,16 @@ pub async fn deploy(args: crate::args::DeployArgs) { ) .await; } + "mastodon" => { + println!("deploying {} to mastodon", &deploy_id); + deploy_mastodon( + deploy_id, + sticker_config, + creds.clone(), + args.folder.clone(), + ) + .await; + } _ => { panic!("deploy_to not set") } diff --git a/src/creds.rs b/src/creds.rs deleted file mode 100644 index f27554a..0000000 --- a/src/creds.rs +++ /dev/null @@ -1,7 +0,0 @@ -use serde::Deserialize; - -#[derive(Debug, Clone, Deserialize)] -pub struct Creds { - pub discord_bot_token: String, - pub telegram_bot_token: String, -} diff --git a/src/deploy_discord.rs b/src/deploy_discord.rs index f7b1412..c69d006 100644 --- a/src/deploy_discord.rs +++ b/src/deploy_discord.rs @@ -1,4 +1,3 @@ -use crate::creds::Creds; use crate::sticker_config::{DiscordDeployLocation, Sticker, StickerConfig, StickerType}; use indexmap::IndexMap; use serenity::http::client::Http as DiscordClient; @@ -79,16 +78,12 @@ fn split_by( pub async fn deploy_discord( deploy_id: String, sticker_config: StickerConfig, - creds: Creds, + creds: HashMap, base_stickerdb_path: String, ) { - let discord_token = creds.discord_bot_token.as_str(); - let discord_client = DiscordClient::new(discord_token); - //let discord_cache = DiscordCache::new() - let deploy_where = sticker_config.deploy_where.get(&deploy_id).unwrap(); let deploy_locations = deploy_where.discord.as_ref().unwrap().clone(); - let deploy_locations_map: HashMap<_, _> = deploy_locations + let deploy_locations_map: HashMap = deploy_locations .iter() .map(|loc| (loc.deploy_name.clone(), loc)) .collect(); @@ -152,6 +147,9 @@ pub async fn deploy_discord( let deploy_location = deploy_locations_map.get(&deploy_name).unwrap(); + let discord_token = creds.get(&deploy_location.credential_name).unwrap(); + let discord_client = DiscordClient::new(discord_token); + let deploy_emoji_names: Vec = deploy_emojis .iter() .map(|deploy_emoji| deploy_emoji.0.clone()) diff --git a/src/deploy_mastodon.rs b/src/deploy_mastodon.rs new file mode 100644 index 0000000..985cfd3 --- /dev/null +++ b/src/deploy_mastodon.rs @@ -0,0 +1,142 @@ +use std::{collections::HashMap, io::Read, path::PathBuf, process::Command}; + +use crate::{ + mastodon_api::{AdminEmoji, MastodonAPI}, + sticker_config::{Sticker, StickerConfig}, +}; + +fn convert_to_png(path: PathBuf) -> Vec { + let output = Command::new("convert") + .args([ + "-format", + "png", + "-resize", + "128x128", + path.as_os_str().to_str().unwrap(), + "png:-", + ]) + .output() + .expect("failed to execute process"); + + if !output.status.success() { + panic!( + "failed to run convert, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + output.stdout +} + +pub async fn deploy_mastodon( + deploy_id: String, + sticker_config: StickerConfig, + creds: HashMap, + base_stickerdb_path: String, +) { + let deploy_where = sticker_config.deploy_where.get(&deploy_id).unwrap(); + + let deploy_location = deploy_where.mastodon.as_ref().unwrap(); + + let pack_contents = sticker_config + .sticker_sets + .get(&deploy_where.pack_id) + .unwrap(); + + let pack_stickers: HashMap = pack_contents + .iter() + .map(|sticker_name| { + return ( + sticker_name.clone(), + sticker_config.stickers.get(sticker_name).unwrap().clone(), + ); + }) + .collect(); + + let token = creds.get(&deploy_location.credential_name).unwrap(); + + let mastodon_api = MastodonAPI::new(token.to_string(), deploy_location.api_base.clone()); + + let filter = vec!["domain:local".to_string()]; + + let mut mastodon_emojis = mastodon_api + .get_custom_emoji(&filter) + .await + .expect("could not get custom emojis"); + + // Delete emojis which have been removed from list + { + let invalid_emojis: Vec = mastodon_emojis + .clone() + .into_iter() + .filter(|emoji| { + !(pack_contents.contains(&emoji.shortcode) + || emoji.category != deploy_location.category) + }) + .collect(); + + for invalid_emoji in invalid_emojis.iter() { + println!("Deleting emoji with name {}", &invalid_emoji.shortcode); + mastodon_api + .delete_custom_emoji(&invalid_emoji.id) + .await + .expect("could not delete emoji"); + } + } + + if let Some(category) = &deploy_location.category { + mastodon_emojis = mastodon_emojis + .into_iter() + .filter(|emoji| emoji.category.is_some()) + .filter(|emoji| emoji.category == Some(category.to_string())) + .collect(); + } else { + mastodon_emojis = mastodon_emojis + .into_iter() + .filter(|emoji| emoji.category.is_none()) + .collect() + } + + // add emoji that arent in mastodon_emojis + { + let missing_emoji: Vec = pack_contents + .clone() + .into_iter() + .filter(|emoji_name| { + for emoji in mastodon_emojis.iter() { + if &emoji.shortcode == emoji_name { + return false; + } + } + true + }) + .collect(); + + for emoji_name in missing_emoji.iter() { + println!("Uploading Emoji: {}", emoji_name); + let sticker_data = pack_stickers.get(emoji_name).unwrap(); + + let file_path = PathBuf::from(&base_stickerdb_path).join(&sticker_data.file); + + let mut file_data: Vec; + if file_path.extension().unwrap() != "png" { + file_data = convert_to_png(file_path); + } else { + file_data = Vec::new(); + + let mut file = std::fs::File::open(file_path).unwrap(); + file.read_to_end(&mut file_data) + .expect("could not read file"); + } + + mastodon_api + .upload_emoji( + emoji_name.clone(), + deploy_location.category.clone(), + file_data, + ) + .await + .expect("could not upload emoji"); + } + } +} diff --git a/src/deploy_telegram.rs b/src/deploy_telegram.rs index 906ced1..7729156 100644 --- a/src/deploy_telegram.rs +++ b/src/deploy_telegram.rs @@ -3,7 +3,6 @@ use std::ops::Index; use std::{collections::HashMap, error::Error}; use crate::{ - creds::Creds, sticker_config::{Sticker, StickerConfig, StickerType}, tg_api::{TelegramAPI, TelegramSticker}, }; @@ -56,7 +55,7 @@ impl TelegramStickerIDState { pub async fn deploy_telegram( deploy_id: String, sticker_config: StickerConfig, - creds: Creds, + creds: HashMap, base_stickerdb_path: String, ) { let mut tg_state = TelegramStickerIDState::new(&base_stickerdb_path); @@ -106,7 +105,9 @@ pub async fn deploy_telegram( panic!("too many stickers in pack"); } - let tg_bot = TelegramAPI::new(creds.telegram_bot_token); + let token = creds.get(&deploy_location.credential_name).unwrap(); + + let tg_bot = TelegramAPI::new(token.to_string()); let tg_sticker_set = tg_bot .get_sticker_set(&deploy_location.name) .await @@ -241,7 +242,7 @@ pub async fn deploy_telegram( tg_state.set(&new_sticker.file_unique_id, sticker_name); tg_stickers.push(( - sticker_name.clone(), + sticker_name.to_string(), tg_new_sticker_set .stickers .index(new_sticker_position_base + i) diff --git a/src/main.rs b/src/main.rs index d678e8f..816fb50 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,9 +2,10 @@ pub mod args; pub mod cmd_check_missing; pub mod cmd_createtg; pub mod cmd_deploy; -pub mod creds; pub mod deploy_discord; +pub mod deploy_mastodon; pub mod deploy_telegram; +pub mod mastodon_api; pub mod sticker_config; pub mod tg_api; @@ -21,11 +22,12 @@ async fn main() { if args.debug { let subscriber = tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) - .compact() - .finish(); + .with_max_level(tracing::Level::DEBUG) + .pretty() + .finish(); - tracing::subscriber::set_global_default(subscriber).expect("setting tracing default failed"); + tracing::subscriber::set_global_default(subscriber) + .expect("setting tracing default failed"); } match args.command { diff --git a/src/mastodon_api.rs b/src/mastodon_api.rs new file mode 100644 index 0000000..0638da7 --- /dev/null +++ b/src/mastodon_api.rs @@ -0,0 +1,168 @@ +use reqwest::multipart::Part; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, error::Error}; + +// API docs taken from https://github.com/superseriousbusiness/gotosocial/blob/main/docs/api/swagger.yaml + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MastodonErrorResp { + pub error: String, +} + +impl MastodonErrorResp { + fn to_error(&self, error_code: u16) -> MastodonAPIError { + MastodonAPIError::new(self.error.clone(), error_code) + } +} + +#[derive(Debug)] +struct MastodonAPIError { + error: String, + error_code: u16, +} + +impl MastodonAPIError { + fn new(error: String, error_code: u16) -> MastodonAPIError { + MastodonAPIError { error, error_code } + } +} + +impl std::fmt::Display for MastodonAPIError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}: {}", self.error_code, self.error) + } +} + +impl Error for MastodonAPIError { + fn description(&self) -> &str { + self.error.as_str() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdminEmoji { + pub id: String, + pub category: Option, + pub shortcode: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Emoji { + pub category: Option, + pub shortcode: String, +} + +pub struct MastodonAPI { + token: String, + api_base: String, + client: reqwest::Client, +} + +impl MastodonAPI { + pub fn new(token: String, api_base: String) -> Self { + Self { + token, + api_base, + client: reqwest::Client::builder() + .user_agent("stickerdeploy") + .build() + .unwrap(), + } + } + + pub async fn get_custom_emoji( + &self, + filter: &[String], + ) -> Result, Box> { + let url = format!("{}/api/v1/admin/custom_emojis", self.api_base); + + let mut query: HashMap = HashMap::new(); + query.insert("filter".to_string(), filter.join(",")); + query.insert("limit".to_string(), "0".to_string()); + + let res = self + .client + .get(url) + .header( + "Authorization".to_string(), + format!("Bearer {}", self.token).to_string(), + ) + .query(&query) + .send() + .await?; + + let status = res.status(); + if status.is_success() { + let json = res.json::>().await?; + + Ok(json) + } else { + let json = res.json::().await?; + + Err(Box::new(json.to_error(status.as_u16()))) + } + } + + pub async fn delete_custom_emoji(&self, id: &String) -> Result<(), Box> { + let url = format!("{}/api/v1/admin/custom_emojis/{}", self.api_base, id); + + let res = self + .client + .delete(url) + .header( + "Authorization".to_string(), + format!("Bearer {}", self.token).to_string(), + ) + .send() + .await?; + + let status = res.status(); + if !status.is_success() { + let json = res.json::().await?; + + Err(Box::new(json.to_error(status.as_u16()))) + } else { + Ok(()) + } + } + + pub async fn upload_emoji( + &self, + shortcode: String, + category: Option, + file_data: Vec, + ) -> Result> { + let url = format!("{}/api/v1/admin/custom_emojis", self.api_base); + + let mut form = reqwest::multipart::Form::new(); + form = form.text("shortcode", shortcode.clone()); + if let Some(category) = category { + form = form.text("category", category); + } else { + form = form.text("category", ""); + } + form = form.part("image", Part::bytes(file_data).file_name("test.png")); + + let res = self + .client + .post(url) + .header( + "Authorization".to_string(), + format!("Bearer {}", self.token).to_string(), + ) + .multipart(form) + .send() + .await?; + + let status = res.status(); + if status.is_success() { + let json = res.json::().await?; + + Ok(json) + } else { + let json = res.json::().await?; + + Err(Box::new(json.to_error(status.as_u16()))) + } + } +} diff --git a/src/sticker_config.rs b/src/sticker_config.rs index ffaefbb..b1224d4 100644 --- a/src/sticker_config.rs +++ b/src/sticker_config.rs @@ -15,26 +15,50 @@ pub struct DeployWhere { pub deploy_to: String, pub discord: Option>, pub telegram: Option, + pub mastodon: Option, } #[derive(Debug, Clone, Deserialize)] pub struct TelegramDeployLocation { - pub deploy_name: String, + #[serde(default = "telegram_credential_name_default")] + pub credential_name: String, pub r#type: StickerType, pub name: String, pub user_id: u64, } +fn telegram_credential_name_default() -> String { + "telegram".to_string() +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MastodonDeployLocation { + pub api_base: String, + #[serde(default = "mastodon_credential_name_default")] + pub credential_name: String, + pub category: Option, +} + +fn mastodon_credential_name_default() -> String { + "mastodon".to_string() +} + #[derive(Debug, Clone, Deserialize)] pub struct DiscordDeployLocation { pub deploy_name: String, pub id: u64, + #[serde(default = "discord_credential_name_default")] + pub credential_name: String, #[serde(default = "discord_max_regular_emoji_default")] pub max_regular_emoji: u64, #[serde(default = "discord_max_animated_emoji_default")] pub max_animated_emoji: u64, } +fn discord_credential_name_default() -> String { + "discord".to_string() +} + fn discord_max_regular_emoji_default() -> u64 { 50 }