diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d5cf3b..d7fc589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `minimum` field in `Settings`, which controls the minimum number of available variants a product should have for a `restock` webhook to be sent out. +- The ability to detect invalid webhook URLs and stop using them. +- The process of automatically stopping the monitoring of a site if none + of its webhooks are working. +- The logic to terminate the program if no stores are being monitored. +- A self-updating [version number](src/main.rs#L43-L50) in the program's + start screen. + +### Changed + +- The monitor's logic to have a "background process" handle tasks that + would interrupt the monitor. +- The number of fields in `products.json` that are deserialized, + reducing latency. + +### Fixed + +- Bug where the Status Codes to the Discord API Responses were + interpreted incorrectly. +- Process that "spammed" the terminal, sending repeated warnings about + websites being unreachable. ## [0.1.1] - 2021-07-31 diff --git a/Cargo.lock b/Cargo.lock index fa1ce4f..331a0eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -831,6 +831,7 @@ dependencies = [ name = "shopify-monitor" version = "0.1.1" dependencies = [ + "base64", "chrono", "colored", "futures", diff --git a/Cargo.toml b/Cargo.toml index 6333e1d..f7c6791 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ version = "1" features = ["full"] [dependencies] +base64 = "0.13.0" chrono = "0.4" colored = "2" futures = "0.3.16" diff --git a/src/config.rs b/src/config.rs index 69a7610..cb31b38 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,39 +9,45 @@ use std::{collections::HashMap, fs, io, process, vec::IntoIter}; // This function is used to get the deserialized values saved in // `config.json` in order for the program to know what to do. pub fn read() -> Config { - // At first, the program assumes it's being run by a regular user - // and doesn't mention the possible existence of - // `config.private.json`. - - // This file is intended to be used by developers, as the repository's - // `.gitignore` doesn't include `config.json`. By configuring their - // own settings in the private file, leaving the "public" one with - // its example settings, they can ensure that they don't - // accidentally publish their webhook URLs and the integrity of the - // example configuration isn't compromised. - - hidden!("Loading `config.private.json`..."); - default!("Loading config file..."); - - if let Ok(config) = fs::read_to_string("config.private.json") { - hidden!("Reading `private.config.json`..."); - - // The program only refers to the private config file as such if - //the directory it's in contains it. - default!("Reading private config file..."); - - let json = serde_json::from_str(config.as_str()); - - if let Ok(value) = json { - success!("Successfully parsed settings!"); - return value; - } else if let Err(error) = json { - hidden!("Failed to parse `config.private.json`: {}", error); - } + // This code block will only run when the program is in debug mode, + // and won't be included in the monitor's release build, as regular + // users have no use for the alternative `config.private.json`. + #[cfg(debug_assertions)] + { + // At first, the program assumes it's being run by a regular user + // and doesn't mention the possible existence of + // `config.private.json`. + + // This file is intended to be used by developers, as the repository's + // `.gitignore` doesn't include `config.json`. By configuring their + // own settings in the private file, leaving the "public" one with + // its example settings, they can ensure that they don't + // accidentally publish their webhook URLs and the integrity of the + // example configuration isn't compromised. + + hidden!("Loading `config.private.json`..."); + default!("Loading config file..."); + + if let Ok(config) = fs::read_to_string("config.private.json") { + hidden!("Reading `private.config.json`..."); + + // The program only refers to the private config file as such if + //the directory it's in contains it. + default!("Reading private config file..."); + + let json = serde_json::from_str(config.as_str()); + + if let Ok(value) = json { + success!("Successfully parsed settings!"); + return value; + } else if let Err(error) = json { + hidden!("Failed to parse `config.private.json`: {}", error); + } - warning!("Invalid private config file!"); - default!("Trying again with `config.json`..."); - }; + warning!("Invalid private config file!"); + default!("Trying again with `config.json`..."); + }; + } hidden!("Loading `config.json`..."); @@ -134,7 +140,9 @@ fn suggest_instructions() { // there's enough time to read the error messages, before discarding // the new input and terminating the program. let mut input = String::new(); - io::stdin().read_line(&mut input).unwrap(); + io::stdin() + .read_line(&mut input) + .expect("Failed to read input."); // The function could also call `process::exit()`, as it is repeated // after both of its instances, however the compiler wouldn't @@ -476,7 +484,7 @@ pub struct Keyword { // they can set common "shared" keywords without having to repeat // them in every event's settings. - // As a result, these two example are equivalent: + // As a result, these three example are equivalent: // // Example 1: // "keywords": null, diff --git a/src/main.rs b/src/main.rs index 6894be3..1390e05 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod tests; mod webhook; use colored::*; +use std::io::stdin; #[tokio::main] async fn main() { @@ -18,7 +19,7 @@ async fn main() { // of "Shopify Monitor", with "Shopify" printed green, using the // `colored` crate, to somewhat resemble the company logo. println!( - " {} __ __ _ _\n {} | \\/ | (_) |\n{} | \\ / | ___ _ __ _| |_ ___ _ __\n {} | |\\/| |/ _ \\| '_ \\| | __/ _ \\| '__|\n {} | | | | (_) | | | | | || (_) | |\n{} |_| |_|\\___/|_| |_|_|\\__\\___/|_|\n {}\n {}\n", + " {} __ __ _ _\n {} | \\/ | (_) |\n{} | \\ / | ___ _ __ _| |_ ___ _ __\n {} | |\\/| |/ _ \\| '_ \\| | __/ _ \\| '__|\n {} | | | | (_) | | | | | || (_) | |\n{} |_| |_|\\___/|_| |_|_|\\__\\___/|_|\n {}\n {}{}\n", "_____ _ _ __".green(), "/ ____| | (_)/ _|".green(), "| (___ | |__ ___ _ __ _| |_ _ _".green(), @@ -26,7 +27,16 @@ async fn main() { "____) | | | | (_) | |_) | | | | |_| |".green(), "|_____/|_| |_|\\___/| .__/|_|_| \\__, |".green(), "| | __/ |".green(), - "|_| |___/".green() + "|_| |___/".green(), + + // This code block allows for the version number of the program + // to be always up-to-date, as it will check the value indicated + // in `Cargo.toml` and dynamically adjust the number of spaces + // used so that the text is always aligned properly. + { + let version = env!("CARGO_PKG_VERSION"); + format!("{}VERSION {}", " ".repeat(27 - version.len()), version.green()) + } ); // The output will look like this: @@ -37,7 +47,7 @@ async fn main() { // ____) | | | | (_) | |_) | | | | |_| | | | | | (_) | | | | | || (_) | | // |_____/|_| |_|\___/| .__/|_|_| \__, | |_| |_|\___/|_| |_|_|\__\___/|_| // | | __/ | - // |_| |___/ + // |_| |___/ VERSION X.X.X important!("LOADING SETTINGS"); @@ -50,4 +60,15 @@ async fn main() { // Once the `settings` are returned, the monitor can start running. monitor::run(settings).await; + + // If there aren't any issues, the program should run indefinitely. + // If the monitor is stopped, however, the function will return and + // the following code will run. At the moment, the only cause for + // `run()` to end is if all provided webhook links are invalid, + // however additional logic may be implemented in the future. + important!("STOPPED MONITOR"); + default!("The monitor has stopped running. Press `Enter` to quit."); + stdin() + .read_line(&mut String::new()) + .expect("Failed to read input."); } diff --git a/src/monitor.rs b/src/monitor.rs index 2ff21b6..de42333 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -1,30 +1,144 @@ // This is where the logic for the actual monitor will be. use crate::{ - default, hidden, + default, error, hidden, message::*, products::{File, Product}, stores::Store, success, warning, webhook::{self, Status}, }; +use base64::{encode_config, URL_SAFE}; use chrono::prelude::*; use futures::future::join_all; use std::{sync::Arc, time::Duration}; use tokio::{ + sync::{ + mpsc::{self, Sender}, + oneshot, RwLock, + }, task, time::{self, sleep}, }; pub async fn run(stores: Vec) { + // This variable will keep track of the number of stores being + // monitored so that the program can quit if it drops to zero. + let amount = stores.len(); + + default!("Monitoring {} stores...", amount); + + // While after some testing I realized that collecting tasks in a + // vector and `join`ing them is not necessary for them to run, the + // program will quit immediately as it will not "wait for them to + // complete", therefore I'm keeping this. While other methods exist + // to avoid this, maintaining the `tasks` vector should make it + // simpler to have the program quit once new features are introduced. let mut tasks = vec![]; + // This vector will list all invalid webhook links so that the + // program does not waste time sending requests to them. It uses a + // `RwLock` so that all tasks can read its contents while only one + // of them can edit it (adding more links) at once. In order to + // maximize performance, the current strategy is to check that the + // vector's length has not changed before each iteration of the + // monitoring task, and to check if the newly black-listed webhooks + // are part of the list the task is supposed to send to. If they + // are, they will be removed from said list, and the monitor will + // not attempt t send updates to it again. If any webhook-sending + // task receives an invalid status, the link will be added to the + // `broken_webhooks` list so that all tasks can remove it + // immediately. + let broken_webhooks = Arc::new(RwLock::new(vec![])); + + // This channel will be used to allow monitoring tasks to + // communicate with a background task. While more functionality will + // be added to it in the future (such as detecting any updates to + // This counter will be used the config file), it currently only + // serves as a counter to keep track of how many stores the monitor + // is unable to connect to at any given time, so that if its value + // reaches the total number of stores, the program will know is + // offline. The value of its buffer is arbitrary and may be subject + // to change following further testing. + let (tx, mut rx) = mpsc::channel(amount * 5); + + // This channel, on the other hand, is meant to be used only once in + // order to force the `run()` function to return, terminating the + // monitor. + let (quit_tx, quit_rx) = oneshot::channel(); + + // `amount` is re-declared here as it needs to be an `Arc<>` but it's + // inconvenient to have to `.read()` it in the statements between + // when it is first declared and here. + let amount = Arc::new(RwLock::new(amount)); + + // This code block is sectioned-off so that `amount` can be cloned + // without renaming it. + { + let amount = amount.clone(); + let broken_webhooks = broken_webhooks.clone(); + + // This "background task" receives updates from the other tasks + // through a `mpsc` channel and handles them so that the monitor + // can run without interruptions. + tasks.push(task::spawn(async move { + let mut offline = 0; + + while let Some(update) = rx.recv().await { + match update { + // Monitor Updates + Update::Monitor(MonitorUpdate::Quit, message) => { + error!("{}", message); + break; + } + + // Site Updates + Update::Site(SiteUpdate::Online, _) => { + offline -= 1; + + if offline == 0 { + success!("All sites are are back online!"); + } + } + + Update::Site(SiteUpdate::Offline, _) => { + offline += 1; + + if offline == *amount.read().await { + error!("The program seems not to be connected to the Internet!"); + } + } + + // Webhook Updates + Update::Webhook(_, url) => { + warning!("Invalid webhook: {}!", url); + + // If the webhook is invalid, it's added to this vector so + // that the program will stop sending requests to it. + broken_webhooks.write().await.push(url.clone()); + } + + #[allow(unreachable_patterns)] + _ => {} + } + } + + // If the loop is ended, a message will be sent through + // `quit_tx` and the monitor will stop. + quit_tx.send(()).expect("Failed to send message."); + })); + } + for store in stores { + let broken_webhooks = broken_webhooks.clone(); + let tx = tx.clone(); + let amount = amount.clone(); + // These vectors contain all channels the monitor should send // webhooks to, divided by the type of events included. let restock = Arc::clone(&store.restock); - let password_up = Arc::clone(&store.password_up.clone()); - let password_down = Arc::clone(&store.password_down.clone()); + let password_up = Arc::clone(&store.password_up); + let password_down = Arc::clone(&store.password_down); tasks.push(task::spawn(async move { let client = reqwest::Client::new(); @@ -34,19 +148,77 @@ pub async fn run(stores: Vec) { let mut previous: Option> = None; let mut password_page = false; let mut rate_limit = false; + let mut online = true; + let mut broken_prev = 0; // This will be used to return `Future`s that complete at // intervals as long as the `delay` specified by the user. let mut interval = time::interval(Duration::from_millis(store.delay)); - loop { + // This `loop` is named so that it can be `break`ed out of + // from within another loop. + 'main: loop { + // In this version of the monitor, when a webhook is + // detected to be invalid it is removed from the list of + // links that the program sends requests to. Checking + // the list takes some time, as the list has to be + // checked at every iteration, but skipping the process + // of sending unnecessary requests should make up for + // it. If testing shows that there is performance is + // negatively affected, this feature will be removed. + + // The webhooks are only checked if new links have been + // blacklisted. + let broken_curr = broken_webhooks.read().await; + + if broken_curr.len() != broken_prev { + // Only the newly "banned" webhooks are checked. + for i in broken_prev..broken_curr.len() { + // Since links are never removed from the + // vector, it isn't possible for the length of + // `broken_webhooks` to decrease, so it's safe + // to access the elements using square brackets. + + // The URL is checked instead of the channel + // itself because if two user-created "channels" + // were to share the same link, both should be + // removed. + restock + .write() + .await + .retain(|c| c.url != broken_curr[i]); + password_up + .write() + .await + .retain(|c| c.url != broken_curr[i]); + password_down + .write() + .await + .retain(|c| c.url != broken_curr[i]); + + // If nothing is being monitored (as there + // aren't any valid webhooks to send updates to) + // the task is killed. + if restock.read().await.is_empty() && + password_up.read().await.is_empty() && + password_down.read().await.is_empty() { + break 'main; + } + } + + // The variable keeping track of the amount of + // banned links should be updated or the program + // will always check every vector for no reason. + broken_prev = broken_curr.len(); + } + // The endpoint for all Shopify store is // `/products.json`, so it has to be added to the // website's URL to get the link to it. let req = client.get( /* format!("{}/products.json?limit=100", */ format!("{}/products.json", - &store.url.clone().trim_end_matches('/') + &store.url.clone() )) // For this first version, I simply "borrowed" the "Safe @@ -71,6 +243,12 @@ pub async fn run(stores: Vec) { if let Ok(res) = req { /* hidden!("Fetched {}! Status: {}!", res.url(), res.status()); */ + if !online { + default!("`{}` is back online!", store.name); + tx.send(Update::Site(SiteUpdate::Online, "".into())).await.expect("Failed to send update."); + online = true; + } + if res.status() == 200 { // In this case, a webhook saying the password // page is down will be sent. @@ -80,13 +258,15 @@ pub async fn run(stores: Vec) { hidden!("Password page raised on {}!", store.url); success!("{}: Password Page Up!", store.name); - let mut webhooks = vec![]; + // This variable keeps track of the number + // of webhooks sent for each store update. + let mut quantity = 0; // The program will cycle through each // channel that should be notified and send // out a webhook. - for channel in (*password_down).iter() { - webhooks.push(password(PasswordSettings { + for channel in password_down.read().await.iter() { + task::spawn(password(PasswordSettings { kind: Password::Down, url: channel.url.clone(), username: channel.settings.username.clone(), @@ -97,29 +277,30 @@ pub async fn run(stores: Vec) { timestamp: channel.settings.timestamp, store_name: store.name.clone(), store_url: store.url.clone(), - store_logo: store.logo.clone() + store_logo: store.logo.clone(), + broken_webhooks: broken_webhooks.clone(), + tx: tx.clone(), })); - } - let length = webhooks.len(); + // I think that using a counter should + // be faster than accessing the + // `password_down` vector to check its + // length, but I may be wrong. + quantity += 1; + } - let s = if length == 1 { - // I'm using `\0`, a null character, - // instead of an empty character as the - // latter doesn't exist. + default!( + "Sending {} webhook{}...", + quantity, + // This conditional statement appends an + // "s" to the word "webhook" if more + // than one is sent. I'm using `\0`, a + // null character, instead of an empty + // character as the latter doesn't + // exist. // https://stackoverflow.com/questions/3670505/why-is-there-no-char-empty-like-string-empty - '\0' - } else { - 's' - }; - - default!("Sending {} webhook{}...", length, s); - - // In a future version of the monitor, I - // will probably use channels to send the - // webhooks to a different task, so that I - // don't have to wait for them to be sent. - join_all(webhooks).await; + if quantity == 1 { '\0' } else { 's' } + ); } if rate_limit { @@ -173,28 +354,35 @@ pub async fn run(stores: Vec) { // the available variants used to // be unavailable), before sending a // webhook. - if let Some(prev) = previous.iter().find(|prev| prev.id == curr.id) { - if curr.updated_at != prev.updated_at && - curr.variants.iter().any(|curr| - prev.variants.iter().any(|prev| - prev.id == curr.id && !prev.available && curr.available - ) - ) + if let Some(prev) = + previous.iter().find(|prev| prev.id == curr.id) + { + if curr.updated_at != prev.updated_at + && curr.variants.iter().any(|curr| { + prev.variants.iter().any(|prev| { + prev.id == curr.id + && !prev.available + && curr.available + }) + }) { /* hidden!("Product {} Updated At: {}", curr.id, curr.updated_at); */ hidden!("{}/product/{} restocked!", store.url, curr.id); success!("{}: `{}` restocked!", store.name, curr.title); - let mut webhooks = vec![]; + let mut quantity = 0; let ap = available_product(curr); - for channel in (*restock).iter() { - if curr.variants + for channel in restock.read().await.iter() { + if curr + .variants .iter() .filter(|v| v.available) - .count() >= channel.settings.minimum { + .count() + >= channel.settings.minimum + { // Although it may // not seem like it // at first glance, @@ -206,7 +394,7 @@ pub async fn run(stores: Vec) { // function for each // webhook that // should be sent. - webhooks.push(item(ItemSettings { + task::spawn(item(ItemSettings { kind: Item::Restock, product: ap.clone(), url: channel.url.clone(), @@ -216,36 +404,35 @@ pub async fn run(stores: Vec) { sizes: channel.settings.sizes, thumbnail: channel.settings.thumbnail, image: channel.settings.image, - footer_text: channel.settings.footer_text.clone(), - footer_image: channel.settings.footer_image.clone(), + footer_text: channel + .settings + .footer_text + .clone(), + footer_image: channel + .settings + .footer_image + .clone(), timestamp: channel.settings.timestamp, store_name: store.name.clone(), store_url: store.url.clone(), - store_logo: store.logo.clone() + store_logo: store.logo.clone(), + broken_webhooks: broken_webhooks.clone(), + tx: tx.clone(), })); /* hidden!("Pushed a webhook for product {}!", curr.id); */ + + quantity += 1; } } /* hidden!("Sending webhooks for `{}`!", curr.id); */ - let length = webhooks.len(); - - default!("Sending {} webhook{}...", - length, - // This appends an "s" to - // the word "webhook" if - // more than one is - // sent. - if length == 1 { - '\0' - } else { - 's' - } + default!( + "Sending {} webhook{}...", + quantity, + if quantity == 1 { '\0' } else { 's' } ); - - join_all(webhooks).await; } // This code will run if a @@ -256,12 +443,12 @@ pub async fn run(stores: Vec) { hidden!("{}/product/{} was added!", store.url, curr.id); success!("{}: `{}` was added!", store.name, curr.title); - let mut webhooks = vec![]; + let mut quantity = 0; let ap = available_product(curr); - for channel in (*restock).iter() { - webhooks.push(item(ItemSettings{ + for channel in restock.read().await.iter() { + task::spawn(item(ItemSettings { kind: Item::New, product: ap.clone(), url: channel.url.clone(), @@ -271,26 +458,24 @@ pub async fn run(stores: Vec) { sizes: channel.settings.sizes, thumbnail: channel.settings.thumbnail, image: channel.settings.image, - footer_text:channel.settings.footer_text.clone(), + footer_text: channel.settings.footer_text.clone(), footer_image: channel.settings.footer_image.clone(), timestamp: channel.settings.timestamp, store_name: store.name.clone(), store_url: store.url.clone(), - store_logo: store.logo.clone() + store_logo: store.logo.clone(), + broken_webhooks: broken_webhooks.clone(), + tx: tx.clone(), })); - } - - let length = webhooks.len(); - let s = if length == 1 { - '\0' - } else { - 's' - }; - - default!("Sending {} webhook{}...", length, s); + quantity += 1; + } - join_all(webhooks).await; + default!( + "Sending {} webhook{}...", + quantity, + if quantity == 1 { '\0' } else { 's' } + ); } } } @@ -303,7 +488,6 @@ pub async fn run(stores: Vec) { // has to be updated on every cycle // regardless. previous = minimal_products(current_products); - } else if let Err(e) = json { hidden!("Failed to parse JSON for {}: {}", store.url, e); @@ -316,9 +500,7 @@ pub async fn run(stores: Vec) { // In this case, a webhook with the restocked // items will be sent. - } else if res.status() == 401 { - // In this case, a webhook saying the password // page is up will be sent. if !password_page { @@ -327,13 +509,13 @@ pub async fn run(stores: Vec) { hidden!("Password page raised on {}!", store.url); success!("{}: Password Page Up!", store.name); - let mut webhooks = vec![]; + let mut quantity = 0; // The program will cycle through each // channel that should be notified and send // out a webhook. - for channel in (*password_up).iter() { - webhooks.push(password(PasswordSettings { + for channel in password_up.read().await.iter() { + task::spawn(password(PasswordSettings { kind: Password::Up, url: channel.url.clone(), username: channel.settings.username.clone(), @@ -344,42 +526,81 @@ pub async fn run(stores: Vec) { timestamp: channel.settings.timestamp, store_name: store.name.clone(), store_url: store.url.clone(), - store_logo: store.logo.clone() + store_logo: store.logo.clone(), + broken_webhooks: broken_webhooks.clone(), + tx: tx.clone(), })); - } - - let length = webhooks.len(); - let s = if length == 1 { - '\0' - } else { - 's' - }; + quantity += 1; + } - default!("Sending {} webhook{}...", length, s); + default!( + "Sending {} webhook{}...", + quantity, + if quantity == 1 { '\0' } else { 's' } + ); } } else if res.status() == 429 && !rate_limit { rate_limit = true; warning!("Rate limit reached for {}!", store.name); } - } else { + } else if online { warning!("Failed to GET {}!", store.url); + tx.send(Update::Site(SiteUpdate::Offline, "".into())).await.expect("Failed to send update."); + online = false; } // The program will wait for the interval to complete // its cycle before running the next iteration and // fetching the store's products again. interval.tick().await; + }; + + error!("All webhook URLs for `{}` are invalid!", store.name); + default!("Stopped monitoring {}.", store.url); + *amount.write().await -= 1; + + // If no stores are being monitored, the `run()` function + // will return and the program will quit. + if *amount.read().await == 0 { + tx.send(Update::Monitor(MonitorUpdate::Quit, "No valid webhooks!".into())).await.expect("Failed to send update."); } })); } - default!("Monitoring {} stores...", tasks.len()); + if quit_rx.await.is_ok() { + return; + } + // This function call ensures that the program doesn't exit while + // the monitor is still running. join_all(tasks).await; } -pub fn minimal_products(current_products: Arc>) -> Option> { +#[derive(Debug)] +enum Update { + Monitor(MonitorUpdate, String), + Site(SiteUpdate, String), + Webhook(WebhookUpdate, String), +} + +#[derive(Debug)] +enum MonitorUpdate { + Quit, +} + +#[derive(Debug, PartialEq)] +enum SiteUpdate { + Online, + Offline, +} + +#[derive(Debug)] +enum WebhookUpdate { + Invalid, +} + +fn minimal_products(current_products: Arc>) -> Option> { Some({ let mut products = vec![]; for product in (*current_products).iter() { @@ -407,7 +628,7 @@ pub fn minimal_products(current_products: Arc>) -> Option, @@ -416,9 +637,9 @@ pub struct MinimalProduct { // The fields of this struct used to be public while those of `MinimalProduct` // are not because a test required it. -pub struct MinimalVariant { - pub id: u64, - pub available: bool, +struct MinimalVariant { + id: u64, + available: bool, // While the program could check when each variant was last updated, // ignoring that value and only checking its availability is faster, // and removing its field results in lower memory usage. @@ -433,28 +654,28 @@ pub struct MinimalVariant { // webhook's embed, as well as a vector containing the available // variants. #[derive(PartialEq, Debug)] -pub struct AvailableProduct { - pub name: String, +struct AvailableProduct { + name: String, // The product's handle can be used to obtain the product link as // follows: `format!("{}/products/{}", store_url, handle)`. - pub handle: String, - pub brand: String, - pub price: String, + handle: String, + brand: String, + price: String, // I changed this to an `Option` as for some reason (which I can't // remember) I was using an empty `String` instead of `None` if the // product didn't have a photo. - pub image: Option, - pub variants: Vec, + image: Option, + variants: Vec, } // There's no need to make unnecessary operations or clone unused data, // so this struct holds the bare minimum. Since some values #[derive(PartialEq, Debug)] -pub struct AvailableVariant { - pub name: String, - pub id: u64, +struct AvailableVariant { + name: String, + id: u64, } // Why do two `struct`s for both "Minimal" and "Available" Products and @@ -466,7 +687,7 @@ pub struct AvailableVariant { // different data types, as they include the product details used to // form webhook embeds. As a result, both types are needed. -pub fn available_product( +fn available_product( curr: &Product, /*, prev: Option<&Vec>*/ ) -> Arc { let mut variants: Vec = vec![]; @@ -535,56 +756,62 @@ pub fn available_product( // only parameters are the webhook's URL and the `Message` to be sent, // while the two functions' role is to construct the embeds, as they // will differ between item and password-related notifications. -async fn request(url: String, msg: Arc) { - /* hidden!("`request()` started!"); */ +async fn request( + url: String, + msg: Arc, + broken: Arc>>, + tx: Sender, +) { + /* hidden!("Webhook Preview: {}", preview(msg.clone())); */ loop { - let status = webhook::send(url.clone(), msg.clone()).await; - - /* hidden!("Webhook Status: {:?}!", status); */ - - if status == Status::Success { - /* hidden!("Successfully sent webhook to {}!", url); */ - break; - } - - if let Status::RateLimit(seconds) = status { - hidden!("Rate Limit reached for {}!", url); - - if let Some(seconds) = seconds { - hidden!("Waiting {} seconds for {}...", seconds, url); - sleep(Duration::from_secs_f64(seconds)).await; - continue; + match webhook::send(url.clone(), msg.clone()).await { + // Seems like the compiler complains when I use my logging + // macros outside of code blocks... + /* Status::Success => hidden!("Successfully sent webhook to {}!", url), */ + Status::Success => { + hidden!("Successfully sent webhook to {}!", url); + } + Status::RateLimit(seconds) => { + hidden!("Rate Limit reached for {}!", url); + + if let Some(seconds) = seconds { + hidden!("Waiting {} seconds for {}...", seconds, url); + sleep(Duration::from_secs_f64(seconds)).await; + + // The loop only iterates again if the webhook is + // rate-limited and the program can determine how + // long to wait for. In all other cases, the + // function returns and the task is terminated. + continue; + } + } + Status::Invalid => { + if !broken.read().await.contains(&url) { + // Due to the channel's buffer, sending this message + // should take less time than `.write()`ing to + // `broken` directly. + tx.send(Update::Webhook(WebhookUpdate::Invalid, url)) + .await + .expect("Failed to send update."); + } } - } - if status == Status::Invalid { - hidden!("Invalid webhook: {}!", url); + // I'm not sure what the program should do when an unknown + // error occurs. Since in some cases it might be best to + // treat it the same way as an invalid webhook (as the issue + // should be persistent), I might chang this `match` + // statement's logic or introduce a new setting to decide + // the monitor's behavior. + Status::Unknown => { + warning!("Failed to send webhook to {}!", url); + } } - break; + return; } } -pub struct ItemSettings { - kind: Item, - product: Arc, - url: String, - username: Option, - avatar: Option, - color: Option, - sizes: bool, - /* atc: Option, */ - thumbnail: bool, - image: bool, - footer_text: Option, - footer_image: Option, - timestamp: bool, - store_name: String, - store_url: String, - store_logo: String, -} - // The function and enum are named `item()` and `Item`, and not // `product()` and `Product`, because the `Product` name is already used // by `crate::products::Product`, which is named after `products.json`. @@ -713,12 +940,16 @@ async fn item(settings: ItemSettings) { image: { // This isn't very elegant, but I copied it from the // `thumbnail` field below where it was the only - // solution i found. + // solution I found. let mut img = None; if settings.image && settings.product.image.is_some() { img = Some(Image { - url: settings.product.image.clone().unwrap(), + url: settings + .product + .image + .clone() + .expect("Failed to extract Image URL."), }); } img @@ -727,7 +958,11 @@ async fn item(settings: ItemSettings) { let mut tn = None; if settings.thumbnail && settings.product.image.is_some() { tn = Some(Thumbnail { - url: settings.product.image.clone().unwrap(), + url: settings + .product + .image + .clone() + .expect("Failed to extract Image URL."), }); } tn @@ -743,7 +978,28 @@ async fn item(settings: ItemSettings) { /* hidden!("Calling `request()` for {}!", product.name.clone()); */ - request(settings.url, msg).await; + request(settings.url, msg, settings.broken_webhooks, settings.tx).await; +} + +struct ItemSettings { + kind: Item, + product: Arc, + url: String, + username: Option, + avatar: Option, + color: Option, + sizes: bool, + /* atc: Option, */ + thumbnail: bool, + image: bool, + footer_text: Option, + footer_image: Option, + timestamp: bool, + store_name: String, + store_url: String, + store_logo: String, + broken_webhooks: Arc>>, + tx: Sender, } #[derive(PartialEq)] @@ -752,7 +1008,59 @@ enum Item { Restock, } -pub struct PasswordSettings { +async fn password(settings: PasswordSettings) { + let embed = Embed { + title: Some(format!("Password Page {}!", { + if settings.kind == Password::Up { + "Up" + } else { + "Down" + } + })), + description: None, + url: Some(settings.store_url.clone()), + color: settings.color, + fields: None, + author: Some(Author { + name: settings.store_name, + url: Some(settings.store_url.clone()), + icon_url: Some(settings.store_logo), + }), + footer: { + // The program doesn't check if a footer image was included, + // as if a timestamp or footer text weren't, it won't be + // rendered regardless. + if settings.footer_text.is_some() || settings.timestamp { + Some(Footer { + text: settings.footer_text, + icon_url: settings.footer_image, + }) + } else { + None + } + }, + timestamp: { + if settings.timestamp { + Some(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)) + } else { + None + } + }, + image: None, + thumbnail: None, + }; + + let msg = Arc::from(Message { + content: None, + embeds: Some(vec![embed]), + username: settings.username, + avatar_url: settings.avatar.clone(), + }); + + request(settings.url, msg, settings.broken_webhooks, settings.tx).await; +} + +struct PasswordSettings { kind: Password, url: String, username: Option, @@ -764,73 +1072,8 @@ pub struct PasswordSettings { store_name: String, store_url: String, store_logo: String, -} - -async fn password(settings: PasswordSettings) { - // In order for the Webhook URL to be included in the logs if the - // task fails, it has to be cloned, or it will be consumed when it's - // `move`d into the task. - let webhook_url = settings.url.clone(); - - let task = task::spawn(async move { - let embed = Embed { - title: Some(format!("Password Page {}!", { - if settings.kind == Password::Up { - "Up" - } else { - "Down" - } - })), - description: None, - url: Some(settings.store_url.clone()), - color: settings.color, - fields: None, - author: Some(Author { - name: settings.store_name, - url: Some(settings.store_url.clone()), - icon_url: Some(settings.store_logo), - }), - footer: { - // The program doesn't check if a footer image was - // included, as if a timestamp or footer text - // weren't, it won't be rendered regardless. - if settings.footer_text.is_some() || settings.timestamp { - Some(Footer { - text: settings.footer_text, - icon_url: settings.footer_image, - }) - } else { - None - } - }, - timestamp: { - if settings.timestamp { - Some(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)) - } else { - None - } - }, - image: None, - thumbnail: None, - }; - - let msg = Arc::from(Message { - content: None, - embeds: Some(vec![embed]), - username: settings.username, - avatar_url: settings.avatar.clone(), - }); - - request(settings.url, msg).await; - }) - .await; - - if task.is_err() { - hidden!( - "The task failed before sending a webhook to {}!", - webhook_url - ); - }; + broken_webhooks: Arc>>, + tx: Sender, } #[derive(PartialEq)] @@ -838,3 +1081,22 @@ enum Password { Up, Down, } + +// While this function currently isn't used anywhere, it has been tested +// and seems to be working. In the future, it could be used to allow +// users to view a preview of the embeds, which could be convenient when +// setting up the monitor using the command line in future versions of +// the program. +#[allow(dead_code)] +fn preview(msg: Arc) -> String { + format!( + "https://discohook.org/?data={}", + encode_config( + format!( + "{{\"messages\":[{{\"data\":{}}}]}}", + serde_json::to_string(&*msg).expect("Failed to serialize JSON.") + ), + URL_SAFE + ) + ) +} diff --git a/src/products.rs b/src/products.rs index b04ab28..2f0d0ce 100644 --- a/src/products.rs +++ b/src/products.rs @@ -1,4 +1,10 @@ -// This file models the `products.json` file used in Shopify stores +// This file models the `products.json` file used in Shopify stores. +// Most of the fields have been commented out so that they can be +// ignored in the deserialization process as they are not used by the +// program, which should save some time. This is a bit of a shame +// considering how the file structure had been completely modeled. +// Still, some of them may become useful in the future, either for this +// monitor or in other projects, so I won't remove them. use serde::Deserialize; @@ -12,66 +18,87 @@ pub struct Product { pub id: u64, pub title: String, pub handle: String, - pub body_html: String, - pub published_at: String, - pub created_at: String, + /* pub body_html: String, */ + /* pub published_at: String, */ + /* pub created_at: String, */ pub updated_at: String, pub vendor: String, - pub product_type: String, - pub tags: Vec, + /* pub product_type: String, */ + /* pub tags: Vec, */ pub variants: Vec, pub images: Vec, - pub options: Vec, + /* pub options: Vec, */ } #[derive(Deserialize)] pub struct Variant { pub id: u64, pub title: String, - pub option1: String, - pub option2: Option, - pub option3: Option, - pub sku: String, - pub requires_shipping: bool, - pub taxable: bool, + /* pub option1: String, */ + /* pub option2: Option, */ + /* pub option3: Option, */ + /* pub sku: String, */ + /* pub requires_shipping: bool, */ + /* pub taxable: bool, */ + + // // In the `products.json` files I've checked so far, + // // `featured_image` always has a `null` value, therefore I'm not + // // sure what its type is. I will temporarily assume it's a string + // // until I find out. + // pub featured_image: Option, - // In the `products.json` files I've checked so far, - // `featured_image` always has a `null` value, therefore I'm not - // sure what its type is. I will temporarily assume it's a string - // until I find out. - pub featured_image: Option, + // Turns out its not a string but a map... + /* pub featured_image: Option, */ pub available: bool, pub price: String, - pub grams: u32, + /* pub grams: u32, */ // The same applies for `compare_at_price`. - pub compare_at_price: Option, - pub position: u32, + /* pub compare_at_price: Option, */ + /* pub position: u32, */ + /* pub product_id: u64, */ + /* pub created_at: String, */ + /* pub updated_at: String, */ +} + +/* +#[derive(Deserialize)] +pub struct FeaturedImage { + pub id: u64, pub product_id: u64, + pub position: u32, pub created_at: String, pub updated_at: String, + pub alt: String, + pub width: u32, + pub height: u32, + pub src: String, + pub variant_ids: Vec, } +*/ #[derive(Deserialize)] pub struct Image { - pub id: u64, - pub created_at: String, - pub position: u32, - pub updated_at: String, - pub product_id: u64, + /* pub id: u64, */ + /* pub created_at: String, */ + /* pub position: u32, */ + /* pub updated_at: String, */ + /* pub product_id: u64, */ // Similarly, I've only encountered `variant_ids` in the form of // empty vectors, and assume it contains integers as that is the // type used for other ID values, however I am not certain. - pub variant_ids: Vec, + /* pub variant_ids: Vec, */ pub src: String, - pub width: u32, - pub height: u32, + /* pub width: u32, */ + /* pub height: u32, */ } +/* #[derive(Deserialize)] pub struct ProductOption { pub name: String, pub position: u32, pub values: Vec, } +*/ diff --git a/src/stores.rs b/src/stores.rs index f33a88c..71faf43 100644 --- a/src/stores.rs +++ b/src/stores.rs @@ -5,6 +5,7 @@ use crate::{alternative::Alternative as Alt, config, default, hidden, warning}; use std::sync::Arc; +use tokio::sync::RwLock; pub fn get() -> Vec { let config = config::read(); @@ -15,11 +16,15 @@ pub fn get() -> Vec { // won't display all of it if you scroll up. /* hidden!("\n{:#?}", config); */ - // this vector, which will then be passed to the `monitor::run()` + // This vector, which will then be passed to the `monitor::run()` // function, will be filled with one `Store` struct per site listed // in `config.json`, along with every event the user has selected. let mut stores: Vec = vec![]; + // This `Vec<>` will store all invalid webhook URLs so that the + // program won't warn the user about them more than once. + let mut invalid: Vec = vec![]; + for site in config.sites { // A mutable vector is created for each event type let mut restock: Vec> = vec![]; @@ -133,7 +138,7 @@ pub fn get() -> Vec { if let Alt::Some(settings) = &server.settings { // Although in the example in `config.rs` I used `.is_some()` - // and `.unwrap()`, i know what those methods contain, as I + // and `.unwrap()`, I know what those methods contain, as I // wrote them, and using `if let` uses fewer steps. if let Alt::Some(value) = &settings.username { server_settings.username = Some(value.into()); @@ -175,275 +180,283 @@ pub fn get() -> Vec { } for channel in server.channels { - let mut channel_settings = server_settings.clone(); - let mut color = color.clone(); - - if let Alt::Some(settings) = &channel.settings { - if let Alt::Some(value) = &settings.username { - channel_settings.username = Some(value.into()); - } else if settings.username.is_null() { - channel_settings.username = None; - } - - if let Alt::Some(value) = &settings.avatar { - channel_settings.avatar = Some(value.into()); - } else if settings.avatar.is_null() { - channel_settings.avatar = None; - } - - color = settings.color.clone().into(); - - if let Alt::Some(value) = settings.sizes { - channel_settings.sizes = value; - } else if settings.sizes.is_null() { - channel_settings.sizes = false; - } - - if let Alt::Some(value) = settings.thumbnail { - channel_settings.thumbnail = value; - } else if settings.thumbnail.is_null() { - channel_settings.thumbnail = false; - } - - if let Alt::Some(value) = settings.image { - channel_settings.image = value; - } else if settings.image.is_null() { - channel_settings.image = false; - } - - if let Alt::Some(value) = &settings.footer_text { - channel_settings.footer_text = Some(value.into()); - } else if settings.footer_text.is_null() { - channel_settings.footer_text = None; - } - - if let Alt::Some(value) = &settings.footer_image { - channel_settings.footer_image = Some(value.into()); - } else if settings.footer_image.is_null() { - channel_settings.footer_image = None; - } - - if let Alt::Some(value) = settings.timestamp { - channel_settings.timestamp = value; - } else if settings.timestamp.is_null() { - channel_settings.timestamp = false; - } - - if let Alt::Some(value) = settings.minimum { - channel_settings.minimum = value; - } else if settings.minimum.is_null() { - channel_settings.minimum = 0; - } - } else if channel.settings.is_null() { - channel_settings = Settings::new(); - } - - // Just to clarify, in this context `site` refers to the - // website being monitored, as the program iterates - // through each one and checks if it's referenced, as a - // `store`, within a channel. - for store in channel.sites.clone() { - let mut store_settings = channel_settings.clone(); + if channel.url.contains("https://discord.com/api/webhooks/") { + let mut channel_settings = server_settings.clone(); let mut color = color.clone(); - if let Alt::Some(settings) = &store.settings { + if let Alt::Some(settings) = &channel.settings { if let Alt::Some(value) = &settings.username { - store_settings.username = Some(value.into()); + channel_settings.username = Some(value.into()); } else if settings.username.is_null() { - store_settings.username = None; + channel_settings.username = None; } if let Alt::Some(value) = &settings.avatar { - store_settings.avatar = Some(value.into()); + channel_settings.avatar = Some(value.into()); } else if settings.avatar.is_null() { - store_settings.avatar = None; + channel_settings.avatar = None; } color = settings.color.clone().into(); if let Alt::Some(value) = settings.sizes { - store_settings.sizes = value; + channel_settings.sizes = value; } else if settings.sizes.is_null() { - store_settings.sizes = false; + channel_settings.sizes = false; } if let Alt::Some(value) = settings.thumbnail { - store_settings.thumbnail = value; + channel_settings.thumbnail = value; } else if settings.thumbnail.is_null() { - store_settings.thumbnail = false; + channel_settings.thumbnail = false; } if let Alt::Some(value) = settings.image { - store_settings.image = value; + channel_settings.image = value; } else if settings.image.is_null() { - store_settings.image = false; + channel_settings.image = false; } if let Alt::Some(value) = &settings.footer_text { - store_settings.footer_text = Some(value.into()); + channel_settings.footer_text = Some(value.into()); } else if settings.footer_text.is_null() { - store_settings.footer_text = None; + channel_settings.footer_text = None; } if let Alt::Some(value) = &settings.footer_image { - store_settings.footer_image = Some(value.into()); + channel_settings.footer_image = Some(value.into()); } else if settings.footer_image.is_null() { - store_settings.footer_image = None; + channel_settings.footer_image = None; } if let Alt::Some(value) = settings.timestamp { - store_settings.timestamp = value; + channel_settings.timestamp = value; } else if settings.timestamp.is_null() { - store_settings.timestamp = false; + channel_settings.timestamp = false; } if let Alt::Some(value) = settings.minimum { - store_settings.minimum = value; + channel_settings.minimum = value; } else if settings.minimum.is_null() { - store_settings.minimum = 0; + channel_settings.minimum = 0; } - } else if store.settings.is_null() { - store_settings = Settings::new(); + } else if channel.settings.is_null() { + channel_settings = Settings::new(); } - // Since every event is being checked and the - // channels are then saved in a `Vec`, the program - // will include duplicate channels if a user - // accidentally improperly configures the monitor. - // In the future, this could be prevented by using - // `HashMap`s with webhook URLs as keys, instead. - for event in store.events.clone() { - let mut event_settings = store_settings.clone(); + // Just to clarify, in this context `site` refers to the + // website being monitored, as the program iterates + // through each one and checks if it's referenced, as a + // `store`, within a channel. + for store in channel.sites.clone() { + let mut store_settings = channel_settings.clone(); + let mut color = color.clone(); - if let Alt::Some(settings) = &event.settings { + if let Alt::Some(settings) = &store.settings { if let Alt::Some(value) = &settings.username { - event_settings.username = Some(value.into()); + store_settings.username = Some(value.into()); } else if settings.username.is_null() { - event_settings.username = None; + store_settings.username = None; } if let Alt::Some(value) = &settings.avatar { - event_settings.avatar = Some(value.into()); + store_settings.avatar = Some(value.into()); } else if settings.avatar.is_null() { - event_settings.avatar = None; + store_settings.avatar = None; } color = settings.color.clone().into(); if let Alt::Some(value) = settings.sizes { - event_settings.sizes = value; + store_settings.sizes = value; } else if settings.sizes.is_null() { - event_settings.sizes = false; + store_settings.sizes = false; } if let Alt::Some(value) = settings.thumbnail { - event_settings.thumbnail = value; + store_settings.thumbnail = value; } else if settings.thumbnail.is_null() { - event_settings.thumbnail = false; + store_settings.thumbnail = false; } if let Alt::Some(value) = settings.image { - event_settings.image = value; + store_settings.image = value; } else if settings.image.is_null() { - event_settings.image = false; + store_settings.image = false; } if let Alt::Some(value) = &settings.footer_text { - event_settings.footer_text = Some(value.into()); + store_settings.footer_text = Some(value.into()); } else if settings.footer_text.is_null() { - event_settings.footer_text = None; + store_settings.footer_text = None; } if let Alt::Some(value) = &settings.footer_image { - event_settings.footer_image = Some(value.into()); + store_settings.footer_image = Some(value.into()); } else if settings.footer_image.is_null() { - event_settings.footer_image = None; + store_settings.footer_image = None; } if let Alt::Some(value) = settings.timestamp { - event_settings.timestamp = value; + store_settings.timestamp = value; } else if settings.timestamp.is_null() { - event_settings.timestamp = false; + store_settings.timestamp = false; } if let Alt::Some(value) = settings.minimum { - event_settings.minimum = value; + store_settings.minimum = value; } else if settings.minimum.is_null() { - event_settings.minimum = 0; + store_settings.minimum = 0; } } else if store.settings.is_null() { - event_settings = Settings::new(); + store_settings = Settings::new(); } - // The `color()` function has been temporarily removed. - - /* - // A webhook's embed color can be specified in two - // places: within the `Event` itself, where it can - // be individually customized, or in the `Server`'s - // `ServerSettings`, whose value should be used if - // one isn't specified for the `Event`. - - // Creating a function that's only called once [in - // the code] and requiring so many parameters may - // seem counter-intuitive, but after wasting some - // time trying to properly assign values to the - // `color` variable from within nested `if let` - // statements (to no avail), I decided to use a - // function, always using `return`, to "calm down" - // the compiler. - let color = color( - event.color.clone(), - server.settings.color.clone(), - server.name.clone(), - channel.name.clone(), - channel.id, - ); - */ - - let color = parse_color(&color); - - // If the site being iterated through is - // mentioned in a a channel (one of all the ones - // also being iterated through), its values are collected. - if store.name == site.name { - let channel = Arc::new(Channel { - name: channel.name.clone(), - /* id: channel.id.clone(), */ - url: channel.url.clone(), - settings: Settings { - username: event_settings.username, - avatar: event_settings.avatar, - color, - sizes: event_settings.sizes, - /* atc: event_settings.atc, */ - /* price: event_settings.price, */ - thumbnail: event_settings.thumbnail, - image: event_settings.image, - footer_text: event_settings.footer_text, - footer_image: event_settings.footer_image, - timestamp: event_settings.timestamp, - minimum: event_settings.minimum, - }, - }); - - // It is then added to the list (`Vec`) of - // channels that will receive a webhook - // notifying the occurrence of an event. - - if event.restock == Some(true) { - restock.push(channel.clone()); - } - - if event.password_up == Some(true) { - password_up.push(channel.clone()); + // Since every event is being checked and the + // channels are then saved in a `Vec`, the program + // will include duplicate channels if a user + // accidentally improperly configures the monitor. + // In the future, this could be prevented by using + // `HashMap`s with webhook URLs as keys, instead. + for event in store.events.clone() { + let mut event_settings = store_settings.clone(); + + if let Alt::Some(settings) = &event.settings { + if let Alt::Some(value) = &settings.username { + event_settings.username = Some(value.into()); + } else if settings.username.is_null() { + event_settings.username = None; + } + + if let Alt::Some(value) = &settings.avatar { + event_settings.avatar = Some(value.into()); + } else if settings.avatar.is_null() { + event_settings.avatar = None; + } + + color = settings.color.clone().into(); + + if let Alt::Some(value) = settings.sizes { + event_settings.sizes = value; + } else if settings.sizes.is_null() { + event_settings.sizes = false; + } + + if let Alt::Some(value) = settings.thumbnail { + event_settings.thumbnail = value; + } else if settings.thumbnail.is_null() { + event_settings.thumbnail = false; + } + + if let Alt::Some(value) = settings.image { + event_settings.image = value; + } else if settings.image.is_null() { + event_settings.image = false; + } + + if let Alt::Some(value) = &settings.footer_text { + event_settings.footer_text = Some(value.into()); + } else if settings.footer_text.is_null() { + event_settings.footer_text = None; + } + + if let Alt::Some(value) = &settings.footer_image { + event_settings.footer_image = Some(value.into()); + } else if settings.footer_image.is_null() { + event_settings.footer_image = None; + } + + if let Alt::Some(value) = settings.timestamp { + event_settings.timestamp = value; + } else if settings.timestamp.is_null() { + event_settings.timestamp = false; + } + + if let Alt::Some(value) = settings.minimum { + event_settings.minimum = value; + } else if settings.minimum.is_null() { + event_settings.minimum = 0; + } + } else if store.settings.is_null() { + event_settings = Settings::new(); } - if event.password_down == Some(true) { - password_down.push(channel.clone()); + // The `color()` function has been temporarily removed. + + /* + // A webhook's embed color can be specified in two + // places: within the `Event` itself, where it can + // be individually customized, or in the `Server`'s + // `ServerSettings`, whose value should be used if + // one isn't specified for the `Event`. + + // Creating a function that's only called once [in + // the code] and requiring so many parameters may + // seem counter-intuitive, but after wasting some + // time trying to properly assign values to the + // `color` variable from within nested `if let` + // statements (to no avail), I decided to use a + // function, always using `return`, to "calm down" + // the compiler. + let color = color( + event.color.clone(), + server.settings.color.clone(), + server.name.clone(), + channel.name.clone(), + channel.id, + ); + */ + + let color = parse_color(&color); + + // If the site being iterated through is + // mentioned in a a channel (one of all the ones + // also being iterated through), its values are collected. + if store.name == site.name { + let channel = Arc::new(Channel { + name: channel.name.clone(), + /* id: channel.id.clone(), */ + url: channel.url.trim_end_matches('/').into(), + settings: Settings { + username: event_settings.username, + avatar: event_settings.avatar, + color, + sizes: event_settings.sizes, + /* atc: event_settings.atc, */ + /* price: event_settings.price, */ + thumbnail: event_settings.thumbnail, + image: event_settings.image, + footer_text: event_settings.footer_text, + footer_image: event_settings.footer_image, + timestamp: event_settings.timestamp, + minimum: event_settings.minimum, + }, + }); + + // It is then added to the list (`Vec`) of + // channels that will receive a webhook + // notifying the occurrence of an event. + + if event.restock == Some(true) { + restock.push(channel.clone()); + } + + if event.password_up == Some(true) { + password_up.push(channel.clone()); + } + + if event.password_down == Some(true) { + // `channel` doesn't have to be + // `clone`d here as it won't be used + // again. + password_down.push(channel); + } } } } + } else if !invalid.contains(&channel.url) { + warning!("Invalid Webhook URL: `{}`!", channel.url); + invalid.push(channel.url); } } } @@ -457,9 +470,9 @@ pub fn get() -> Vec { url: site.url.clone(), logo, delay, - restock: Arc::new(restock), - password_up: Arc::new(password_up), - password_down: Arc::new(password_down), + restock: Arc::new(RwLock::new(restock)), + password_up: Arc::new(RwLock::new(password_up)), + password_down: Arc::new(RwLock::new(password_down)), }) } } @@ -586,9 +599,9 @@ pub struct Store { // This field isn't optional, as a default value is set if one // wasn't configured. pub delay: u64, - pub restock: Arc>>, - pub password_up: Arc>>, - pub password_down: Arc>>, + pub restock: Arc>>>, + pub password_up: Arc>>>, + pub password_down: Arc>>>, } #[derive(Debug)] diff --git a/src/webhook.rs b/src/webhook.rs index 76a3d9b..2ec86fa 100644 --- a/src/webhook.rs +++ b/src/webhook.rs @@ -21,22 +21,20 @@ pub async fn send(url: String, msg: Arc) -> Status { if let Ok(res) = req { hidden!("Sent webhook to {}!, Status: {}", url, res.status()); - let status = res.status().as_u16(); - - if status == 204 | 200 { - Status::Success - } else if status == 201 { - Status::Invalid - } else if status == 429 { - if let Ok(info) = res.json::().await { - Status::RateLimit(Some(info.retry_after)) - } else { - Status::RateLimit(None) + // I have to include the `return` keyword here as the compiler + // will complain if I don't (as it expects the `if let` + // statement after it to return too) + return match res.status().as_u16() { + 200 | 204 => Status::Success, + 201 | 404 => Status::Invalid, + 429 => { + if let Ok(info) = res.json::().await { + Status::RateLimit(Some(info.retry_after)) + } else { + Status::RateLimit(None) + } } - - // For some reason the compiler requires me to include this clause - } else { - Status::Unknown + _ => Status::Unknown, }; } else if let Err(e) = req { hidden!("Error sending webhook to {}: {}", url, e);