diff --git a/Cargo.toml b/Cargo.toml index 5b45826..e251842 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nyaa-notifs" -version = "0.1.6" +version = "0.1.7" edition = "2021" [dependencies] diff --git a/README.md b/README.md index 36bdf21..4bd38b8 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,11 @@ ___ * If the input url contains search patterns (aside from "newest"), the script will download all pages to find a new release. This can get your ip **banned** if you input the wrong url. (`complete_result = false`: limits everything to the first page) #### Config notes: -* To add multiple discord-notification channels, you can just continue the `channel_id` list. -* On the first run, I'd highly suggest you to keep all notification services deactivated, so you don't get spammed with outdated news. +* Discord channels can be configured seperately through the slash command framework +* On the first run, I'd highly suggest you to keep gotify&smtp notification services deactivated, so you don't get spammed with outdated news. #### Misc: -* All web-requests are executed two seconds from each other. +* All web-requests are executed two seconds from each other. (hopefully) ___ diff --git a/RECENT_CHANGES.md b/RECENT_CHANGES.md index 3fe2b3e..d746a2c 100644 --- a/RECENT_CHANGES.md +++ b/RECENT_CHANGES.md @@ -1,6 +1,7 @@ -* Attempt to fix multiple threads spawning when the discord bot reconnects -* Discord: If comments are too long, the message will be split and sent in different parts -* Discord: Removed "A new comment!" message for less bloat -* Discord: New releases messages now feature the actual uploaders gravatar instead of nyaa's default one. -* Comments in general: Markdown image embeds are now being stripped to only the url. f.e. `![](http://images.com/i.jpg)` -> `http://images.com/i.jpg` -* Cleaned up code a little bit +* Finally fixed the running loop when the bot gets reconnected +* Multiple discord server support + * Check out `/help` to for setup. + * Make sure the bot has permissions to read/write messages + * Only administrators have permissions to access the commands + +##### slash commands are easy, no need for poise ha diff --git a/migrations/20230114135944_front.sql b/migrations/20230114135944_front.sql new file mode 100644 index 0000000..85c8fca --- /dev/null +++ b/migrations/20230114135944_front.sql @@ -0,0 +1,8 @@ +-- Add migration script here +CREATE TABLE FRONT ( + activated TEXT NO NULL, + comments TEXT NO NULL, + releases TEXT NO NULL, + channel_id INTEGER, + urls TEXT NO NULL +) \ No newline at end of file diff --git a/sqlx-data.json b/sqlx-data.json index 8b0532c..b85cb39 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -1,5 +1,67 @@ { "db": "SQLite", + "081dfdf28ab067034cbc80fe8105fca51124d1db9446282c7e8e426c241bdd1f": { + "describe": { + "columns": [ + { + "name": "activated", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "comments", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "releases", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "channel_id", + "ordinal": 3, + "type_info": "Int64" + }, + { + "name": "urls", + "ordinal": 4, + "type_info": "Text" + } + ], + "nullable": [ + true, + true, + true, + true, + true + ], + "parameters": { + "Right": 0 + } + }, + "query": "SELECT * FROM FRONT" + }, + "0c63cfa40737ab6696c659cdf6c672d7a0ae004f6b6642bc094293525d4aa2d6": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 1 + } + }, + "query": "DELETE FROM FRONT WHERE channel_id=?" + }, + "0e3d95114cb3809601f7c6716850c4081db1cd455b4ab117e25129c32249c607": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 5 + } + }, + "query": "INSERT INTO FRONT (activated, releases, comments, channel_id, urls) VALUES(?1, ?2, ?3, ?4, ?5)" + }, "180823bc2240d852547538c642379a36e09e8d7094a1d70a5b8613d3bb9d0dbd": { "describe": { "columns": [], @@ -85,5 +147,15 @@ } }, "query": "SELECT * FROM Main" + }, + "3ded2174058a711f06e601ab1a29d921ebcdf6478895487ea4dffc8e60115d39": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 2 + } + }, + "query": "UPDATE FRONT SET activated=? WHERE channel_id=?" } } \ No newline at end of file diff --git a/src/commands/activity.rs b/src/commands/activity.rs new file mode 100644 index 0000000..aaaf662 --- /dev/null +++ b/src/commands/activity.rs @@ -0,0 +1,25 @@ +use serenity::model::prelude::command::CommandOptionType; +use serenity::{builder::CreateApplicationCommand, prelude::Context}; +use serenity::model::Permissions; +use serenity::model::prelude::Activity; +use serenity::model::prelude::interaction::application_command::CommandDataOption; + +pub async fn run(options: &[CommandDataOption], ctx: &Context) -> String { + let act = &options.get(0).unwrap().value.as_ref().unwrap().as_str().unwrap(); + ctx.set_activity(Activity::listening(act)).await; + "Activity changed.".to_string() +} + +pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { + command + .name("activity").description("Change the activity status of the bot.") + .default_member_permissions(Permissions::ADMINISTRATOR) + .create_option(|option| { + option + .name("listens to") + .description("listening to what") + .kind(CommandOptionType::String) + .min_length(2) + .required(true) + }) +} diff --git a/src/commands/create.rs b/src/commands/create.rs new file mode 100644 index 0000000..68f120e --- /dev/null +++ b/src/commands/create.rs @@ -0,0 +1,56 @@ +use serenity::builder::CreateApplicationCommand; +use serenity::model::Permissions; +use serenity::model::prelude::ChannelId; +use serenity::model::prelude::interaction::application_command::CommandDataOption; +use serenity::model::prelude::command::CommandOptionType; + +use crate::database::{check_for_channel_id, add_discord_channel}; +use crate::DiscordChannel; + +pub async fn run(options: &[CommandDataOption], channel_id: ChannelId) -> String { + let url_input = &options.get(0).unwrap().value.as_ref().unwrap().as_str().unwrap(); + let releases = &options.get(1).unwrap().value.as_ref().unwrap().as_bool().unwrap(); + let comments = &options.get(2).unwrap().value.as_ref().unwrap().as_bool().unwrap(); + let channel_id = channel_id.0 as i64; + if ! check_for_channel_id(channel_id).await.unwrap().is_empty() + { + return "This discord channel has already been configured, please make sure to `/reset` it before adding new settings.".to_string(); + } + let urls: Vec = url_input.split(", ").map(|str| str.to_string()).collect(); + add_discord_channel(DiscordChannel { + activated: true, + releases: *releases, + comments: *comments, + channel_id, + urls: urls.clone() + }).await.unwrap(); + println!("{:?} configured with {:?} | {} {}", channel_id, urls, releases, comments); + "Channel successfully configured.".to_string() +} + +pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { + command + .name("create").description("Setup notifications for the current channel") + .create_option(|option| { + option + .name("url") + .description("List of nyaa url's") + .kind(CommandOptionType::String) + .min_length(15) + .required(true) + }) + .create_option(|option| { + option + .name("releases") + .description("Notifications for releases") + .kind(CommandOptionType::Boolean) + .required(true) + }) + .create_option(|option| { + option + .name("comments") + .description("Notifications for comments") + .kind(CommandOptionType::Boolean) + .required(true) + }).default_member_permissions(Permissions::ADMINISTRATOR) +} diff --git a/src/commands/help.rs b/src/commands/help.rs new file mode 100644 index 0000000..0ee312d --- /dev/null +++ b/src/commands/help.rs @@ -0,0 +1,20 @@ +use serenity::builder::CreateApplicationCommand; +use serenity::model::Permissions; +use serenity::model::prelude::interaction::application_command::CommandDataOption; + +pub fn run(_options: &[CommandDataOption]) -> String { + "```\ +[Nyaa-Notifications] + +Commands: + \"help\" - Print this help message + \"create\" - Setup notifications for the current channel + \"reset\" - Remove notifications for the current channel + \"pause\" - Pause all notifications for this channel + \"resume\" - Resume all notifications for this channel```".to_string() +} + +pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { + command.name("help").description("Small description of available commands") + .default_member_permissions(Permissions::ADMINISTRATOR) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..8b29d96 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,5 @@ +pub mod help; +pub mod create; +pub mod reset; +pub mod pause_unpause; +pub mod activity; diff --git a/src/commands/pause_unpause.rs b/src/commands/pause_unpause.rs new file mode 100644 index 0000000..330292b --- /dev/null +++ b/src/commands/pause_unpause.rs @@ -0,0 +1,47 @@ +use serenity::{builder::CreateApplicationCommand, model::prelude::command::CommandOptionType}; +use serenity::model::Permissions; +use serenity::model::prelude::ChannelId; +use serenity::model::prelude::interaction::application_command::CommandDataOption; + +use crate::database::{check_for_channel_id, update_discord_bot}; + +pub async fn run(options: &[CommandDataOption], channel_id: ChannelId) -> String { + let channel_id = channel_id.0 as i64; + let input = &options.get(0).unwrap().value.as_ref().unwrap().as_bool().unwrap(); + let check = check_for_channel_id(channel_id).await.unwrap(); + if check.is_empty() + { + return "This discord channel has not been configured yet. Type `/create` to set it up.".to_string(); + } + else if check.get(0).unwrap().activated && ! *input + { + return "This discord channel is not paused to begin with.\nYou might want to check out `/pause True`.".to_string(); + } + else if ! check.get(0).unwrap().activated && *input + { + return "This discord channel is already paused.\nYou might want to check out `/pause False`.".to_string(); + } + if *input + { + update_discord_bot(channel_id, true, false).await.unwrap(); + } + else + { + update_discord_bot(channel_id, false, false).await.unwrap(); + } + println!("Notifications now {:?} for {:?}", input, channel_id); + "Channel configuration successfully edited.".to_string() +} + +pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { + command + .name("pause").description("Change the notification-state for the current channel") + .default_member_permissions(Permissions::ADMINISTRATOR) + .create_option(|option| { + option + .name("yesno") + .description("Pause notifications or resume them") + .kind(CommandOptionType::Boolean) + .required(true) + }) +} diff --git a/src/commands/reset.rs b/src/commands/reset.rs new file mode 100644 index 0000000..7f61acb --- /dev/null +++ b/src/commands/reset.rs @@ -0,0 +1,23 @@ +use serenity::builder::CreateApplicationCommand; +use serenity::model::Permissions; +use serenity::model::prelude::ChannelId; +use serenity::model::prelude::interaction::application_command::CommandDataOption; + +use crate::database::{check_for_channel_id, update_discord_bot}; + +pub async fn run(_options: &[CommandDataOption], channel_id: ChannelId) -> String { + let channel_id = channel_id.0 as i64; + if check_for_channel_id(channel_id).await.unwrap().is_empty() + { + return "This discord channel has not been configured. Type `/create` to set it up.".to_string(); + } + update_discord_bot(channel_id, false, true).await.unwrap(); + println!("Configuration removed for {:?}", channel_id); + "Channel configuration successfully removed.".to_string() +} + +pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { + command + .name("reset").description("Remove configurations for the current channel") + .default_member_permissions(Permissions::ADMINISTRATOR) +} diff --git a/src/database.rs b/src/database.rs index d565428..9305dbd 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,7 +1,7 @@ -use crate::{NyaaTorrent, Update}; +use crate::{NyaaTorrent, Update, DiscordChannel}; +use sqlx::Row; - -pub async fn updates_to_database(updates: &[Update]) -> Result<(), sqlx::Error> { +pub async fn updates_to_main_database(updates: &[Update]) -> Result<(), sqlx::Error> { let database = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(2) .connect_with( @@ -33,7 +33,7 @@ pub async fn updates_to_database(updates: &[Update]) -> Result<(), sqlx::Error> } -pub async fn get_database() -> Result, sqlx::Error> { +pub async fn get_main_database() -> Result, sqlx::Error> { let database = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(2) .connect_with( @@ -63,4 +63,171 @@ pub async fn get_database() -> Result, sqlx::Error> { database.close().await; Ok([].to_vec()) } -} \ No newline at end of file +} + +pub async fn add_discord_channel(channel: DiscordChannel) -> Result<(), sqlx::Error> { + let database = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(2) + .connect_with( + sqlx::sqlite::SqliteConnectOptions::new() + .filename("./data/nyaa-notifs.sqlite") + .create_if_missing(true), + ).await.unwrap(); + let url_string = + { + let mut str: String = String::new(); + for url in channel.urls + { + str.push_str(&(url+",")); + } + str = str.trim_end_matches(", ").to_string(); + str + }; + let activated = channel.activated.to_string(); + let release = channel.releases.to_string(); + let comments = channel.comments.to_string(); + + sqlx::query!("INSERT INTO FRONT (activated, releases, comments, channel_id, urls) VALUES(?1, ?2, ?3, ?4, ?5)", + activated, release, comments, channel.channel_id, url_string + ).execute(&database).await.expect("insert error"); + sqlx::query(format!("CREATE TABLE _{:?} ( + Category TEXT NO NULL, + Title TEXT NOT NULL, + Comments INTEGER, + Magnet TEXT NOT NULL, + Torrent_File TEXT NOT NULL, + Seeders INTERGER, + Leechers INTEGER, + Completed INTEGER, + Timestamp INTEGER + )", channel.channel_id).as_str()).execute(&database).await.unwrap(); + println!("Added new discord channel"); + database.close().await; + Ok(()) +} + +pub async fn check_for_channel_id(channel_id: i64) -> Result, sqlx::Error> { + let channels = get_discord_channels().await.unwrap(); + for channel in channels + { + if channel.channel_id == channel_id + { + return Ok(vec![channel]); + } + } + Ok(vec![]) +} + +pub async fn get_discord_channels() -> Result, sqlx::Error> { + let database = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(2) + .connect_with( + sqlx::sqlite::SqliteConnectOptions::new() + .filename("./data/nyaa-notifs.sqlite") + .create_if_missing(true), + ).await.unwrap(); + let channels: Vec = sqlx::query!("SELECT * FROM FRONT").fetch_all(&database).await.unwrap().iter().map(|record| DiscordChannel { + activated: record.activated.clone().unwrap() == "true", + releases: record.releases.clone().unwrap() == "true", + comments: record.comments.clone().unwrap() == "true", + channel_id: record.channel_id.unwrap() as i64, + urls: record.urls.clone().unwrap().split(',').map(|str| str.to_string()).collect() + }).collect(); + database.close().await; + Ok(channels) +} + +pub async fn update_discord_bot(channel_id: i64, pause: bool, reset: bool) -> Result<(), sqlx::Error> { + let database = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(2) + .connect_with( + sqlx::sqlite::SqliteConnectOptions::new() + .filename("./data/nyaa-notifs.sqlite") + .create_if_missing(true), + ).await.unwrap(); + if reset { + sqlx::query!("DELETE FROM FRONT WHERE channel_id=?", + channel_id).execute(&database).await.expect("insert error"); + sqlx::query(format!("DROP TABLE _{}", channel_id).as_str()).execute(&database).await.unwrap(); + } + else + { + let activated = if pause { + false.to_string() + } else { + true.to_string() + }; + sqlx::query!("UPDATE FRONT SET activated=? WHERE channel_id=?", + activated, channel_id).execute(&database).await.expect("insert error"); + } + database.close().await; + Ok(()) +} + +pub async fn get_channel_database(channel_id: i64) -> Result, sqlx::Error> { + let database = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(2) + .connect_with( + sqlx::sqlite::SqliteConnectOptions::new() + .filename("./data/nyaa-notifs.sqlite") + .create_if_missing(true), + ).await.unwrap(); + let db = sqlx::query(format!("SELECT * FROM _{}", &channel_id).as_str()).fetch_all(&database).await.unwrap(); + let mut torrents: Vec = vec![]; + + for row in db { + let comments: f64 = row.get_unchecked(2); + let seeders: f64 = row.get_unchecked(5); + let leechers: f64 = row.get_unchecked(6); + let completed: f64 = row.get_unchecked(7); + let timestamp: f64 = row.get_unchecked(8); + + let torrent = NyaaTorrent { + category: row.get(0), + title: row.get(1), + magnet: row.get(3), + torrent_file: row.get(4), + size: "NULL".to_string(), + date: "NULL".to_string(), + uploader_avatar: None, + seeders: seeders as u64, + comments: comments as u64, + leechers: leechers as u64, + completed: completed as u64, + timestamp: timestamp as u64 + }; + torrents.append(&mut vec![torrent]); + } + Ok(torrents) +} + +pub async fn update_channel_db(channel_id: i64, updates: &[Update]) -> Result<(), sqlx::Error> { + let database = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(2) + .connect_with( + sqlx::sqlite::SqliteConnectOptions::new() + .filename("./data/nyaa-notifs.sqlite") + .create_if_missing(true), + ).await.unwrap(); + for update in updates.iter().cloned() { + let comments = update.nyaa_torrent.comments as i64; + let seeders = update.nyaa_torrent.seeders as i64; + let leechers = update.nyaa_torrent.leechers as i64; + let completed = update.nyaa_torrent.completed as i64; + let timestamp = update.nyaa_torrent.timestamp as i64; + if update.new_torrent { + sqlx::query(format!("INSERT INTO _{} (Category, Title, Comments, Magnet, Torrent_File, Seeders, Leechers, Completed, Timestamp) + VALUES ({:?}, {:?}, {:?}, {:?}, {:?}, {:?}, {:?}, {:?}, {:?})", + channel_id, update.nyaa_torrent.category, update.nyaa_torrent.title, comments, update.nyaa_torrent.magnet, update.nyaa_torrent.torrent_file, + seeders, leechers, completed, timestamp).as_str() + ).execute(&database).await.expect("insert error"); + } else { + sqlx::query(format!("UPDATE _{} SET Category={:?}, Title={:?}, Comments={:?}, Seeders={:?}, Leechers={:?}, Completed={:?} WHERE Torrent_File={:?}", + channel_id, update.nyaa_torrent.category, update.nyaa_torrent.title, comments, seeders, leechers, completed, update.nyaa_torrent.torrent_file).as_str() + ).execute(&database).await.expect("insert error"); + } + }; + println!("Updated discord database"); + database.close().await; + Ok(()) +} diff --git a/src/html.rs b/src/html.rs index 864c2af..e18b3f3 100644 --- a/src/html.rs +++ b/src/html.rs @@ -174,7 +174,7 @@ pub fn serizalize_torrent_page(website: &str) -> Result, String } -pub fn serizalize_user_page(website: &str) -> Result { +pub fn serizalize_search_page(website: &str) -> Result { if ! website.starts_with(&"".to_string()) { return Err("This is not plaintext html code!".to_string()) }; @@ -195,7 +195,6 @@ pub fn serizalize_user_page(website: &str) -> Result { for line in lines.collect::>().split_at(worthy_text).1 { body.append(&mut [line.to_string()].to_vec()); }; - let mut user = String::from("None"); let mut torrent_count = 0; let mut body_iterator = body.iter(); let mut torrent_list_end: bool = false; @@ -205,10 +204,6 @@ pub fn serizalize_user_page(website: &str) -> Result { while let Some(line) = body_iterator.next() { let x = line.trim(); if x.contains("Browsing "#).unwrap(); - text = text.strip_suffix(r#"'s torrents"#).unwrap(); - user = text.to_string(); let x = body_iterator.next().unwrap(); torrent_count = x.trim()[1..x.trim().len() - 1].parse::().unwrap(); break @@ -373,7 +368,6 @@ pub fn serizalize_user_page(website: &str) -> Result { } } Ok(NyaaPage { - user, torrent_count, torrents, incomplete @@ -397,13 +391,19 @@ pub fn get_uploader_avatar(html: String) -> String { }; let mut avatar: String = String::new(); + let mut delim = 0; for ch in worthy_text.chars() { if ch == '"' { - break + if delim == 3 { + continue; + } + delim += 1; + continue; + } + if delim == 3 && ch != '>' { + avatar.push(ch); } - avatar.push(ch); } - avatar } @@ -429,20 +429,22 @@ pub fn get_uploader_name(html: String) -> Option { let mut seperator: u8 = 0; for ch in worthy_text.chars() { if ch == '|' { - if seperator == 2 { + if seperator == 1 { stage1 = true; } else { - seperator += seperator; + seperator += 1; } continue; }; if ch == ' ' && stage1 { - if seperator == 5 { + if seperator == 3 && ! record { record = true; - } else if seperator == 6 { + continue; + } else if seperator == 3 && record { break; } else { - seperator += seperator; + seperator += 1; + continue; } }; if record { diff --git a/src/main.rs b/src/main.rs index cdae022..9a78e33 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,7 @@ use std::path::Path; use std::fs; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::io::prelude::*; use std::time::Duration; use std::fmt::{self, Debug}; @@ -10,23 +12,22 @@ use lettre::{ transport::smtp::authentication::Credentials, AsyncSmtpTransport, AsyncTransport, message::{header, MultiPart, SinglePart}, Tokio1Executor, Message }; -use serenity::{json::json, utils::Color}; -use serenity::async_trait; -use serenity::prelude::*; -use serenity::model::prelude::*; -use serenity::framework::standard::macros::group; -use serenity::framework::standard::{StandardFramework}; +use serenity::framework::StandardFramework; +use serenity::{json::json, utils::Color, async_trait, prelude::*, model::prelude::*}; +use serenity::model::application::interaction::Interaction; +use serenity::model::application::interaction::InteractionResponseType; +use serenity::model::application::command::Command; use http::StatusCode; -use isahc::prelude::Configurable; -use isahc::ReadResponseExt; -use isahc::{Request, RequestExt}; +use isahc::{Request, RequestExt, ReadResponseExt, prelude::Configurable}; use serde_derive::{Deserialize, Serialize}; pub mod database; pub mod html; +mod commands; + +use database::{get_main_database, updates_to_main_database, get_discord_channels, get_channel_database, update_channel_db}; +use html::{serizalize_torrent_page, serizalize_search_page, get_uploader_avatar, get_uploader_name}; -use database::{get_database, updates_to_database}; -use html::{serizalize_torrent_page, serizalize_user_page, get_uploader_avatar, get_uploader_name}; #[derive(Clone, Debug)] pub struct NyaaComment { @@ -40,7 +41,6 @@ pub struct NyaaComment { #[derive(Clone, Debug)] pub struct NyaaPage { - pub user: String, pub torrent_count: u64, pub torrents: Vec, pub incomplete: bool @@ -67,6 +67,7 @@ pub struct NyaaTorrent { #[derive(Debug, Clone)] pub struct Update { pub nyaa_comments: Vec, + pub nyaa_url: String, pub new_comments: u64, pub nyaa_torrent: NyaaTorrent, pub new_torrent: bool @@ -76,18 +77,26 @@ pub struct Update { #[derive(Debug, Deserialize, Serialize, Clone)] struct ConfigFile { main: Main, - discord_bot: DiscordBot, + discord_bot: DiscordInstance, smtp: Smtp, gotfiy: Gotify, } #[derive(Debug, Deserialize, Serialize, Clone)] -struct DiscordBot { +struct DiscordInstance { enabled: bool, - comment_notifications: bool, discord_token: String, - channel_ids: Vec +} + + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct DiscordChannel { + pub activated: bool, + pub releases: bool, + pub comments: bool, + pub channel_id: i64, + pub urls: Vec } @@ -125,7 +134,7 @@ struct Main { impl std::fmt::Display for NyaaPage { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f, "Uploader: {}\nTorrents: {}\nMultiple pages: {}", self.user, self.torrent_count, self.incomplete).expect("failed to fmt"); + writeln!(f, "Torrents: {}\nMultiple pages: {}", self.torrent_count, self.incomplete).expect("failed to fmt"); writeln!(f, "Torrents:").expect("failed to fmt"); self.torrents.iter().fold(Ok(()), |result, nyaatorrent| { result.and_then(|_| writeln!(f, "{}", nyaatorrent)) @@ -141,157 +150,111 @@ impl std::fmt::Display for NyaaTorrent { } } - -#[group] -struct General; struct Handler { - config_clone: ConfigFile + config_clone: ConfigFile, + running_loop: AtomicBool, } -lazy_static::lazy_static! { - static ref NYA_CONNECTED: Mutex> = Mutex::new(vec![]); -} #[async_trait] impl EventHandler for Handler { + async fn interaction_create(&self, ctx: Context, interaction: Interaction) { + if let Interaction::ApplicationCommand(command) = interaction { + // println!("Received command interaction: {:#?}", command); + let content = match command.data.name.as_str() { + "help" => commands::help::run(&command.data.options), + "create" => commands::create::run(&command.data.options, command.channel_id).await, + "reset" => commands::reset::run(&command.data.options, command.channel_id).await, + "pause" => commands::pause_unpause::run(&command.data.options, command.channel_id).await, + "activity" => commands::activity::run(&command.data.options, &ctx).await, + _ => "not implemented :(".to_string(), + }; + if let Err(why) = command + .create_interaction_response(&ctx.http, |response| { + response + .kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|message| message.content(content)) + }) + .await + { + println!("Cannot respond to slash command: {}", why); + } + } + } async fn ready(&self, ctx: Context, ready: Ready) { println!("{} is connected!", ready.user.name); - ctx.set_activity(Activity::watching("japanese cats.")).await; + ctx.set_activity(Activity::listening("japanese cats.")).await; + Command::create_global_application_command(&ctx.http, |command| { + commands::help::register(command) + }).await.unwrap(); + Command::create_global_application_command(&ctx.http, |command| { + commands::create::register(command) + }).await.unwrap(); + Command::create_global_application_command(&ctx.http, |command| { + commands::reset::register(command) + }).await.unwrap(); + Command::create_global_application_command(&ctx.http, |command| { + commands::pause_unpause::register(command) + }).await.unwrap(); + Command::create_global_application_command(&ctx.http, |command| { + commands::activity::register(command) + }).await.unwrap(); let config_clone = self.config_clone.clone(); - tokio::spawn(async move { - if NYA_CONNECTED.lock().await.len() != 0 { - return; - } else { - NYA_CONNECTED.lock().await.push(1); - }; - loop { - println!("Checking at: {}", chrono::Local::now()); - for nyaa_url in config_clone.clone().main.nyaa_url { - let updates = nyaa_check(&config_clone, &nyaa_url).await; - if config_clone.gotfiy.enabled || config_clone.smtp.enabled { - send_notification(&config_clone, &updates).await.unwrap(); - }; - for update in updates { - for channel_id in config_clone.discord_bot.channel_ids.clone() { - if update.new_torrent { - let timestamp = &update.nyaa_torrent.timestamp.to_string()[0..update.nyaa_torrent.timestamp.to_string().len()].parse::().unwrap(); - let nanosec = &update.nyaa_torrent.timestamp.to_string()[update.nyaa_torrent.timestamp.to_string().len() - 3..update.nyaa_torrent.timestamp.to_string().len()].parse::().unwrap(); - let utc_time = chrono::DateTime::::from_utc(NaiveDateTime::from_timestamp_opt(*timestamp, *nanosec).unwrap(), chrono::Utc); - let avatar = if update.nyaa_torrent.uploader_avatar.is_some() { - update.nyaa_torrent.uploader_avatar.clone().unwrap().replace("amp;", "") + let ctx = Arc::new(ctx); + if self.running_loop.load(Ordering::Relaxed) { + let ctx1 = Arc::clone(&ctx); + tokio::spawn(async move { + println!("Starting Loop"); + loop { + println!("Checking at: {}", chrono::Local::now()); + let channels = get_discord_channels().await.unwrap(); + for channel in channels { + if channel.activated { + let database = get_channel_database(channel.channel_id).await.unwrap(); + for url in channel.urls { + if url.is_empty() { + continue; + } + if database.is_empty() { + let mut edited_config = config_clone.clone(); + edited_config.gotfiy.comment_notifications = false; + edited_config.smtp.comment_notifications = false; + edited_config.discord_bot.enabled = false; + let updates = nyaa_check(&edited_config, &url, database.clone(), false).await; + update_channel_db(channel.channel_id, &updates).await.unwrap(); + continue; + } + let updates = nyaa_check(&config_clone, &url, database.clone(), true).await; + if updates.is_empty() { + println!("NO UPDATES"); } else { - "https://avatars3.githubusercontent.com/u/28658394?v=4&s=400".to_owned() - }; - let discord_message = ChannelId(channel_id) - .send_message(&ctx, |m| { - m.embed(|e| { - e.title(update.nyaa_torrent.title.clone()) - .color(Color::BLITZ_BLUE) - .thumbnail(avatar) - .author(|a| { - a.name("Found in this feed!") - .url(nyaa_url.clone()) - }) - .description("A new release!") - .fields(vec![ - ("Category", update.nyaa_torrent.category.clone(), false), - ("Size", update.nyaa_torrent.size.clone(), false) - ]) - .timestamp(utc_time) - }).components(|c| { - c.create_action_row(|r| { - r.create_button(|b| { - b.label("Nyaa.si") - .url(update.nyaa_torrent.torrent_file.replace("download", "view").strip_suffix(".torrent").unwrap()) - .style(serenity::model::prelude::component::ButtonStyle::Link) - }) - .create_button(|b| { - b.label("Torrent-File") - .url(update.nyaa_torrent.torrent_file.clone()) - .style(serenity::model::prelude::component::ButtonStyle::Link) - }) - }) - }) - }).await; - if let Err(w) = discord_message { - eprintln!("Failed to create message: {:?}", w) - }; - }; - if update.new_comments > 0 && config_clone.discord_bot.comment_notifications { - for comment_index in update.nyaa_comments.len() as u64 - update.new_comments..update.nyaa_comments.len() as u64 { - let nyaa_comment = update.nyaa_comments.get(comment_index as usize).unwrap(); - let timestamp_op1 = if nyaa_comment.timestamp.contains('.') { - let mut temp: String = String::new(); - for ch in nyaa_comment.timestamp.chars() { - if ch == '.' { - break + for update in updates.clone() { + if update.new_torrent && channel.releases { + let channel_id = channel.channel_id as u64; + let timestamp = &update.nyaa_torrent.timestamp.to_string()[0..update.nyaa_torrent.timestamp.to_string().len()].parse::().unwrap(); + let nanosec = &update.nyaa_torrent.timestamp.to_string()[update.nyaa_torrent.timestamp.to_string().len() - 3..update.nyaa_torrent.timestamp.to_string().len()].parse::().unwrap(); + let utc_time = chrono::DateTime::::from_utc(NaiveDateTime::from_timestamp_opt(*timestamp, *nanosec).unwrap(), chrono::Utc); + let avatar = if update.nyaa_torrent.uploader_avatar.is_some() { + update.nyaa_torrent.uploader_avatar.clone().unwrap().replace("amp;", "") } else { - temp.push_str(ch.to_string().as_str()) - } - }; - temp - } else { - nyaa_comment.timestamp.to_string() - }; - let seconds = timestamp_op1[0..timestamp_op1.len()].parse::().unwrap(); - let nanosec = timestamp_op1[timestamp_op1.len() - 3..timestamp_op1.len()].parse::().unwrap(); - let utc_time_comment = chrono::DateTime::::from_utc(NaiveDateTime::from_timestamp_opt(seconds, nanosec).unwrap(), chrono::Utc); - if nyaa_comment.user.len() + nyaa_comment.message.len() <= 1024 { - let discord_message = ChannelId(channel_id) - .send_message(&ctx, |m| { - m.embed(|e| { - e.title(update.nyaa_torrent.title.clone()) - .color(Color::BLITZ_BLUE) - .thumbnail(nyaa_comment.gravatar.clone().replace("amp;", "")) - .author(|a| { - a.name("Found in this feed!") - .url(nyaa_url.clone()) - }) - .fields(vec![ - (nyaa_comment.user.clone() + ":", nyaa_comment.message.clone(), false), - ]) - .timestamp(utc_time_comment) - }).components(|c| { - c.create_action_row(|r| { - r.create_button(|b| { - b.label("Nyaa.si") - .url(update.nyaa_torrent.torrent_file.replace("download", "view").strip_suffix(".torrent").unwrap()) - .style(serenity::model::prelude::component::ButtonStyle::Link) - }) - .create_button(|b| { - b.label(nyaa_comment.user.clone()) - .url(format!("https://nyaa.si/user/{}", nyaa_comment.user.clone())) - .style(serenity::model::prelude::component::ButtonStyle::Link) - }) - }) - }) - }).await; - if let Err(w) = discord_message { - eprintln!("Failed to create message: {:?}", w) - }; - } else { - let amount = ((nyaa_comment.user.len() as f64 + nyaa_comment.message.len() as f64) / 500.0).ceil() as u32; - let mut comment = nyaa_comment.message.clone(); - for index in 1..amount { - let cut = if comment.len() > 500 { - 500 - } else { - comment.len() + "https://avatars3.githubusercontent.com/u/28658394?v=4&s=400".to_owned() }; let discord_message = ChannelId(channel_id) - .send_message(&ctx, |m| { + .send_message(&ctx1, |m| { m.embed(|e| { e.title(update.nyaa_torrent.title.clone()) .color(Color::BLITZ_BLUE) - .thumbnail(nyaa_comment.gravatar.clone().replace("amp;", "")) + .thumbnail(avatar) .author(|a| { a.name("Found in this feed!") - .url(nyaa_url.clone()) + .url(update.nyaa_url.clone()) }) + .description("A new release!") .fields(vec![ - (nyaa_comment.user.clone() + " (" + &index.to_string() + "/" + &(amount-1).to_string() + ")" + ":", &comment[..cut], false), + ("Category", update.nyaa_torrent.category.clone(), false), + ("Size", update.nyaa_torrent.size.clone(), false) ]) - .timestamp(utc_time_comment) + .timestamp(utc_time) }).components(|c| { c.create_action_row(|r| { r.create_button(|b| { @@ -300,8 +263,8 @@ impl EventHandler for Handler { .style(serenity::model::prelude::component::ButtonStyle::Link) }) .create_button(|b| { - b.label(nyaa_comment.user.clone()) - .url(format!("https://nyaa.si/user/{}", nyaa_comment.user.clone())) + b.label("Torrent-File") + .url(update.nyaa_torrent.torrent_file.clone()) .style(serenity::model::prelude::component::ButtonStyle::Link) }) }) @@ -309,19 +272,132 @@ impl EventHandler for Handler { }).await; if let Err(w) = discord_message { eprintln!("Failed to create message: {:?}", w) - } else { - comment = comment[500..comment.len()].to_string(); - } + }; + } + if update.new_comments > 0 && channel.comments { + let channel_id = channel.channel_id as u64; + for comment_index in update.nyaa_comments.len() as u64 - update.new_comments..update.nyaa_comments.len() as u64 { + let nyaa_comment = update.nyaa_comments.get(comment_index as usize).unwrap(); + let timestamp_op1 = if nyaa_comment.timestamp.contains('.') { + let mut temp: String = String::new(); + for ch in nyaa_comment.timestamp.chars() { + if ch == '.' { + break + } else { + temp.push_str(ch.to_string().as_str()) + } + }; + temp + } else { + nyaa_comment.timestamp.to_string() + }; + let seconds = timestamp_op1[0..timestamp_op1.len()].parse::().unwrap(); + let nanosec = timestamp_op1[timestamp_op1.len() - 3..timestamp_op1.len()].parse::().unwrap(); + let utc_time_comment = chrono::DateTime::::from_utc(NaiveDateTime::from_timestamp_opt(seconds, nanosec).unwrap(), chrono::Utc); + if nyaa_comment.user.len() + nyaa_comment.message.len() <= 1024 { + let discord_message = ChannelId(channel_id) + .send_message(&ctx1, |m| { + m.embed(|e| { + e.title(update.nyaa_torrent.title.clone()) + .color(Color::BLITZ_BLUE) + .thumbnail(nyaa_comment.gravatar.clone().replace("amp;", "")) + .author(|a| { + a.name("Found in this feed!") + .url(update.nyaa_url.clone()) + }) + .fields(vec![ + (nyaa_comment.user.clone() + ":", nyaa_comment.message.clone(), false), + ]) + .timestamp(utc_time_comment) + }).components(|c| { + c.create_action_row(|r| { + r.create_button(|b| { + b.label("Nyaa.si") + .url(update.nyaa_torrent.torrent_file.replace("download", "view").strip_suffix(".torrent").unwrap()) + .style(serenity::model::prelude::component::ButtonStyle::Link) + }) + .create_button(|b| { + b.label(nyaa_comment.user.clone()) + .url(format!("https://nyaa.si/user/{}", nyaa_comment.user.clone())) + .style(serenity::model::prelude::component::ButtonStyle::Link) + }) + }) + }) + }).await; + if let Err(w) = discord_message { + eprintln!("Failed to create message: {:?}", w) + }; + } else { + let amount = ((nyaa_comment.user.len() as f64 + nyaa_comment.message.len() as f64) / 500.0).ceil() as u32; + let mut comment = nyaa_comment.message.clone(); + for index in 1..amount { + let cut = if comment.len() > 500 { + 500 + } else { + comment.len() + }; + let discord_message = ChannelId(channel_id) + .send_message(&ctx1, |m| { + m.embed(|e| { + e.title(update.nyaa_torrent.title.clone()) + .color(Color::BLITZ_BLUE) + .thumbnail(nyaa_comment.gravatar.clone().replace("amp;", "")) + .author(|a| { + a.name("Found in this feed!") + .url(update.nyaa_url.clone()) + }) + .fields(vec![ + (nyaa_comment.user.clone() + " (" + &index.to_string() + "/" + &(amount-1).to_string() + ")" + ":", &comment[..cut], false), + ]) + .timestamp(utc_time_comment) + }).components(|c| { + c.create_action_row(|r| { + r.create_button(|b| { + b.label("Nyaa.si") + .url(update.nyaa_torrent.torrent_file.replace("download", "view").strip_suffix(".torrent").unwrap()) + .style(serenity::model::prelude::component::ButtonStyle::Link) + }) + .create_button(|b| { + b.label(nyaa_comment.user.clone()) + .url(format!("https://nyaa.si/user/{}", nyaa_comment.user.clone())) + .style(serenity::model::prelude::component::ButtonStyle::Link) + }) + }) + }) + }).await; + if let Err(w) = discord_message { + eprintln!("Failed to create message: {:?}", w) + } else { + comment = comment[500..comment.len()].to_string(); + } + } + } + }; } } - }; + update_channel_db(channel.channel_id, &updates).await.unwrap(); + } } } } + for nyaa_url in config_clone.clone().main.nyaa_url { + if config_clone.gotfiy.enabled || config_clone.smtp.enabled { + let database = get_main_database().await.unwrap(); + let updates = nyaa_check(&config_clone, &nyaa_url, database, false).await; + if updates.is_empty() { + println!("NO UPDATES"); + } else { + updates_to_main_database(&updates).await.unwrap(); + send_notification(&config_clone, &updates).await.unwrap(); + } + }; + } + println!("Finished update-check."); + tokio::time::sleep(Duration::from_secs(config_clone.main.update_delay)).await; } - thread::sleep(Duration::from_secs(config_clone.main.update_delay)); - } - }); + }); + self.running_loop.swap(true, Ordering::Relaxed); + } } } @@ -334,14 +410,9 @@ async fn main() { complete_result: false, update_delay: 500 }, - discord_bot: DiscordBot { + discord_bot: DiscordInstance { enabled: false, - comment_notifications: false, - discord_token: "".to_string(), - channel_ids: [ - 69_u64, - 420_u64 - ].to_vec() + discord_token: "".to_string() }, smtp: Smtp { enabled: false, @@ -374,6 +445,17 @@ async fn main() { println!("Please edit the config file and restart the application."); exit(0x0100); }; + let database = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(5) + .connect_with( + sqlx::sqlite::SqliteConnectOptions::new() + .filename("nyaa-notifs.sqlite") + .create_if_missing(true), + ) + .await + .expect("Couldn't connect to database"); + sqlx::migrate!("./migrations").run(&database).await.expect("Couldn't run database migrations"); + database.close().await; let config_file = toml::from_str::(&fs::read_to_string(Path::new("./data/config.toml")).expect("Failed reading config file.")).expect("Failed to deserialize config file."); let config_clone = config_file.clone(); if config_file.discord_bot.enabled { @@ -389,7 +471,13 @@ async fn main() { loop { println!("Checking at: {}", chrono::Local::now()); for nyaa_url in &config_clone.main.nyaa_url { - let updates = &nyaa_check(&config_clone, nyaa_url).await; + let database = get_main_database().await.unwrap(); + let updates = &nyaa_check(&config_clone, nyaa_url, database, false).await; + if updates.is_empty() { + println!("NO UPDATES"); + } else { + updates_to_main_database(updates).await.unwrap(); + } send_notification(&config_clone, updates).await.unwrap(); }; thread::sleep(Duration::from_secs(config_clone.main.update_delay)); @@ -400,7 +488,13 @@ async fn main() { tokio::spawn(async move { println!("Checking at: {}", chrono::Local::now()); for nyaa_url in &config_clone.main.nyaa_url { - let updates = &nyaa_check(&config_clone, nyaa_url).await; + let database = get_main_database().await.unwrap(); + let updates = &nyaa_check(&config_clone, nyaa_url, database, false).await; + if updates.is_empty() { + println!("NO UPDATES"); + } else { + updates_to_main_database(updates).await.unwrap(); + } send_notification(&config_clone, updates).await.unwrap(); }; println!("Done.") @@ -410,7 +504,8 @@ async fn main() { } -async fn nyaa_check(config_file: &ConfigFile, nyaa_url: &String) -> Vec { +async fn nyaa_check(config_file: &ConfigFile, nyaa_url: &String, database: Vec, discord: bool) -> Vec { + tokio::time::sleep(Duration::from_secs(2)).await; let mut updates: Vec = [].to_vec(); let mut nyaa_page_res = get_nyaa(nyaa_url); if nyaa_page_res.is_err() { @@ -421,7 +516,7 @@ async fn nyaa_check(config_file: &ConfigFile, nyaa_url: &String) -> Vec let mut page_array: Vec = [].to_vec(); let mut page_number = 2; loop { - match serizalize_user_page(&nyaa_page) { + match serizalize_search_page(&nyaa_page) { Ok(page) => { page_array.append(&mut [page.clone()].to_vec()); if page.incomplete && (config_file.main.complete_result || nyaa_url.contains('?')) { @@ -450,7 +545,6 @@ async fn nyaa_check(config_file: &ConfigFile, nyaa_url: &String) -> Vec } } }; - let database = get_database().await.unwrap(); // This might seem stupid, but considering some torrent lists could grow into thousands, checking for a new comment is a lot more effective this way. let mut torrent_file_links: String = String::new(); let database_iterator = database.iter(); @@ -461,7 +555,7 @@ async fn nyaa_check(config_file: &ConfigFile, nyaa_url: &String) -> Vec for mut torrent in page.torrents { if ! torrent_file_links.contains(&torrent.torrent_file) { let nyaa_comments_res: Result, ()> = if torrent.comments > 0 && - (config_file.discord_bot.enabled && config_file.discord_bot.comment_notifications || + (config_file.discord_bot.enabled || config_file.smtp.enabled && config_file.smtp.comment_notifications || config_file.gotfiy.enabled && config_file.gotfiy.comment_notifications) { println!("Waiting 2 seconds"); @@ -475,11 +569,12 @@ async fn nyaa_check(config_file: &ConfigFile, nyaa_url: &String) -> Vec }; let nyaa_comments = nyaa_comments_res.unwrap(); - if config_file.discord_bot.enabled { - let torrent_page_unv = get_nyaa(&torrent.torrent_file.replace("download", "view")); + if discord { + let torrent_page_unv = get_nyaa(&torrent.torrent_file.replace("download", "view").trim_end_matches(".torrent").to_string()); if let Ok(torrent_page) = torrent_page_unv { - let uploader: Option = get_uploader_name(torrent_page); // This time as option, since anonymous uploades are possible as well + let uploader: Option = get_uploader_name(torrent_page); // This time as an option, since anonymous uploades if let Some(name) = uploader { + println!("Waiting 2 seconds"); tokio::time::sleep(Duration::from_secs(2)).await; let torrent_page_unv = get_nyaa(&("https://nyaa.si/user/".to_owned()+&name)); if let Ok(user_page) = torrent_page_unv { @@ -490,6 +585,7 @@ async fn nyaa_check(config_file: &ConfigFile, nyaa_url: &String) -> Vec } updates.append(&mut [Update { nyaa_comments, + nyaa_url: nyaa_url.to_string(), new_comments: torrent.comments, nyaa_torrent: torrent, new_torrent: true @@ -507,6 +603,7 @@ async fn nyaa_check(config_file: &ConfigFile, nyaa_url: &String) -> Vec let nyaa_comments = nyaa_comments_res.unwrap(); updates.append(&mut [Update { nyaa_comments, + nyaa_url: nyaa_url.to_string(), new_comments: amount_new_comments, nyaa_torrent: torrent, new_torrent: false @@ -515,11 +612,6 @@ async fn nyaa_check(config_file: &ConfigFile, nyaa_url: &String) -> Vec } } } - if updates.is_empty() { - println!("NO UPDATES"); - } else { - updates_to_database(&updates).await.unwrap(); - } updates } @@ -547,12 +639,12 @@ async fn get_nyaa_comments(torrent: &NyaaTorrent) -> Result, () async fn discord_bot(config_file: &ConfigFile) -> Result<(), SerenityError> { let config_clone = config_file.to_owned(); - let framework = StandardFramework::new() - .group(&GENERAL_GROUP); - let intents = GatewayIntents::non_privileged(); + let framework = StandardFramework::new(); + let intents = GatewayIntents::non_privileged() | GatewayIntents::MESSAGE_CONTENT; let mut client = Client::builder(config_clone.discord_bot.discord_token.clone(), intents) .event_handler(Handler { - config_clone + config_clone, + running_loop: AtomicBool::new(false), }) .framework(framework) .await?; @@ -712,7 +804,7 @@ fn send_gotify(config_file: &ConfigFile, json: serde_json::Value) -> Result<(), fn get_nyaa(nyaa_url: &String) -> Result { - println!("Requesting: {}", nyaa_url); + println!("Requesting: {:?}", nyaa_url); let sending_request = Request::get(nyaa_url) .timeout(Duration::from_secs(10)) .body(()).expect("Failed to create request.").send();