From 0c87e5f56ac44664d8b9605fa59a8f533d3287eb Mon Sep 17 00:00:00 2001 From: Byte Dice Date: Sun, 6 Apr 2025 15:43:35 +0200 Subject: [PATCH] added support for language files --- .gitignore | 2 + Cargo.toml | 1 + TODO.md | 3 +- ...d_data_preset.json => dc_data_preset.json} | 0 data/lang/en.json | 62 ++ ...t_data_preset.json => re_data_preset.json} | 0 src/bk_week_cmds.rs | 752 ------------------ src/cmds.rs | 47 +- src/data.rs | 45 +- src/events.rs | 11 +- src/gen.rs | 96 +++ src/macros.rs | 36 + src/main.rs | 172 +--- src/messages.rs | 28 +- src/python.rs | 15 +- src/python/data.py | 6 +- src/python/main.py | 2 +- src/re_cmds/add.rs | 68 ++ src/re_cmds/admin_bind.rs | 28 + src/re_cmds/approve.rs | 58 ++ src/re_cmds/generic_fns.rs | 34 + src/re_cmds/get.rs | 50 ++ src/re_cmds/remove.rs | 40 + src/re_cmds/top.rs | 77 ++ src/re_cmds/update.rs | 305 +++++++ src/re_cmds/vote.rs | 67 ++ src/schedule.rs | 35 + src/websocket.rs | 20 +- 28 files changed, 1087 insertions(+), 973 deletions(-) rename data/{discord_data_preset.json => dc_data_preset.json} (100%) create mode 100644 data/lang/en.json rename data/{reddit_data_preset.json => re_data_preset.json} (100%) delete mode 100644 src/bk_week_cmds.rs create mode 100644 src/gen.rs create mode 100644 src/re_cmds/add.rs create mode 100644 src/re_cmds/admin_bind.rs create mode 100644 src/re_cmds/approve.rs create mode 100644 src/re_cmds/generic_fns.rs create mode 100644 src/re_cmds/get.rs create mode 100644 src/re_cmds/remove.rs create mode 100644 src/re_cmds/top.rs create mode 100644 src/re_cmds/update.rs create mode 100644 src/re_cmds/vote.rs create mode 100644 src/schedule.rs diff --git a/.gitignore b/.gitignore index 448c8eb..d226673 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,6 @@ __pycache__/ # program-created data data/reddit_data.json data/discord_data.json +data/re_data.json +data/dc_data.json data/cfg.json \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 53528d7..f35a927 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" [dependencies] clap = { version = "4.5.28", features = ["derive"] } +formatx = "0.2.3" futures = "0.3.31" poise = "0.6.1" pyo3 = "0.23.4" diff --git a/TODO.md b/TODO.md index 65d324e..a152e3f 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,7 @@ ### High priority: - [ ] Reddit bot that scrapes images with tag "Original Art" and posts them in Discord server + - [ ] Rename commands from "bk_week_{name}" to "re_{name}" @@ -10,7 +11,7 @@ - [ ] Allow disabling bk_week - - [ ] Language files? + - [ ] Language files - [ ] Allow updating the data autonomously and via manual commands. diff --git a/data/discord_data_preset.json b/data/dc_data_preset.json similarity index 100% rename from data/discord_data_preset.json rename to data/dc_data_preset.json diff --git a/data/lang/en.json b/data/lang/en.json new file mode 100644 index 0000000..59749e6 --- /dev/null +++ b/data/lang/en.json @@ -0,0 +1,62 @@ +{ + "8-ball_answer": "Q: {0}\nA: {1}", + "add_to_data": "Added your server to my data! Thanks for letting me steal it! (/s)", + "bound_dc_channel": "Successfully bound channel ID `{0}` as the bk_week channel!", + "cfg_data_404": "cfg.json not found,", + "cfg_data_wipe": "[WIPE] (cfg.json)", + "cmd_404": "No command with the name \"{0}\" found!\nHint: Try `/help` without any arguments or `/help `", + "corrupted_data": "Oopsies `(。>\\\\<)`. It looks like my data i-is \\**sob*\\*... c-corrupted!", + "couldnt_shorturl": "Couldn't convert to shortURL: Invalid URL", + "creating_data_file": "{0} creating new from preset...", + "data_post_embed": "Spoilers and vote length anonymizer for fair review!\n ## Post Data:\n **Post upvotes:** ||`{0:>6}`||\n **Moderator votes:** ||`{1:>6}`||\n **Media type:** `{2}`\n **URL:** ||<{3}>||\n\n ## Listing Data:\n **Added by:** `{{ human: {4}, bot: {5} }}`\n **Approved by:** `{{ human: {6}, bot: [not implemented] }}`", + "data_post_removed_embed": "## Removed by `{0}`\n**Reason:** {1}\nURL: ||<{2}>||\n\nJSON: ||`{3}`||", + "data_save_complete": "Saving data... Done!\nShutting down...", + "data_save_progress": "Saving data...", + "dc_404": "Your server is not in the data!\n Hint: Run the command `/add_server` inside of a Discord server.", + "dc_bot_started": "Bot started as user \"{0}\" with id {1}", + "dc_bot_starting": "Starting bot...", + "dc_data_404": "discord_data.json not found,", + "dc_data_wipe": "[WIPE] (discord_data.json)", + "default_embed_desc": "Default description", + "err_read_py_file": "Failed to read Python file!\nPath: {0}", + "help_text_removed": "Help text not found. Someone deleted it. :(", + "invalid_confirm_shutdown": "Failed to shut down: Invalid confirmation.", + "invalid_os": "Man what kinda OS do you have? Neither unix or windows, what the hell!? I can't process this anymore, you're too weird!", + "lang_data_404": "file for language \"{}\" not found!", + "lang_load_success": "Successfully loaded the english language file!", + "mandatory_response": "Mandatory response message, please ignore.", + "python_err": "Unknown internal Python Error: {0}", + "python_socket_binary_response": "[Binary] from Python: {0}", + "python_socket_err": "Unknown internal Python error occurred: Websocket response error.", + "python_socket_null": "Response from Python is None!", + "python_socket_response": "Received from Python: [RESPONSE] {0}", + "python_socket_unknown_response": "Received from Python: [UNKNOWN / OTHER]", + "re_404": "Post URL \"<{0}>\" not found: Post doesn't exist in the data!\n Hint: Run the command `/re_addPost [URL]` in a Discord channel or `u/ByteDiceAssistant bk_week_add` in a Reddit post.", + "re_add_post_success": "Added post with URL \"<{0}>\"!", + "re_already_voted": "Couldn't cast a vote: You have already voted on this post!", + "re_also_approved": "Also approved it!", + "re_approved_post_success": "Successfully approved the post!", + "re_data_404": "reddit_data.json not found,", + "re_data_corrupted": "Post URL \"<{0}>\" not found: Post data is corrupted!\n Full details: Could not find key \"bk_weekly_art_posts\" in data file \"reddit_data.json\"", + "re_data_wipe": "[WIPE] (reddit_data.json)", + "re_disapproved_post_success": "Successfully disapproved the post!", + "re_err_trace": "Unknown error!\nError trace: {0}.", + "re_havent_voted_remove_vote": "Couldn't remove your vote: You haven't voted on this post yet!", + "re_permdeny_bk_mod": "Permission denied: You are not a moderator of {0}", + "re_remove_post_success": "Successfully removed post!", + "re_unremove_post_success": "Successfully un-removed post with URL \"<{0}>\"!", + "re_update_post_success": "Updated post with URL \"<{0}>\"!", + "re_vote_err": "Failed to vote/un-vote: Unknown internal error", + "re_vote_remove_success": "Successfully removed vote!", + "re_vote_success_mod": "Successfully voted (as moderator vote)!", + "re_vote_success": "Successfully voted!", + "reload_config_python_fail": "Failed to reload configs: Failed-type response from Python.", + "reload_config_success": "Successfully reloaded the configs!\nNew configs:\n```\n{0}\n```", + "shorturl": "ShortURL: <{0}>", + "socket_received_python": "Received from Python: {0}", + "socket_rust_connection_test": "[Connection test] Hello from Rust!", + "started_socket": "WebSocket server running on ws://{0}", + "starting_python_program": "Running Python program...", + "starting_schedules": "Starting schedules...", + "starting_socket": "Starting local websocket..." +} \ No newline at end of file diff --git a/data/reddit_data_preset.json b/data/re_data_preset.json similarity index 100% rename from data/reddit_data_preset.json rename to data/re_data_preset.json diff --git a/src/bk_week_cmds.rs b/src/bk_week_cmds.rs deleted file mode 100644 index 06c21d5..0000000 --- a/src/bk_week_cmds.rs +++ /dev/null @@ -1,752 +0,0 @@ -use crate::websocket::send_cmd_json; -use crate::{cmds, rs_println, websocket, Context, Error, BK_WEEK}; -use crate::messages::*; -use crate::data::{self, dc_bind_bk, get_mutex_data}; - -use std::collections::HashMap; -use std::time::{SystemTime, UNIX_EPOCH}; - -use poise::serenity_prelude::{ChannelId, EditMessage, GetMessages, Http, Message, MessageId, UserId}; -use poise::ReplyHandle; -use serde_json::{json, Map, Value}; - - -#[derive(poise::ChoiceParameter, PartialEq)] -enum TopCategory { - Upvotes, - ModVotes, - Oldest, - Newest -} - - -fn is_bk_mod(mod_list: Vec, uid: u64) -> bool { - return mod_list.contains(&uid); -} - - -async fn not_bk_mod_msg(ctx: Context<'_>) { - send_msg(ctx, "Permission denied: You are not a moderator of r/boykisser or r/boykisser2".to_string(), true, true).await; -} - - - - -#[poise::command( - slash_command, - prefix_command, - category = "bk_week", - required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL | EMBED_LINKS" -)] -/// Fetches the data of a single post, just for you. The data has to be within the database to work. -pub async fn bk_week_get( - ctx: Context<'_>, - #[description = "The post URL."] url: String -) -> Result<(), Error> -{ - data::update_re_data(ctx.data()).await; - - let reddit_data = get_mutex_data(&ctx.data().reddit_data).await?; - - if let Some(post) = get_post_from_data(ctx, &reddit_data, &url).await? { - send_embed_for_post(ctx, post, &url).await?; - } - - return Ok(()); -} - - -async fn get_post_from_data(ctx: Context<'_>, reddit_data: &Value, url: &str) -> Result, Error> { - if let Some(bk_week) = reddit_data.get(BK_WEEK) { - if let Some(post) = bk_week.get(url) { - if post.get("removed").is_some() { - send_post_removed_message(ctx, url, post).await; - return Ok(None); - } - return Ok(Some(post.clone())); - } - else { - send_post_not_found_message(ctx, url).await; - } - } - else { - send_data_corrupted_message(ctx, url).await; - rs_println!("{}", serde_json::to_string_pretty(reddit_data)?); - } - return Ok(None); -} - - -async fn send_embed_for_post(ctx: Context<'_>, post: Value, url: &str) -> Result<(), Error> { - send_embed(ctx, embed_post(&post, url, true), true).await; - Ok(()) -} - - -async fn send_post_not_found_message(ctx: Context<'_>, url: &str) { - send_msg( - ctx, - format!( - r#"Post URL \"<{}>\" not found: Post doesn't exist in the data! - Hint: Run the command `/bk_week_add [URL]` in a Discord channel or `u/ByteDiceAssistant bk_week_add` in a Reddit post."#, - url - ).trim().to_string(), - true, - true - ).await; -} - - -async fn send_post_removed_message(ctx: Context<'_>, url: &str, post: &Value) { - send_embed( - ctx, - embed_post_removed(post, url, true), - true - ).await; -} - - -async fn send_data_corrupted_message(ctx: Context<'_>, url: &str) { - send_msg( - ctx, - format!( - r#"Post URL \"<{}>\" not found: Post data is corrupted! - Full details: Could not find key \"bk_weekly_art_posts\" in data file \"reddit_data.json\""#, - url, - ).trim().to_string(), - true, - true - ).await; -} - - - - -#[poise::command( - slash_command, - prefix_command, - category = "bk_week", - required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL" -)] -/// Fetches a post from Reddit and adds it to the database. -pub async fn bk_week_add( - ctx: Context<'_>, - #[description = "The post URL."] url: String, - #[description = "Wether to approve it after adding it"] approve: Option -) -> Result<(), Error> -{ - if !is_bk_mod(ctx.data().bk_mods.clone(), ctx.author().id.get()) { - not_bk_mod_msg(ctx).await; - return Ok(()); - } - - let shorturl_u = cmds::to_shorturl(&url); - let shorturl = &shorturl_u.unwrap_or(url.clone()); - - data::update_re_data(ctx.data()).await; - let reddit_data = get_mutex_data(&ctx.data().reddit_data).await?; - - if let Some(bk_week) = reddit_data.get(BK_WEEK) { - let a = approve.unwrap_or(false); - let r = websocket::send_cmd_json("add_post_url", Some(json!([&shorturl, a, true]))).await.unwrap(); - - if !r["value"].as_bool().unwrap() { - send_msg( - ctx, - r#"Unknown error! - Error trace: `bk_week_cmds.rs -> bk_week_add() -> Unknown error`. - Common reasons: The URL provided was likely invalid or 403: forbidden (e.g a private subreddit)."#.to_string(), - true, - true - ).await; - return Ok(()); - } - - if let Some(post) = bk_week.get(shorturl) { - if post.get("removed").is_some() { - send_unremove_msg(ctx, shorturl).await; - } - else { - send_updated_msg(ctx, shorturl).await; - } - } - else { - send_msg(ctx, format!("Added post with URL \"<{}>\"!", &shorturl), true, true).await; - } - - if a { - send_msg(ctx, "Also approved it!".to_string(), true, true).await; - } - } - - return Ok(()); -} - - -async fn send_unremove_msg(ctx: Context<'_>, url: &str) { - send_msg(ctx, format!("Un-removed post with URL \"<{}>\"!", url), true, true).await; -} - - -async fn send_updated_msg(ctx: Context<'_>, url: &str) { - send_msg(ctx, format!("Updated post with URL \"<{}>\"!", url), true, true).await; -} - - - - -#[poise::command( - slash_command, - prefix_command, - category = "bk_week", - required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL" -)] -/// Removes a post from the database. It will show who last removed it. -pub async fn bk_week_remove( - ctx: Context<'_>, - #[description = "The post URL."] url: String, - #[description = "The reason of the removal."] reason: Option -) -> Result<(), Error> -{ - if !is_bk_mod(ctx.data().bk_mods.clone(), ctx.author().id.get()) { - not_bk_mod_msg(ctx).await; - return Ok(()); - } - - let auth = &ctx.author().name; - let r = send_cmd_json("remove_post_url", Some(json!([&url, &auth, &reason]))).await.unwrap(); - - if r["value"].as_bool().unwrap() { - send_msg( - ctx, - "Successfully flagged the post as removed!".to_string(), - true, - true - ).await; - } - else { - send_post_not_found_message(ctx, &url).await; - } - - return Ok(()); -} - - - - -#[poise::command( - slash_command, - prefix_command, - category = "bk_week", - required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL" -)] -/// Approves a post in the database. Approving posts tells the bot that it's original. -pub async fn bk_week_approve( - ctx: Context<'_>, - #[description = "The post URL."] url: String, - #[description = "Wether to approve or disapprove the post"] disapprove: Option -) -> Result<(), Error> -{ - if !is_bk_mod(ctx.data().bk_mods.clone(), ctx.author().id.get()) { - not_bk_mod_msg(ctx).await; - return Ok(()); - } - - data::update_re_data(ctx.data()).await; - let reddit_data = get_mutex_data(&ctx.data().reddit_data).await?; - - approve_cmd(ctx, &url, &reddit_data, !disapprove.unwrap_or(false)).await; - - return Ok(()); -} - - -async fn approve_cmd(ctx: Context<'_>, url: &str, reddit_data: &Value, approve: bool) { - if let Some(post) = reddit_data.get(BK_WEEK).unwrap().get(url) { - if post.get("removed").is_some() { - send_post_removed_message(ctx, url, post).await; - return; - } - - let r = websocket::send_cmd_json("set_approve_post", Some(json!([approve, &url]))).await.unwrap(); - if r.get("value").is_some() { - if approve { - send_msg(ctx, "Successfully flagged the post as approved (by a human)!".to_string(), true, true).await; - } - else { - send_msg(ctx, "Successfully removed the \"approved (by a human)\" flag from the post!".to_string(), true, true).await; - } - } - else { - send_msg(ctx, "Unknown error!\nError trace: `bk_week_cmds.rs -> bk_week_approve() -> unwrap websocket result error`.".to_string(), true, true).await; - } - } - else { - send_post_not_found_message(ctx, url).await; - } -} - - - - -#[poise::command( - slash_command, - prefix_command, - category = "admin", - default_member_permissions = "ADMINISTRATOR", - guild_only, - required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL" -)] -/// Sets the channel where the bot will dump all log info. It's recommended to only run this once. -pub async fn bk_admin_bind( - ctx: Context<'_> -) -> Result<(), Error> -{ - let c_id = ctx.channel_id().into(); - let r = dc_bind_bk(ctx.data(), ctx.guild_id().unwrap().into(), c_id).await; - - if r.is_ok() { - send_msg(ctx, format!("Successfully bound channel ID `{}` as the bk_week channel!", c_id), true, true).await; - } - else { - send_server_not_in_data_msg(ctx).await; - } - - return Ok(()); -} - - -async fn send_server_not_in_data_msg(ctx: Context<'_>) { - send_msg(ctx, "Your server is not in the data!\nHint: Run the command `/add_server` inside of a Discord server.".to_string(), true, true).await; -} - - - - -#[poise::command( - slash_command, - prefix_command, - category = "bk_week", - guild_only, - guild_cooldown = 120, - required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL | READ_MESSAGE_HISTORY | EMBED_LINKS" -)] -/// Updates all logs -pub async fn bk_week_update( - ctx: Context<'_>, - #[description = "Only adds new posts, leaves everything else unchanged."] - only_add: Option, - #[description = "The max age of a post (in days). Any post older than this will be removed. (0 is infinite.)"] - #[min = 0] - #[max = 65535] - max_age: Option -) -> Result<(), Error> -{ - let http = ctx.http(); - - let mut p_text = "`/bk_week_update`:".to_string(); - - let progress = send_msg(ctx, p_text.clone(), true, true).await.unwrap(); - p_text = update_progress(ctx, progress.clone(), p_text, "\nFetching new posts & updating data file...".to_string()).await; - - let max_age_u = max_age.unwrap_or(8); - let max_age_secs = max_age_u as u64 * (60 * 60 * 24); - - send_cmd_json("add_new_posts", Some(json!([max_age_secs]))).await; - data::update_re_data(ctx.data()).await; - let r_data = get_mutex_data(&ctx.data().reddit_data).await?; - - let c_id_u = get_c_id(ctx).await; - - if c_id_u.is_none() { - send_msg(ctx, "Could not find bk_week_channel in data!\nHint: Run (or tell an admin to run) `/bk_admin_bind` in a (preferably read-only) channel.".to_string(), true, true).await; - return Ok(()); - } - - let c_id = c_id_u.unwrap(); - - // Reading messages - p_text = update_progress(ctx, progress.clone(), p_text.clone(), format!("✅\nReading messages in <#{}>...", c_id)).await; - let msgs = read_msgs(http, ctx.framework().bot_id, c_id).await; - - // Parsing messages to JSON - p_text = update_progress(ctx, progress.clone(), p_text.clone(), "✅\nParsing messages to JSON...".to_string()).await; - let msgs_json = msgs_to_json(msgs, &r_data, max_age_secs).await; - - // Adding new posts - p_text = update_progress(ctx, progress.clone(), p_text.clone(), "✅\nAdding new posts...".to_string()).await; - let weekly_art = r_data[BK_WEEK].as_object().unwrap(); - add_posts(http, c_id, weekly_art, &msgs_json, max_age_secs).await; - - // Stop if only_add - if only_add.unwrap_or(false) { - send_msg(ctx, "`/bk_week_update`\n## Done!".to_string(), true, true).await; - update_progress(ctx, progress.clone(), p_text, "✅\n## Done!".to_string()).await; - return Ok(()); - } - - // Editing updated posts - p_text = update_progress(ctx, progress.clone(), p_text.clone(), "✅\nEditing updated posts...".to_string()).await; - edit_posts(http, c_id, weekly_art, &msgs_json).await; - - // Removing removed posts - p_text = update_progress(ctx, progress.clone(), p_text.clone(), "✅\nRemoving removed posts...".to_string()).await; - remove_posts(http, c_id, weekly_art, &msgs_json).await; - - // Removing old posts - if max_age_u > 0 { - p_text = update_progress(ctx, progress.clone(), p_text.clone(), format!("✅\nRemoving old posts (threshold: {}d)...", max_age_u)).await; - remove_old(http, c_id, &msgs_json).await; - send_cmd_json("remove_old_posts", Some(json!([max_age_secs]))).await; - } - - // Removing duplicate posts - p_text = update_progress(ctx, progress.clone(), p_text.clone(), "✅\nRemoving duplicate posts...".to_string()).await; - remove_dupes(http, c_id, &msgs_json).await; - - // Done - update_progress(ctx, progress.clone(), p_text, "✅\n## Done!".to_string()).await; - send_msg(ctx, "`/bk_week_update`\n## Done!".to_string(), true, true).await; - - return Ok(()); -} - - -async fn update_progress(ctx: Context<'_>, p: ReplyHandle<'_>, t: String, added_t: String) -> String { - let p_text = format!("{} {}", t, added_t); - - edit_reply(ctx, p, p_text.clone()).await; - return p_text; -} - - -async fn get_c_id(ctx: Context<'_>) -> Option { - if !data::dc_contains_server(ctx.data(), ctx.guild_id().unwrap().into()).await { - send_server_not_in_data_msg(ctx).await; - return None; - } - - let d = get_mutex_data(&ctx.data().discord_data).await.unwrap(); - let c_id_u = - d["servers"] - [ctx.guild_id().unwrap().to_string()] - ["bk_week_channel"].as_u64().unwrap(); - - let c_id = ChannelId::new(c_id_u); - - return Some(c_id); -} - - -async fn read_msgs(http: &Http, bot_id: UserId, c_id: ChannelId) -> Vec { - let b = GetMessages::new().limit(100); - let mut msgs = c_id.messages(http, b).await.unwrap(); - msgs.retain(|item| item.author.id == bot_id); - - let mut last_msg: Option = msgs.last().cloned(); - - while last_msg.is_some() { - let new_b = GetMessages::new().limit(100).before(last_msg.clone().unwrap()); - let new_msgs = c_id.messages(http, new_b).await.unwrap(); - - last_msg = new_msgs.last().cloned(); - - if new_msgs.is_empty() { - break; - } - - let filtered_msgs: Vec = new_msgs - .into_iter() - .filter(|item| item.author.id == bot_id) - .collect(); - - msgs.extend(filtered_msgs); - } - - return msgs; -} - - -async fn msgs_to_json(msgs: Vec, reddit_data: &Value, max_age: u64) -> Value { - let mut msgs_json: Value = json!({"no_change": {}, "updated": {}, "removed": {}, "duplicates": {}, "old": {}}); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - - for msg in msgs { - if msg.embeds.is_empty() { continue; } - if msg.embeds[0].url.is_none() { continue; } - - let url = msg.embeds[0].url.clone().unwrap(); - - // duplicates - if ["no_change", "updated", "removed", "old"] - .iter() - .any(|key| msgs_json[key].as_object().unwrap().contains_key(&url)) - { - let dupes_mut = msgs_json["duplicates"].as_object_mut().unwrap(); - if !dupes_mut.contains_key(&url) { - dupes_mut.insert(url.clone(), json!(msg.id.get())); - } - continue; - } - - let msg_desc = &msg.embeds[0].description.clone().unwrap(); - let msg_lines = msg_desc.split("\n"); - let msg_last_len = msg_lines.clone().last().unwrap().len(); - - if msg_last_len < 13 { continue; } - - let msg_json_str = &msg_lines.clone().last().unwrap()[9..msg_last_len - 3]; - - let msg_json = serde_json::from_str(msg_json_str); - if msg_json.is_err() { continue; } - - let mut u_json: Value = msg_json.unwrap(); - let re_url = &reddit_data[BK_WEEK][&url]; - - let post_date = re_url["post_data"]["date_unix"].as_u64().unwrap_or(0); - - // old - if now - post_date > max_age { - if let Some(obj) = msgs_json["old"].as_object_mut() { - obj.insert(url.clone(), json!(msg.id.get())); - continue; - } - } - - // removed - if re_url.get("removed").is_some() { - if u_json.get("removed").is_some() { - // no change - if let Some(obj) = msgs_json["no_change"].as_object_mut() { - obj.insert(url.clone(), json!(msg.id.get())); - continue; - } - } - - // removed - if let Some(obj) = msgs_json["removed"].as_object_mut() { - obj.insert(url.clone(), json!(msg.id.get())); - continue; - } - } - - // updated - if u_json["added"] != re_url["added"] - || u_json["approved"] != re_url["approved"] - || u_json["post_data"]["upvotes"] != re_url["post_data"]["upvotes"] - || u_json["votes"]["mod_voters"] != re_url["votes"]["mod_voters"] - { - u_json.as_object_mut().unwrap().insert("msg_id".to_string(), Value::String(msg.id.clone().to_string())); - - if let Some(obj) = msgs_json["updated"].as_object_mut() { - obj.insert(url.clone(), json!(msg.id.get())); - continue; - } - } - - // no change - if let Some(obj) = msgs_json["no_change"].as_object_mut() { - obj.insert(url.clone(), json!(msg.id.get())); - } - } - - return msgs_json; -} - - -async fn add_posts(http: &Http, c_id: ChannelId, r_data: &Map, msgs_json: &Value, max_age: u64) { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - - for url in r_data.keys() { - if ["no_change", "updated", "removed", "old", "duplicates"] - .iter() - .any(|key| msgs_json[key].as_object().unwrap().contains_key(url)) - { continue; } - - let post_date = r_data[url]["post_data"]["date_unix"].as_u64().unwrap(); - if now - post_date > max_age { continue; } - - if r_data[url].get("removed").is_some() { - http_send_embed(http, c_id, embed_post_removed(&r_data[url], url, false)).await; - continue; - } - - http_send_embed(http, c_id, embed_post(&r_data[url], url, false)).await; - } -} - - -async fn edit_posts(http: &Http, c_id: ChannelId, r_data: &Map, msgs_json: &Value) { - for (url, msg_id) in msgs_json["updated"].as_object().unwrap() { - let mut msg = http.get_message(c_id, MessageId::new(msg_id.as_u64().unwrap())).await.unwrap(); - let r = EditMessage::new() - .embeds(vec![embed_from_options(embed_post(&r_data[url], url, false))]); - - let _ = msg.edit(http, r).await; - } -} - - -async fn remove_posts(http: &Http, c_id: ChannelId, r_data: &Map, msgs_json: &Value) { - for (url, msg_id) in msgs_json["removed"].as_object().unwrap() { - let mut msg = http.get_message(c_id, MessageId::new(msg_id.as_u64().unwrap())).await.unwrap(); - let r = EditMessage::new() - .embeds(vec![embed_from_options(embed_post_removed(&r_data[url], url, false))]); - - let _ = msg.edit(http, r).await; - } -} - - -async fn remove_old(http: &Http, c_id: ChannelId, msgs_json: &Value) { - for (_url, msg_id) in msgs_json["old"].as_object().unwrap() { - let msg = http.get_message(c_id, MessageId::new(msg_id.as_u64().unwrap())).await.unwrap(); - let _ = msg.delete(http).await; - } -} - - -async fn remove_dupes(http: &Http, c_id: ChannelId, msgs_json: &Value) { - for (_url, msgs) in msgs_json["duplicates"].as_object().unwrap() { - for msg_id in msgs.as_array().unwrap() { - let msg = http.get_message(c_id, MessageId::new(msg_id.as_u64().unwrap())).await.unwrap(); - let _ = msg.delete(http).await; - } - } -} - - -#[poise::command( - slash_command, - prefix_command, - category = "bk_week", - required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL" -)] -/// Adds/removes a vote from a post. These votes are not tied to Reddit upvotes. -pub async fn bk_week_vote( - ctx: Context<'_>, - #[description = "The post URL."] url: String, - #[description = "Wether to undo your vote or not"] un_vote: Option -) -> Result<(), Error> -{ - data::update_re_data(ctx.data()).await; - let uid = ctx.author().id.get(); - let re_data = get_mutex_data(&ctx.data().reddit_data).await?; - let post_data = re_data[BK_WEEK].clone(); - let unw_vote = un_vote.unwrap_or(false); - - if post_data.get(&url).is_none() { - send_post_not_found_message(ctx, &url).await; - return Ok(()); - } - if post_data[&url].get("removed").is_some() { - send_post_removed_message(ctx, &url, &post_data[&url]).await; - return Ok(()); - } - - let url_data = &post_data[&url]; - - let is_mod = is_bk_mod(ctx.data().bk_mods.clone(), ctx.author().id.get()); - let voters_dc = url_data["votes"]["voters_dc"].as_array().unwrap(); - let mod_voters = url_data["votes"]["mod_voters"].as_array().unwrap(); - let voters = if is_mod { mod_voters } else { voters_dc }; - - if voters.contains(&json!(uid)) && !unw_vote { - send_msg(ctx, "Couldn't cast a vote: You have already voted on this post!".to_string(), true, true).await; - return Ok(()); - } - else if !voters.contains(&json!(uid)) && unw_vote { - send_msg(ctx, "Couldn't remove your vote: You haven't voted on this post yet!".to_string(), true, true).await; - return Ok(()); - } - - let r = send_cmd_json("set_vote_post", Some(json!([url, uid, is_mod, true, unw_vote]))).await.unwrap(); - let unw_r = r["value"].as_bool().unwrap(); - - if unw_r && !unw_vote && is_mod { - send_msg(ctx, "Successfully voted (as moderator vote)!".to_string(), true, true).await; - } - else if unw_r && !unw_vote && !is_mod { - send_msg(ctx, "Successfully voted!".to_string(), true, true).await; - } - else if unw_r && unw_vote { - send_msg(ctx, "Successfully removed vote!".to_string(), true, true).await; - } - else { - send_msg(ctx, "Failed to vote/un-vote: Unknown internal error".to_string(), true, true).await; - } - - return Ok(()); -} - - - -#[poise::command( - slash_command, - prefix_command, - category = "bk_week", - required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL | EMBED_LINKS" -)] -/// Gets the top N (up to 10) posts within a certain category, such as upvotes. (Sorted descending.) -pub async fn bk_week_top( - ctx: Context<'_>, - #[description = "The sorting criteria, such as upvotes."] - category: TopCategory, - #[description = "The amount of posts to show (max 10)."] - #[min = 1] - #[max = 10] - amount: Option -) -> Result<(), Error> -{ - let mut all: HashMap<&str, i32> = HashMap::new(); - let posts = &get_mutex_data(&ctx.data().reddit_data).await?[BK_WEEK]; - let posts_u = posts.as_object().unwrap(); - - for (url, dat) in posts_u { - if dat.get("removed").is_some() { continue; } - - let val: i32 = match category { - TopCategory::Upvotes => dat["post_data"]["upvotes"].as_i64().unwrap() as i32, - TopCategory::ModVotes => dat["votes"]["mod_voters"].as_array().unwrap().len() as i32, - TopCategory::Oldest - | TopCategory::Newest => dat["post_data"]["date_unix"].as_i64().unwrap() as i32, - }; - - all.insert(url, val); - } - - let amount_u = amount.unwrap_or(3); - let amount_clamped = amount_u.clamp(1, 10); - - let top = - if category != TopCategory::Oldest - { largest_n (&all, amount_clamped as usize) } - else { smallest_n(&all, amount_clamped as usize) }; - - for post in top { - let url = post.0; - let _ = send_embed_for_post(ctx, posts_u[url].clone(), url).await; - } - - return Ok(()); -} - - -fn largest_n<'a>(map: &'a HashMap<&'a str, i32>, n: usize) -> Vec<(&'a str, i32)> { - let mut vec: Vec<_> = map.iter().collect(); - vec.sort_unstable_by(|a, b| b.1.cmp(a.1)); - vec.into_iter().take(n).map(|(&k, &v)| (k, v)).collect() -} - - -fn smallest_n<'a>(map: &'a HashMap<&'a str, i32>, n: usize) -> Vec<(&'a str, i32)> { - let mut vec: Vec<_> = map.iter().collect(); - vec.sort_unstable_by(|a, b| a.1.cmp(b.1)); - vec.into_iter().take(n).map(|(&k, &v)| (k, v)).collect() -} \ No newline at end of file diff --git a/src/cmds.rs b/src/cmds.rs index bf9ec13..18c9b18 100644 --- a/src/cmds.rs +++ b/src/cmds.rs @@ -3,14 +3,14 @@ use std::process; use std::error::Error as StdErr; use crate::data::{dc_add_server, get_mutex_data, read_cfg_data}; +use crate::re_cmds::generic_fns::to_shorturl; use crate::websocket::send_cmd_json; -use crate::{data, Context, Data, Error}; +use crate::{data, lang, Context, Data, Error}; use crate::messages::{edit_reply, send_embed, send_msg, Author, EmbedOptions, MANDATORY_MSG}; use poise::serenity_prelude::{OnlineStatus, Timestamp}; use poise::Command; use rand::{seq::IteratorRandom, Rng}; -use regex::Regex; use serde_json::json; use tokio::fs; @@ -63,19 +63,19 @@ pub async fn stop( || confirmation.unwrap_or_default().to_lowercase() == "i want to stop the bot now"; if should_stop { - let msg = send_msg(ctx, "Saving data...".to_string(), true, true).await.unwrap(); + let msg = send_msg(ctx, lang!("data_save_progress"), true, true).await.unwrap(); data::write_dc_data(ctx.data()).await; data::write_re_data().await; send_cmd_json("stop_praw", None).await; - edit_reply(ctx, msg, "Saving data... Done!\nShutting down...".to_string()).await; + edit_reply(ctx, msg, lang!("data_save_complete")).await; ctx.serenity_context().set_presence(None, OnlineStatus::Invisible); ctx.framework().shard_manager.shutdown_all().await; process::exit(0); } else { - send_msg(ctx, "Failed to shut down: Invalid confirmation.".to_string(), true, true).await; + send_msg(ctx, lang!("invalid_confirm_shutdown"), true, true).await; } return Ok(()); @@ -171,7 +171,7 @@ pub async fn eight_ball( send_msg( ctx, - format!("Q: {}\nA: {}", question, rand_item.unwrap()), + lang!("8-ball_answer", question, rand_item.unwrap()), true, true ).await; @@ -194,29 +194,16 @@ pub async fn re_shorturl( let shorturl = to_shorturl(&url); if shorturl.is_ok() { - send_msg(ctx, format!("ShortURL: <{}>", shorturl.unwrap()), true, true).await; + send_msg(ctx, lang!("shorturl", shorturl.unwrap()), true, true).await; } else { - send_msg(ctx, "Couldn't convert to shortURL: Invalid URL".to_string(), true, true).await; + send_msg(ctx, lang!("couldnt_shorturl"), true, true).await; } return Ok(()); } -pub fn to_shorturl(url: &str) -> Result { - let re = Regex::new(r"comments/([a-zA-Z0-9]+)").unwrap(); - - if let Some(caps) = re.captures(url) { - let post_id = &caps[1]; - let short_url = format!("https://redd.it/{}", post_id); - return Ok(short_url); - } - - return Err("Invalid URL"); -} - - #[poise::command( slash_command, prefix_command, @@ -233,10 +220,10 @@ pub async fn add_server( let r = dc_add_server(ctx.data(), ctx.guild_id().unwrap().into()).await; if r.is_ok() { - send_msg(ctx, "Added your server to my data! Thanks for letting me steal it! (/s)".to_string(), true, true).await; + send_msg(ctx, lang!("add_to_data"), true, true).await; } else { - send_msg(ctx, "Oopsies `(。>\\\\<)`. It looks like my data i-is \\**sob*\\*... c-corrupted!".to_string(), true, true).await; + send_msg(ctx, lang!("corrupted_data"), true, true).await; } return Ok(()); @@ -263,14 +250,14 @@ pub async fn reload_cfg( if r.is_some() && r.unwrap()["value"].as_bool().unwrap() { send_msg( ctx, - format!("Successfully reloaded the configs!\nNew configs:\n```\n{}\n```", serde_json::to_string_pretty(&d)?), + lang!("reload_config_success", serde_json::to_string_pretty(&d).unwrap()), true, true ).await; return Ok(()); } - send_msg(ctx, "Failed to reload configs: Failed-type response from Python.".to_string(), true, true).await; + send_msg(ctx, lang!("reload_config_python_fail"), true, true).await; return Ok(()); } @@ -342,7 +329,7 @@ async fn send_single_help(ctx: Context<'_>, mut cmd_name: String) { if cmd.is_none() { send_msg( ctx, - format!("No command with the name \"{}\" found!\nHint: Try `/help` without any arguments or `/help `", cmd_name), + lang!("cmd_404", cmd_name), true, true ).await; @@ -368,7 +355,7 @@ async fn send_category_help(ctx: Context<'_>, category: HelpOptions) { async fn send_bk_week_help_re(ctx: Context<'_>) { let t: String = fs::read_to_string("./bk_week_help_re.md").await - .unwrap_or("Help text not found. Someone deleted it. :(".to_string()); + .unwrap_or(lang!("help_text_removed")); send_msg(ctx, t, true, true).await; } @@ -435,10 +422,10 @@ async fn send_bk_week_help(ctx: Context<'_>) { let cmds = &ctx.framework().options().commands; let bk_week_cmds: Vec<_> = cmds .iter() - .filter(|cmd| cmd.category == Some("bk_week".to_string())) + .filter(|cmd| cmd.category == Some("re".to_string())) .collect(); - let t = format_cmds(vec![("bk_week", bk_week_cmds)]); + let t = format_cmds(vec![("re", bk_week_cmds)]); send_msg(ctx, t, true, true).await; } @@ -449,7 +436,7 @@ async fn send_generic_help(ctx: Context<'_>) { .iter() .filter( |cmd| - cmd.category != Some("bk_week".to_string()) + cmd.category != Some("re".to_string()) || cmd.category != Some("owner".to_string()) || cmd.category != Some("admin".to_string()) ) diff --git a/src/data.rs b/src/data.rs index 1e42086..f3eca25 100644 --- a/src/data.rs +++ b/src/data.rs @@ -4,23 +4,24 @@ use std::path::Path; use serde_json::{self, Value, json}; use tokio::sync::Mutex; -use crate::{Data, BK_WEEK, rs_println, Error}; +use crate::{lang, rs_println, Data, Error, BK_WEEK, LANG}; use crate::websocket::send_cmd_json; -static DATA_PATH_DC: &str = "./data/discord_data.json"; -static PRESET_PATH_DC: &str = "./data/discord_data_preset.json"; -static DATA_PATH_RE: &str = "./data/reddit_data.json"; -static PRESET_PATH_RE: &str = "./data/reddit_data_preset.json"; +static DATA_PATH_DC: &str = "./data/dc_data.json"; +static PRESET_PATH_DC: &str = "./data/dc_data_preset.json"; +static DATA_PATH_RE: &str = "./data/re_data.json"; +static PRESET_PATH_RE: &str = "./data/re_data_preset.json"; static DATA_PATH_CFG: &str = "./data/cfg.json"; static PRESET_PATH_CFG: &str = "./data/cfg_default.json"; +static DATA_PATH_LANG: &str = "./data/lang/"; pub async fn read_dc_data(data: &Data, wipe: bool) { if !Path::new(DATA_PATH_DC).exists() || wipe { rs_println!( - "{} creating new from preset...", - if !wipe { "discord_data.json not found," } else { "[WIPE] (discord_data.json)" } + "{}", + lang!("creating_data_file", if !wipe { lang!("dc_data_404") } else { lang!("dc_data_wipe") }) ); generate_dc_data(); } @@ -70,8 +71,8 @@ pub async fn write_dc_data(data: &Data) { pub async fn read_re_data(data: &Data, wipe: bool) { if !Path::new(DATA_PATH_RE).exists() || wipe { rs_println!( - "{} creating new from preset...", - if !wipe { "reddit_data.json not found," } else { "[WIPE] (reddit_data.json)" } + "{}", + lang!("creating_data_file", if !wipe { lang!("re_data_404") } else { lang!("re_data_wipe") }) ); generate_re_data(); } @@ -113,8 +114,8 @@ pub async fn write_re_data() { pub async fn read_cfg_data(data: &Data, wipe: bool) { if !Path::new(DATA_PATH_CFG).exists() || wipe { rs_println!( - "{} creating new from preset...", - if !wipe { "cfg.json not found," } else { "[WIPE] (cfg.json)" } + "{}", + lang!("creating_data_file", if !wipe { lang!("cfg_data_404") } else { lang!("cfg_data_wipe") }) ); generate_cfg_data(); } @@ -148,7 +149,7 @@ pub async fn dc_add_server(data: &Data, server_id: u64) -> Result<(), ()> { let servers = dc_data["servers"].as_object_mut().unwrap(); if !servers.contains_key(&server_id.to_string()) { - servers.insert(server_id.to_string(), json!({ "bk_week_channel": 0, "bk_mod_role": "bk mod", "bk_mods": [] })); + servers.insert(server_id.to_string(), json!({ "bk_week_channel": 0 })); } return Ok(()); @@ -194,4 +195,24 @@ pub async fn get_mutex_data(data: &Mutex>) -> Result Some(data) => Ok(data.clone()), None => Err("Cannot get mutex data: The data is corrupted!".into()), }; +} + + +pub fn load_lang_data(lang: String) { + let full_path = format!("{}{}.json", DATA_PATH_LANG, lang); + + if !Path::new(&full_path).exists() { + rs_println!( + "{}", + lang!("creating_data_file", lang!("cfg_data_404", lang)) + ); + generate_cfg_data(); + } + + let str_data = fs::read_to_string(full_path).unwrap(); + let json_data = serde_json::from_str(&str_data).unwrap(); + + unsafe { + LANG = json_data; + }; } \ No newline at end of file diff --git a/src/events.rs b/src/events.rs index adcfe64..098d575 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1,4 +1,4 @@ -use crate::{Data, Error, rs_println}; +use crate::{lang, rs_println, Data, Error}; use poise::serenity_prelude::{self as serenity, ActivityData}; @@ -14,9 +14,12 @@ pub fn event_handler<'a>( Box::pin(async move { if let serenity::FullEvent::Ready { data_about_bot } = event { rs_println!( - "Bot started as user \"{}\" with id {}", - data_about_bot.user.name, - data_about_bot.user.id + "{}", + lang!( + "dc_bot_started", + data_about_bot.user.name.clone(), + data_about_bot.user.id + ) ); let file_text = std::fs::read_to_string("./data/status.txt").unwrap(); diff --git a/src/gen.rs b/src/gen.rs new file mode 100644 index 0000000..c93b32c --- /dev/null +++ b/src/gen.rs @@ -0,0 +1,96 @@ +use std::collections::HashSet; + +use poise::serenity_prelude::UserId; +use poise::serenity_prelude as serenity; +use poise::serenity_prelude::Client; + +use crate::{cmds, data, events, re_cmds, rs_println, Args, Data}; + + +pub async fn gen_data(args: Args, owners: Vec) -> Data { + let ball_classic_str = std::fs::read_to_string("./data/8-ball_classic.txt").unwrap(); + let ball_quirk_str = std::fs::read_to_string("./data/8-ball_quirky.txt").unwrap(); + + let ball_classic: Vec = ball_classic_str.lines().map(String::from).collect(); + let ball_quirk: Vec = ball_quirk_str .lines().map(String::from).collect(); + + let mods_env = std::env::var("ASSISTANT_BK_MODS").unwrap_or("0".to_string()); + let mods_vec_str: Vec = mods_env.split(",").map(String::from).collect(); + let mods_vec_u64: Vec = mods_vec_str + .iter() + .map(|s| s.parse::().expect("Failed to parse ASSISTANT_BK_MODS. Invalid syntax.")) + .collect(); + + let data = Data { + owners, + ball_prompts: [ball_classic, ball_quirk], + bk_mods: mods_vec_u64, + reddit_data: None.into(), + discord_data: None.into(), + cfg: None.into(), + args: args.clone() + }; + + data::read_dc_data (&data, args.clone().wipe).await; + data::read_re_data (&data, args.clone().wipe).await; + data::read_cfg_data(&data, args.clone().wipe).await; + + return data; +} + + +pub async fn gen_bot(data: Data, args: Args) -> Client { + let token = + if !args.test { std::env::var("ASSISTANT_TOKEN").expect("Missing ASSISTANT_TOKEN env var!") } + else { std::env::var("ASSISTANT_TOKEN_TEST").expect("Missing ASSISTANT_TOKEN_TEST env var!") }; + + let intents = serenity::GatewayIntents::all(); + + let peek_len = 27; + let token_peek = &token[..peek_len]; + let token_end_len = token[peek_len..].len(); + rs_println!("Token: {}{}", token_peek, "*".repeat(token_end_len)); + + let own: HashSet = data.owners.clone().into_iter().map(UserId::from).collect(); + + let framework = poise::Framework::builder() + .options(poise::FrameworkOptions { + owners: own, + commands: vec![ + cmds::help(), + cmds::ping(), + cmds::embed(), + cmds::send(), + cmds::stop(), + cmds::eight_ball(), + cmds::re_shorturl(), + cmds::add_server(), + // reddit + re_cmds::add::cmd(), + re_cmds::approve::cmd(), + re_cmds::get::cmd(), + re_cmds::remove::cmd(), + re_cmds::top::cmd(), + re_cmds::update::cmd(), + re_cmds::vote::cmd(), + // reddit admin + re_cmds::admin_bind::cmd(), + // cfg + cmds::reload_cfg() + ], + event_handler: events::event_handler, + ..Default::default() + }) + .setup(|ctx, _ready, framework| { + Box::pin(async move { + poise::builtins::register_globally(ctx, &framework.options().commands).await?; + return Ok(data); + }) + }) + .build(); + + return serenity::ClientBuilder::new(token, intents) + .framework(framework) + .await + .unwrap(); +} \ No newline at end of file diff --git a/src/macros.rs b/src/macros.rs index 1e6e040..6873725 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -34,4 +34,40 @@ macro_rules! errln { ); std::process::exit(1); }; +} + + +#[macro_export] +macro_rules! lang { + ($key:expr) => { + { + use crate::{LANG, errln}; + let value = unsafe { + LANG + .as_ref() + .expect("LANG must be initialized before use") + .get($key) + }; + + if value.is_none() { errln!("Key not found in LANG JSON: \"{}\"", $key); } + + value.unwrap().as_str().expect("LANG JSON value is not a string!").to_string() + } + }; + ($key:expr, $($arg:expr),*) => {{ + use crate::{LANG, errln}; + use formatx::formatx; + + let value = unsafe { + LANG + .as_ref() + .expect("LANG must be initialized before use") + .get($key) + }; + + if value.is_none() { errln!("Key not found in LANG JSON: \"{}\"", $key); } + + let format_str = value.unwrap().as_str().expect("LANG JSON value is not a string!"); + formatx!(format_str, $($arg),*).unwrap() + }}; } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 68db106..bf17a00 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,17 @@ #![allow(clippy::needless_return)] mod cmds; -mod bk_week_cmds; +mod re_cmds { + pub mod add; + pub mod admin_bind; + pub mod approve; + pub mod generic_fns; + pub mod get; + pub mod remove; + pub mod top; + pub mod update; + pub mod vote; +} mod events; mod messages; mod python; @@ -10,27 +20,27 @@ mod macros; #[allow(unknown_lints)] mod websocket; mod data; +mod schedule; +mod gen; -use std::collections::HashSet; -use std::future::Future; -use std::pin::Pin; use std::process; use std::thread; use std::time::Duration; use std::vec; use clap::Parser; -use poise::serenity_prelude::UserId; -use poise::serenity_prelude as serenity; -use poise::serenity_prelude::Client; +use r#gen::gen_bot; +use r#gen::gen_data; +use schedule::run_schedules; use serde::Serialize; use serde_json::Value; use tokio::runtime::Runtime; use tokio::sync::Mutex; -use tokio::task::JoinHandle; -use tokio::time; use websocket::send_cmd_json; +use crate::schedule::Schedule; + +// TODO: convert to lang! #[derive(Parser, Serialize, Clone)] struct Args { @@ -49,13 +59,14 @@ struct Args { #[arg(long, help = "Removes the annoying ping prints.")] noping: bool, #[arg(long, help = "Makes the program not use the schedules.")] - nosched: bool + nosched: bool, + #[arg(long, default_value = "en", help = "Which language file to use (Do not include file extention)")] + lang: String } type Error = Box; type Context<'a> = poise::Context<'a, Data, Error>; -type Schedule = (Duration, fn() -> Pin + Send>>); struct Data { @@ -65,18 +76,24 @@ struct Data { discord_data: Mutex>, cfg: Mutex>, bk_mods: Vec, - args: Args + args: Args, } static BK_WEEK: &str = "bk_weekly_art_posts"; +pub static mut LANG: Option = None; + #[tokio::main] async fn main() { let args = ::parse(); let args_str = serde_json::to_string(&args).expect("Error serializing args to JSON"); + rs_println!("Fetching language file..."); + data::load_lang_data(args.clone().lang); + rs_println!("{}", lang!("lang_load_success")); + let own_env = std::env::var("ASSISTANT_OWNERS").unwrap_or("0".to_string()); let own_vec_str: Vec = own_env.split(",").map(String::from).collect(); let own_vec_u64: Vec = own_vec_str @@ -92,7 +109,7 @@ async fn main() { if args.py && !args.rs { println!("----- PYTHON ONLY MODE -----"); rs_println!("ARGS: {}", args_str); - let _ = python::start(args); + let _ = python::start(args).await; process::exit(0); } else if args.rs && ! args.py { @@ -101,25 +118,25 @@ async fn main() { start(args, own_vec_u64.clone()).await; process::exit(0); } - else if args.py && args.rs { - errln!("Invalid arguments: Arguments cannot include both --rs and --py."); - } rs_println!("ARGS: {}", args_str); - let rt = Runtime::new().unwrap(); + let rt_rs = Runtime::new().unwrap(); + let rt_py = Runtime::new().unwrap(); let python_args = args.clone(); let rust_args = args.clone(); let rust = thread::spawn(move || { - rt.block_on(async { + rt_rs.block_on(async { websocket::start(rust_args.clone(), own_vec_u64.clone()).await; start(rust_args, own_vec_u64).await; }); }); - let python = thread::spawn(|| { - let _ = python::start(python_args); + let python = thread::spawn(move || { + rt_py.block_on(async { + let _ = python::start(python_args).await; + }); }); if !args.nosched { @@ -139,124 +156,11 @@ async fn start(args: Args, owners: Vec) { let data = gen_data(args.clone(), owners).await; let mut bot = gen_bot(data, args).await; - rs_println!("Starting bot..."); + rs_println!("{}", lang!("dc_bot_starting")); bot.start().await.unwrap(); } -async fn gen_data(args: Args, owners: Vec) -> Data { - let ball_classic_str = std::fs::read_to_string("./data/8-ball_classic.txt").unwrap(); - let ball_quirk_str = std::fs::read_to_string("./data/8-ball_quirky.txt").unwrap(); - - let ball_classic: Vec = ball_classic_str.lines().map(String::from).collect(); - let ball_quirk: Vec = ball_quirk_str .lines().map(String::from).collect(); - - let mods_env = std::env::var("ASSISTANT_BK_MODS").unwrap_or("0".to_string()); - let mods_vec_str: Vec = mods_env.split(",").map(String::from).collect(); - let mods_vec_u64: Vec = mods_vec_str - .iter() - .map(|s| s.parse::().expect("Failed to parse ASSISTANT_BK_MODS. Invalid syntax.")) - .collect(); - - let data = Data { - owners, - ball_prompts: [ball_classic, ball_quirk], - bk_mods: mods_vec_u64, - reddit_data: None.into(), - discord_data: None.into(), - cfg: None.into(), - args: args.clone() - }; - - data::read_dc_data (&data, args.clone().wipe).await; - data::read_re_data (&data, args.clone().wipe).await; - data::read_cfg_data(&data, args.clone().wipe).await; - - return data; -} - - -async fn gen_bot(data: Data, args: Args) -> Client { - let token = - if !args.test { std::env::var("ASSISTANT_TOKEN").expect("Missing ASSISTANT_TOKEN env var!") } - else { std::env::var("ASSISTANT_TOKEN_TEST").expect("Missing ASSISTANT_TOKEN_TEST env var!") }; - - let intents = serenity::GatewayIntents::all(); - - let peek_len = 27; - let token_peek = &token[..peek_len]; - let token_end_len = token[peek_len..].len(); - rs_println!("Token: {}{}", token_peek, "*".repeat(token_end_len)); - - let own: HashSet = data.owners.clone().into_iter().map(UserId::from).collect(); - - let framework = poise::Framework::builder() - .options(poise::FrameworkOptions { - owners: own, - commands: vec![ - cmds::help(), - cmds::ping(), - cmds::embed(), - cmds::send(), - cmds::stop(), - cmds::eight_ball(), - cmds::re_shorturl(), - cmds::add_server(), - // bk_week - bk_week_cmds::bk_week_get(), - bk_week_cmds::bk_week_add(), - bk_week_cmds::bk_week_remove(), - bk_week_cmds::bk_week_approve(), - bk_week_cmds::bk_week_update(), - bk_week_cmds::bk_week_vote(), - bk_week_cmds::bk_week_top(), - // bk_admin - bk_week_cmds::bk_admin_bind(), - // cfg - cmds::reload_cfg() - ], - event_handler: events::event_handler, - ..Default::default() - }) - .setup(|ctx, _ready, framework| { - Box::pin(async move { - poise::builtins::register_globally(ctx, &framework.options().commands).await?; - return Ok(data); - }) - }) - .build(); - - return serenity::ClientBuilder::new(token, intents) - .framework(framework) - .await - .unwrap(); -} - - -async fn run_schedule Pin + Send>>>(d: Duration, f: F) { - let mut ticker = time::interval(d); - loop { - ticker.tick().await; - f().await; - } -} - - -async fn run_schedules(schedules: Vec) { - let mut handles: Vec> = vec![]; - - rs_println!("Starting schedules..."); - for (d, f) in schedules { - let handle = tokio::spawn(run_schedule(d, f)); - handles.push(handle); - } - - for handle in handles { - let _ = handle.await; - } -} - - async fn read_reddit_inbox() { unsafe { if !websocket::HAS_CONNECTED { return; } } send_cmd_json("respond_mentions", None).await; diff --git a/src/messages.rs b/src/messages.rs index 4db60e8..5b5ccc9 100644 --- a/src/messages.rs +++ b/src/messages.rs @@ -1,6 +1,6 @@ use std::env; -use crate::{Args, Context}; +use crate::{lang, Args, Context}; use poise::serenity_prelude::json::Value; use poise::{serenity_prelude::CreateMessage, CreateReply, ReplyHandle}; @@ -29,7 +29,7 @@ pub struct EmbedOptions { impl Default for EmbedOptions { fn default() -> Self { return EmbedOptions { - desc: "default description".to_string(), + desc: lang!("default_embed_desc"), title: None, col: None, url: None, @@ -194,24 +194,14 @@ pub async fn send_dm(msg: String, args: Args, owners: Vec) { } -pub fn embed_post(post_data: &Value, url: &str, ephemeral: bool) -> EmbedOptions { +pub fn make_post_embed(post_data: &Value, url: &str, ephemeral: bool) -> EmbedOptions { let media_type = &post_data["post_data"]["media_type"]; - let desc_str = format!( - r#"Sorted by what I think will be most important - Spoilers and vote length anonymizer for fair review! - ## Post Data: - **Media type:** `{}` - **Post upvotes:** ||`{:>6}`|| - **Moderator votes:** ||`{:>6}`|| - **URL:** ||<{}>|| - - ## Listing Data: - **Added by:** `{{ human: {}, bot: {} }}` - **Approved by:** `{{ human: {}, bot: [not implemented] }}`"#, - if !media_type.is_null() { media_type.as_str().unwrap() } else { "None" }, + let desc_str = lang!( + "data_post_embed", post_data["post_data"]["upvotes"].as_i64().unwrap(), post_data["votes"]["mod_voters"].as_array().unwrap().len(), + if !media_type.is_null() { media_type.as_str().unwrap() } else { "None" }, url, if post_data["added"] ["by_human"].as_bool().unwrap() { "✅" } else { "❌" }, @@ -248,11 +238,11 @@ pub fn embed_post(post_data: &Value, url: &str, ephemeral: bool) -> EmbedOptions } -pub fn embed_post_removed(post_data: &Value, url: &str, ephemeral: bool) -> EmbedOptions { +pub fn make_removed_embed(post_data: &Value, url: &str, ephemeral: bool) -> EmbedOptions { return EmbedOptions { title: Some("REMOVED!".to_string()), - desc: format!( - "## Removed by `{}`\n**Reason:** {}\nURL: ||<{}>||\n\nJSON: ||`{}`||", + desc: lang!( + "data_post_removed_embed", post_data["removed_by"].as_str().unwrap(), if !post_data["remove_reason"].is_null() { post_data["remove_reason"].as_str().unwrap() } else { "None" }, diff --git a/src/python.rs b/src/python.rs index 4dc055f..d6146b2 100644 --- a/src/python.rs +++ b/src/python.rs @@ -1,5 +1,5 @@ use crate::messages::send_dm; -use crate::{errln, rs_println, Args}; +use crate::{errln, lang, rs_println, Args}; use std::fs; use std::ffi::CString; @@ -10,12 +10,12 @@ use pyo3::types::PyList; pub async fn start(args: Args) -> PyResult<()> { - rs_println!("Running Python program..."); + rs_println!("{}", lang!("starting_python_program")); let args_str = serde_json::to_string(&args).expect("Error serializing args to JSON"); let slash = if cfg!(windows) { "\\" } else if cfg!(unix) { "/" } else { "" }; - if slash.is_empty() { errln!("Man what kinda OS do you have? Neither unix or windows, what the hell!? I can't process this anymore, you're too weird!"); } + if slash.is_empty() { errln!("{}", lang!("invalid_os")); } let path = format!("{0}{1}src{1}python", env!("CARGO_MANIFEST_DIR"), slash); @@ -43,7 +43,7 @@ pub async fn start(args: Args) -> PyResult<()> { .map(|s| s.parse::().expect("Failed to parse ASSISTANT_OWNERS. Invalid syntax.")) .collect(); - send_dm(format!("Unknown internal Python Error: {:?}", from_python), args, own_vec_u64).await; + send_dm(lang!("python_err", format!("{:?}", from_python)), args, own_vec_u64).await; errln!("pyO3: {:?}", from_python); } return Ok(()); @@ -51,7 +51,8 @@ pub async fn start(args: Args) -> PyResult<()> { fn get_code(path: &str) -> String { - return fs::read_to_string(path) - .unwrap_or_else(|_| errln!("Failed to read Python file.\nPath: {}", path)) - .to_string(); + let file = fs::read_to_string(path); + if file.is_err() { errln!("Failed to read Python file.\nPath: {}", path); } + + return file.unwrap().to_string(); } diff --git a/src/python/data.py b/src/python/data.py index 5ce0367..de6dfde 100644 --- a/src/python/data.py +++ b/src/python/data.py @@ -66,7 +66,7 @@ class PostData: def read_data(bot: botPy.Bot) -> bool: - r_path = os.path.join(DATA_PATH, "reddit_data.json") + r_path = os.path.join(DATA_PATH, "re_data.json") if os.path.isfile(r_path): bot.data_f = open(r_path, "r+") @@ -75,8 +75,8 @@ def read_data(bot: botPy.Bot) -> bool: if not bot.args["py"]: return False - py_print("reddit_data.json not found, creating new from preset...") - with open(os.path.join(DATA_PATH, "reddit_data_preset.json", "r")) as f: + py_print("re_data.json not found, creating new from preset...") + with open(os.path.join(DATA_PATH, "re_data_preset.json", "r")) as f: data_preset_json = json.load(f) data_preset_json[botPy.BK_WEEKLY].pop("EXAMPLE VALUE", None) diff --git a/src/python/main.py b/src/python/main.py index 9285bf8..72f32b9 100644 --- a/src/python/main.py +++ b/src/python/main.py @@ -40,7 +40,7 @@ async def main(): rd = data.read_data(bot) if data_retries == 5 and not rd: - raise Exception("Couldn't read reddit_data.json: File doesn't exist") + raise Exception("Couldn't read re_data.json: File doesn't exist") py_print("Successfully read data!") diff --git a/src/re_cmds/add.rs b/src/re_cmds/add.rs new file mode 100644 index 0000000..689fad3 --- /dev/null +++ b/src/re_cmds/add.rs @@ -0,0 +1,68 @@ +use serde_json::json; + +use crate::data::get_mutex_data; +use crate::messages::send_msg; +use crate::{data, websocket, Context, Error, BK_WEEK}; +use crate::re_cmds::generic_fns::{is_bk_mod, to_shorturl}; +use crate::lang; + +#[poise::command( + slash_command, + prefix_command, + rename = "re_addpost", + category = "re", + required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL" +)] +/// Fetches a post from Reddit and adds it to the database. +pub async fn cmd( + ctx: Context<'_>, + #[description = "The post URL."] url: String, + #[description = "Wether to approve it after adding it"] approve: Option +) -> Result<(), Error> +{ + if !is_bk_mod(ctx.data().bk_mods.clone(), ctx.author().id.get()) { + send_msg(ctx, lang!("re_permdeny_bk_mod"), false, false).await; + return Ok(()); + } + + let shorturl_u = to_shorturl(&url); + let shorturl = &shorturl_u.unwrap_or(url.clone()); + + data::update_re_data(ctx.data()).await; + let reddit_data = get_mutex_data(&ctx.data().reddit_data).await?; + + if let Some(bk_week) = reddit_data.get(BK_WEEK) { + let a = approve.unwrap_or(false); + let r = websocket::send_cmd_json("add_post_url", Some(json!([&shorturl, a, true]))).await.unwrap(); + + if !r["value"].as_bool().unwrap() { + send_msg( + ctx, + r#"Unknown error! + Error trace: `bk_week_cmds.rs -> bk_week_add() -> Unknown error`. + Common reasons: The URL provided was likely invalid or 403: forbidden (e.g a private subreddit)."#.to_string(), + true, + true + ).await; + return Ok(()); + } + + if let Some(post) = bk_week.get(shorturl) { + if post.get("removed").is_some() { + send_msg(ctx, lang!("re_unremove_post_success", url), true, true).await; + } + else { + send_msg(ctx, lang!("re_update_post_success", url), true, true).await; + } + } + else { + send_msg(ctx, lang!("re_add_post_success", &shorturl), true, true).await; + } + + if a { + send_msg(ctx, lang!("re_also_approved"), true, true).await; + } + } + + return Ok(()); +} \ No newline at end of file diff --git a/src/re_cmds/admin_bind.rs b/src/re_cmds/admin_bind.rs new file mode 100644 index 0000000..9336004 --- /dev/null +++ b/src/re_cmds/admin_bind.rs @@ -0,0 +1,28 @@ +use crate::{data::dc_bind_bk, lang, messages::send_msg, Context, Error}; + +#[poise::command( + slash_command, + prefix_command, + rename = "admin_re_bindchannel", + category = "admin", + default_member_permissions = "ADMINISTRATOR", + guild_only, + required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL" +)] +/// Sets the channel where the bot will dump all Reddit data upon using /re_updateDiscord. +pub async fn cmd( + ctx: Context<'_> +) -> Result<(), Error> +{ + let c_id = ctx.channel_id().into(); + let r = dc_bind_bk(ctx.data(), ctx.guild_id().unwrap().into(), c_id).await; + + if r.is_ok() { + send_msg(ctx, lang!("bound_dc_channel", c_id), true, true).await; + } + else { + send_msg(ctx, lang!("dc_404"), false, false).await; + } + + return Ok(()); +} \ No newline at end of file diff --git a/src/re_cmds/approve.rs b/src/re_cmds/approve.rs new file mode 100644 index 0000000..ed3eeaf --- /dev/null +++ b/src/re_cmds/approve.rs @@ -0,0 +1,58 @@ +use serde_json::{json, Value}; + +use crate::{data::{self, get_mutex_data}, lang, messages::send_msg, re_cmds::generic_fns::is_bk_mod, websocket, Context, Error, BK_WEEK}; + +use super::generic_fns::send_embed_for_removed; + +#[poise::command( + slash_command, + prefix_command, + rename = "re_approvepost", + category = "re", + required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL" +)] +/// Approves a post in the database. +pub async fn cmd( + ctx: Context<'_>, + #[description = "The post URL."] url: String, + #[description = "Wether to approve or disapprove the post"] disapprove: Option +) -> Result<(), Error> +{ + if !is_bk_mod(ctx.data().bk_mods.clone(), ctx.author().id.get()) { + send_msg(ctx, lang!("re_permdeny_bk_mod"), false, false).await; + return Ok(()); + } + + data::update_re_data(ctx.data()).await; + let reddit_data = get_mutex_data(&ctx.data().reddit_data).await?; + + approve_cmd(ctx, &url, &reddit_data, !disapprove.unwrap_or(false)).await; + + return Ok(()); +} + + +async fn approve_cmd(ctx: Context<'_>, url: &str, reddit_data: &Value, approve: bool) { + if let Some(post) = reddit_data.get(BK_WEEK).unwrap().get(url) { + if post.get("removed").is_some() { + send_embed_for_removed(ctx, url, post).await; + return; + } + + let r = websocket::send_cmd_json("set_approve_post", Some(json!([approve, &url]))).await.unwrap(); + if r.get("value").is_some() { + if approve { + send_msg(ctx, lang!("re_approve_post_success"), true, true).await; + } + else { + send_msg(ctx, lang!("re_disapprove_post_success"), true, true).await; + } + } + else { + send_msg(ctx, lang!("re_err_trace", "`re_cmds -> approve.rs -> cmd() -> unwrap websocket result error`"), true, true).await; + } + } + else { + send_msg(ctx, lang!("re_404"), false, false).await; + } +} \ No newline at end of file diff --git a/src/re_cmds/generic_fns.rs b/src/re_cmds/generic_fns.rs new file mode 100644 index 0000000..e5a6b2d --- /dev/null +++ b/src/re_cmds/generic_fns.rs @@ -0,0 +1,34 @@ +use regex::Regex; +use serde_json::Value; + +use crate::{messages::{make_post_embed, make_removed_embed, send_embed}, Context, Error}; + +pub fn is_bk_mod(mod_list: Vec, uid: u64) -> bool { + return mod_list.contains(&uid); +} + +pub fn to_shorturl(url: &str) -> Result { + let re = Regex::new(r"comments/([a-zA-Z0-9]+)").unwrap(); + + if let Some(caps) = re.captures(url) { + let post_id = &caps[1]; + let short_url = format!("https://redd.it/{}", post_id); + return Ok(short_url); + } + + return Err("Invalid URL"); +} + + +pub async fn send_embed_for_post(ctx: Context<'_>, post: Value, url: &str) -> Result<(), Error> { + send_embed(ctx, make_post_embed(&post, url, true), true).await; + Ok(()) +} + +pub async fn send_embed_for_removed(ctx: Context<'_>, url: &str, post: &Value) { + send_embed( + ctx, + make_removed_embed(post, url, true), + true + ).await; +} \ No newline at end of file diff --git a/src/re_cmds/get.rs b/src/re_cmds/get.rs new file mode 100644 index 0000000..54f34ce --- /dev/null +++ b/src/re_cmds/get.rs @@ -0,0 +1,50 @@ +use serde_json::Value; + +use crate::{data::{self, get_mutex_data}, lang, messages::send_msg, re_cmds::generic_fns::send_embed_for_post, rs_println, Context, Error, BK_WEEK}; + +use super::generic_fns::send_embed_for_removed; + +#[poise::command( + slash_command, + prefix_command, + rename = "re_getpost", + category = "re", + required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL | EMBED_LINKS" +)] +/// Fetches and shows a single post, just for you. The post has to be within the database. +pub async fn cmd( + ctx: Context<'_>, + #[description = "The post URL."] url: String +) -> Result<(), Error> +{ + data::update_re_data(ctx.data()).await; + + let reddit_data = get_mutex_data(&ctx.data().reddit_data).await?; + + if let Some(post) = get_post_from_data(ctx, &reddit_data, &url).await? { + send_embed_for_post(ctx, post, &url).await?; + } + + return Ok(()); +} + + +async fn get_post_from_data(ctx: Context<'_>, reddit_data: &Value, url: &str) -> Result, Error> { + if let Some(bk_week) = reddit_data.get(BK_WEEK) { + if let Some(post) = bk_week.get(url) { + if post.get("removed").is_some() { + send_embed_for_removed(ctx, url, post).await; + return Ok(None); + } + return Ok(Some(post.clone())); + } + else { + send_msg(ctx, lang!("re_404", url), true, true).await; + } + } + else { + send_msg(ctx, lang!("re_data_corrupted", url), true, true).await; + rs_println!("{}", serde_json::to_string_pretty(reddit_data)?); + } + return Ok(None); +} \ No newline at end of file diff --git a/src/re_cmds/remove.rs b/src/re_cmds/remove.rs new file mode 100644 index 0000000..f19dfb0 --- /dev/null +++ b/src/re_cmds/remove.rs @@ -0,0 +1,40 @@ +use serde_json::json; + +use crate::{lang, messages::send_msg, re_cmds::generic_fns::is_bk_mod, websocket::send_cmd_json, Context, Error}; + +#[poise::command( + slash_command, + prefix_command, + rename = "re_removepost", + category = "re", + required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL" +)] +/// Removes a post from the database. +pub async fn cmd( + ctx: Context<'_>, + #[description = "The post URL."] url: String, + #[description = "The reason of the removal."] reason: Option +) -> Result<(), Error> +{ + if !is_bk_mod(ctx.data().bk_mods.clone(), ctx.author().id.get()) { + send_msg(ctx, lang!("re_permdeny_bk_mod"), false, false).await; + return Ok(()); + } + + let auth = &ctx.author().name; + let r = send_cmd_json("remove_post_url", Some(json!([&url, &auth, &reason]))).await.unwrap(); + + if r["value"].as_bool().unwrap() { + send_msg( + ctx, + lang!("re_remove_post_success"), + true, + true + ).await; + } + else { + send_msg(ctx, lang!("re_404"), false, false).await; + } + + return Ok(()); +} \ No newline at end of file diff --git a/src/re_cmds/top.rs b/src/re_cmds/top.rs new file mode 100644 index 0000000..e723d11 --- /dev/null +++ b/src/re_cmds/top.rs @@ -0,0 +1,77 @@ +use std::collections::HashMap; + +use crate::{data::get_mutex_data, re_cmds::generic_fns::send_embed_for_post, Context, Error, BK_WEEK}; + +#[derive(poise::ChoiceParameter, PartialEq)] +enum TopCategory { + Upvotes, + ModVotes, + Oldest, + Newest +} + + +#[poise::command( + slash_command, + prefix_command, + rename = "re_topposts", + category = "re", + required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL | EMBED_LINKS" +)] +/// Shows the top N (up to 10, default is 3) posts within a certain category, such as upvotes. +pub async fn cmd( + ctx: Context<'_>, + #[description = "The sorting criteria, such as upvotes."] + category: TopCategory, + #[description = "The amount of posts to show (max 10, default is 3)."] + #[min = 1] + #[max = 10] + amount: Option +) -> Result<(), Error> +{ + let mut all: HashMap<&str, i32> = HashMap::new(); + let posts = &get_mutex_data(&ctx.data().reddit_data).await?[BK_WEEK]; + let posts_u = posts.as_object().unwrap(); + + for (url, dat) in posts_u { + if dat.get("removed").is_some() { continue; } + + let val: i32 = match category { + TopCategory::Upvotes => dat["post_data"]["upvotes"].as_i64().unwrap() as i32, + TopCategory::ModVotes => dat["votes"]["mod_voters"].as_array().unwrap().len() as i32, + TopCategory::Oldest + | TopCategory::Newest => dat["post_data"]["date_unix"].as_i64().unwrap() as i32, + }; + + all.insert(url, val); + } + + let amount_u = amount.unwrap_or(3); + let amount_clamped = amount_u.clamp(1, 10); + + let top = + if category != TopCategory::Oldest + { largest_n (&all, amount_clamped as usize) } + else { smallest_n(&all, amount_clamped as usize) }; + + for post in top { + let url = post.0; + let _ = send_embed_for_post(ctx, posts_u[url].clone(), url).await; + } + + return Ok(()); +} + + +fn largest_n<'a>(map: &'a HashMap<&'a str, i32>, n: usize) -> Vec<(&'a str, i32)> { + let mut vec: Vec<_> = map.iter().collect(); + vec.sort_unstable_by(|a, b| b.1.cmp(a.1)); + vec.into_iter().take(n).map(|(&k, &v)| (k, v)).collect() +} + + +fn smallest_n<'a>(map: &'a HashMap<&'a str, i32>, n: usize) -> Vec<(&'a str, i32)> { + let mut vec: Vec<_> = map.iter().collect(); + vec.sort_unstable_by(|a, b| a.1.cmp(b.1)); + vec.into_iter().take(n).map(|(&k, &v)| (k, v)).collect() +} \ No newline at end of file diff --git a/src/re_cmds/update.rs b/src/re_cmds/update.rs new file mode 100644 index 0000000..da8af2a --- /dev/null +++ b/src/re_cmds/update.rs @@ -0,0 +1,305 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use poise::{serenity_prelude::{ChannelId, EditMessage, GetMessages, Http, Message, MessageId, UserId}, ReplyHandle}; +use serde_json::{json, Map, Value}; + +use crate::{data::{self, get_mutex_data}, lang, messages::{edit_reply, embed_from_options, http_send_embed, make_post_embed, make_removed_embed, send_msg}, websocket::send_cmd_json, Context, Error, BK_WEEK}; + +#[poise::command( + slash_command, + prefix_command, + rename = "re_updatediscord", + category = "re", + guild_only, + guild_cooldown = 120, + required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL | READ_MESSAGE_HISTORY | EMBED_LINKS" +)] +/// Updates the binded Discord channel with the bot's current Reddit data. +pub async fn cmd( + ctx: Context<'_>, + #[description = "Only adds new posts, leaves everything else unchanged."] + only_add: Option, + #[description = "The max age of a post (in days). Any post older than this will be removed. (0 is infinite.)"] + #[min = 0] + #[max = 65535] + max_age: Option +) -> Result<(), Error> +{ + let http = ctx.http(); + + let mut p_text = "`/bk_week_update`:".to_string(); + + let progress = send_msg(ctx, p_text.clone(), true, true).await.unwrap(); + p_text = update_progress(ctx, progress.clone(), p_text, "\nFetching new posts & updating data file...".to_string()).await; + + let max_age_u = max_age.unwrap_or(8); + let max_age_secs = max_age_u as u64 * (60 * 60 * 24); + + send_cmd_json("add_new_posts", Some(json!([max_age_secs]))).await; + data::update_re_data(ctx.data()).await; + let r_data = get_mutex_data(&ctx.data().reddit_data).await?; + + let c_id_u = get_c_id(ctx).await; + + if c_id_u.is_none() { + send_msg(ctx, "Could not find bk_week_channel in data!\nHint: Run (or tell an admin to run) `/bk_admin_bind` in a (preferably read-only) channel.".to_string(), true, true).await; + return Ok(()); + } + + let c_id = c_id_u.unwrap(); + + // Reading messages + p_text = update_progress(ctx, progress.clone(), p_text.clone(), format!("✅\nReading messages in <#{}>...", c_id)).await; + let msgs = read_msgs(http, ctx.framework().bot_id, c_id).await; + + // Parsing messages to JSON + p_text = update_progress(ctx, progress.clone(), p_text.clone(), "✅\nParsing messages to JSON...".to_string()).await; + let msgs_json = msgs_to_json(msgs, &r_data, max_age_secs).await; + + // Adding new posts + p_text = update_progress(ctx, progress.clone(), p_text.clone(), "✅\nAdding new posts...".to_string()).await; + let weekly_art = r_data[BK_WEEK].as_object().unwrap(); + add_posts(http, c_id, weekly_art, &msgs_json, max_age_secs).await; + + // Stop if only_add + if only_add.unwrap_or(false) { + send_msg(ctx, "`/bk_week_update`\n## Done!".to_string(), true, true).await; + update_progress(ctx, progress.clone(), p_text, "✅\n## Done!".to_string()).await; + return Ok(()); + } + + // Editing updated posts + p_text = update_progress(ctx, progress.clone(), p_text.clone(), "✅\nEditing updated posts...".to_string()).await; + edit_posts(http, c_id, weekly_art, &msgs_json).await; + + // Removing removed posts + p_text = update_progress(ctx, progress.clone(), p_text.clone(), "✅\nRemoving removed posts...".to_string()).await; + remove_posts(http, c_id, weekly_art, &msgs_json).await; + + // Removing old posts + if max_age_u > 0 { + p_text = update_progress(ctx, progress.clone(), p_text.clone(), format!("✅\nRemoving old posts (threshold: {}d)...", max_age_u)).await; + remove_old(http, c_id, &msgs_json).await; + send_cmd_json("remove_old_posts", Some(json!([max_age_secs]))).await; + } + + // Removing duplicate posts + p_text = update_progress(ctx, progress.clone(), p_text.clone(), "✅\nRemoving duplicate posts...".to_string()).await; + remove_dupes(http, c_id, &msgs_json).await; + + // Done + update_progress(ctx, progress.clone(), p_text, "✅\n## Done!".to_string()).await; + send_msg(ctx, "`/bk_week_update`\n## Done!".to_string(), true, true).await; + + return Ok(()); +} + + +async fn update_progress(ctx: Context<'_>, p: ReplyHandle<'_>, t: String, added_t: String) -> String { + let p_text = format!("{} {}", t, added_t); + + edit_reply(ctx, p, p_text.clone()).await; + return p_text; +} + + +async fn get_c_id(ctx: Context<'_>) -> Option { + if !data::dc_contains_server(ctx.data(), ctx.guild_id().unwrap().into()).await { + send_msg(ctx, lang!("dc_404"), false, false).await; + return None; + } + + let d = get_mutex_data(&ctx.data().discord_data).await.unwrap(); + let c_id_u = + d["servers"] + [ctx.guild_id().unwrap().to_string()] + ["bk_week_channel"].as_u64().unwrap(); + + let c_id = ChannelId::new(c_id_u); + + return Some(c_id); +} + + +async fn read_msgs(http: &Http, bot_id: UserId, c_id: ChannelId) -> Vec { + let b = GetMessages::new().limit(100); + let mut msgs = c_id.messages(http, b).await.unwrap(); + msgs.retain(|item| item.author.id == bot_id); + + let mut last_msg: Option = msgs.last().cloned(); + + while last_msg.is_some() { + let new_b = GetMessages::new().limit(100).before(last_msg.clone().unwrap()); + let new_msgs = c_id.messages(http, new_b).await.unwrap(); + + last_msg = new_msgs.last().cloned(); + + if new_msgs.is_empty() { + break; + } + + let filtered_msgs: Vec = new_msgs + .into_iter() + .filter(|item| item.author.id == bot_id) + .collect(); + + msgs.extend(filtered_msgs); + } + + return msgs; +} + + +async fn msgs_to_json(msgs: Vec, reddit_data: &Value, max_age: u64) -> Value { + let mut msgs_json: Value = json!({"no_change": {}, "updated": {}, "removed": {}, "duplicates": {}, "old": {}}); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + + for msg in msgs { + if msg.embeds.is_empty() { continue; } + if msg.embeds[0].url.is_none() { continue; } + + let url = msg.embeds[0].url.clone().unwrap(); + + // duplicates + if ["no_change", "updated", "removed", "old"] + .iter() + .any(|key| msgs_json[key].as_object().unwrap().contains_key(&url)) + { + let dupes_mut = msgs_json["duplicates"].as_object_mut().unwrap(); + if !dupes_mut.contains_key(&url) { + dupes_mut.insert(url.clone(), json!(msg.id.get())); + } + continue; + } + + let msg_desc = &msg.embeds[0].description.clone().unwrap(); + let msg_lines = msg_desc.split("\n"); + let msg_last_len = msg_lines.clone().last().unwrap().len(); + + if msg_last_len < 13 { continue; } + + let msg_json_str = &msg_lines.clone().last().unwrap()[9..msg_last_len - 3]; + + let msg_json = serde_json::from_str(msg_json_str); + if msg_json.is_err() { continue; } + + let mut u_json: Value = msg_json.unwrap(); + let re_url = &reddit_data[BK_WEEK][&url]; + + let post_date = re_url["post_data"]["date_unix"].as_u64().unwrap_or(0); + + // old + if now - post_date > max_age { + if let Some(obj) = msgs_json["old"].as_object_mut() { + obj.insert(url.clone(), json!(msg.id.get())); + continue; + } + } + + // removed + if re_url.get("removed").is_some() { + if u_json.get("removed").is_some() { + // no change + if let Some(obj) = msgs_json["no_change"].as_object_mut() { + obj.insert(url.clone(), json!(msg.id.get())); + continue; + } + } + + // removed + if let Some(obj) = msgs_json["removed"].as_object_mut() { + obj.insert(url.clone(), json!(msg.id.get())); + continue; + } + } + + // updated + if u_json["added"] != re_url["added"] + || u_json["approved"] != re_url["approved"] + || u_json["post_data"]["upvotes"] != re_url["post_data"]["upvotes"] + || u_json["votes"]["mod_voters"] != re_url["votes"]["mod_voters"] + { + u_json.as_object_mut().unwrap().insert("msg_id".to_string(), Value::String(msg.id.clone().to_string())); + + if let Some(obj) = msgs_json["updated"].as_object_mut() { + obj.insert(url.clone(), json!(msg.id.get())); + continue; + } + } + + // no change + if let Some(obj) = msgs_json["no_change"].as_object_mut() { + obj.insert(url.clone(), json!(msg.id.get())); + } + } + + return msgs_json; +} + + +async fn add_posts(http: &Http, c_id: ChannelId, r_data: &Map, msgs_json: &Value, max_age: u64) { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + + for url in r_data.keys() { + if ["no_change", "updated", "removed", "old", "duplicates"] + .iter() + .any(|key| msgs_json[key].as_object().unwrap().contains_key(url)) + { continue; } + + let post_date = r_data[url]["post_data"]["date_unix"].as_u64().unwrap(); + if now - post_date > max_age { continue; } + + if r_data[url].get("removed").is_some() { + http_send_embed(http, c_id, make_removed_embed(&r_data[url], url, false)).await; + continue; + } + + http_send_embed(http, c_id, make_post_embed(&r_data[url], url, false)).await; + } +} + + +async fn edit_posts(http: &Http, c_id: ChannelId, r_data: &Map, msgs_json: &Value) { + for (url, msg_id) in msgs_json["updated"].as_object().unwrap() { + let mut msg = http.get_message(c_id, MessageId::new(msg_id.as_u64().unwrap())).await.unwrap(); + let r = EditMessage::new() + .embeds(vec![embed_from_options(make_post_embed(&r_data[url], url, false))]); + + let _ = msg.edit(http, r).await; + } +} + + +async fn remove_posts(http: &Http, c_id: ChannelId, r_data: &Map, msgs_json: &Value) { + for (url, msg_id) in msgs_json["removed"].as_object().unwrap() { + let mut msg = http.get_message(c_id, MessageId::new(msg_id.as_u64().unwrap())).await.unwrap(); + let r = EditMessage::new() + .embeds(vec![embed_from_options(make_removed_embed(&r_data[url], url, false))]); + + let _ = msg.edit(http, r).await; + } +} + + +async fn remove_old(http: &Http, c_id: ChannelId, msgs_json: &Value) { + for (_url, msg_id) in msgs_json["old"].as_object().unwrap() { + let msg = http.get_message(c_id, MessageId::new(msg_id.as_u64().unwrap())).await.unwrap(); + let _ = msg.delete(http).await; + } +} + + +async fn remove_dupes(http: &Http, c_id: ChannelId, msgs_json: &Value) { + for (_url, msgs) in msgs_json["duplicates"].as_object().unwrap() { + for msg_id in msgs.as_array().unwrap() { + let msg = http.get_message(c_id, MessageId::new(msg_id.as_u64().unwrap())).await.unwrap(); + let _ = msg.delete(http).await; + } + } +} \ No newline at end of file diff --git a/src/re_cmds/vote.rs b/src/re_cmds/vote.rs new file mode 100644 index 0000000..b8e5413 --- /dev/null +++ b/src/re_cmds/vote.rs @@ -0,0 +1,67 @@ +use serde_json::json; + +use crate::{data::{self, get_mutex_data}, lang, messages::send_msg, re_cmds::generic_fns::{is_bk_mod, send_embed_for_removed}, websocket::send_cmd_json, Context, Error, BK_WEEK}; + +#[poise::command( + slash_command, + prefix_command, + rename = "re_vote", + category = "re", + required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL" +)] +/// Adds/removes a vote from a post. These votes are not tied to Reddit upvotes. +pub async fn cmd( + ctx: Context<'_>, + #[description = "The post URL."] url: String, + #[description = "Wether to undo your vote or not"] un_vote: Option +) -> Result<(), Error> +{ + data::update_re_data(ctx.data()).await; + let uid = ctx.author().id.get(); + let re_data = get_mutex_data(&ctx.data().reddit_data).await?; + let post_data = re_data[BK_WEEK].clone(); + let unw_vote = un_vote.unwrap_or(false); + + if post_data.get(&url).is_none() { + send_msg(ctx, lang!("re_404"), false, false).await; + return Ok(()); + } + if post_data[&url].get("removed").is_some() { + send_embed_for_removed(ctx, &url, &post_data[&url]).await; + return Ok(()); + } + + let url_data = &post_data[&url]; + + let is_mod = is_bk_mod(ctx.data().bk_mods.clone(), ctx.author().id.get()); + let voters_dc = url_data["votes"]["voters_dc"].as_array().unwrap(); + let mod_voters = url_data["votes"]["mod_voters"].as_array().unwrap(); + let voters = if is_mod { mod_voters } else { voters_dc }; + + if voters.contains(&json!(uid)) && !unw_vote { + send_msg(ctx, lang!("re_already_voted"), true, true).await; + return Ok(()); + } + else if !voters.contains(&json!(uid)) && unw_vote { + send_msg(ctx, lang!("re_havent_voted_remove_vote"), true, true).await; + return Ok(()); + } + + let r = send_cmd_json("set_vote_post", Some(json!([url, uid, is_mod, true, unw_vote]))).await.unwrap(); + let unw_r = r["value"].as_bool().unwrap(); + + if unw_r && !unw_vote && is_mod { + send_msg(ctx, lang!("re_vote_success_mod"), true, true).await; + } + else if unw_r && !unw_vote && !is_mod { + send_msg(ctx, lang!("re_vote_success"), true, true).await; + } + else if unw_r && unw_vote { + send_msg(ctx, lang!("re_vote_remove_success"), true, true).await; + } + else { + send_msg(ctx, lang!("re_vote_err"), true, true).await; + } + + return Ok(()); +} \ No newline at end of file diff --git a/src/schedule.rs b/src/schedule.rs new file mode 100644 index 0000000..683fd7b --- /dev/null +++ b/src/schedule.rs @@ -0,0 +1,35 @@ +use std::future::Future; +use std::pin::Pin; +use std::time::Duration; + +use tokio::task::JoinHandle; +use tokio::time; + +use crate::{lang, rs_println}; + + +pub type Schedule = (Duration, fn() -> Pin + Send>>); + + +pub async fn run_schedule Pin + Send>>>(d: Duration, f: F) { + let mut ticker = time::interval(d); + loop { + ticker.tick().await; + f().await; + } +} + + +pub async fn run_schedules(schedules: Vec) { + let mut handles: Vec> = vec![]; + + rs_println!("{}", lang!("starting_schedules")); + for (d, f) in schedules { + let handle = tokio::spawn(run_schedule(d, f)); + handles.push(handle); + } + + for handle in handles { + let _ = handle.await; + } +} \ No newline at end of file diff --git a/src/websocket.rs b/src/websocket.rs index eba23a3..9a03bcd 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use crate::messages::send_dm; -use crate::rs_println; +use crate::{lang, rs_println}; use crate::Args; type Sender = Arc, tungstenite::Message>>>>; @@ -66,11 +66,11 @@ pub async fn send_cmd_json(func_name: &str, func_args: Option) -> Option< let r = receive_response().await; if !["respond_mentions"].contains(&func_name) || ::parse().dev { - rs_println!("Received from Python: [RESPONSE] {:?}", r); + rs_println!("{}", lang!("python_socket_response", format!("{:?}", r))); } if r.is_none() { - rs_println!("--- Response from Python is None!"); + rs_println!("{}", lang!("python_socket_null")); } return r; @@ -99,10 +99,10 @@ async fn receive_response() -> Option { pub async fn start(args: Args, owners: Vec) { - rs_println!("Starting local websocket..."); + rs_println!("{}", lang!("starting_socket")); let ip = format!("127.0.0.1:{}", args.port); let listener = TcpListener::bind(&ip).await.unwrap(); - rs_println!("WebSocket server running on ws://{}", ip); + rs_println!("{}", lang!("started_socket", ip)); tokio::spawn(handle_connections(listener, args, owners)); } @@ -129,18 +129,18 @@ async fn handle_connections(listener: TcpListener, args: Args, owners: Vec) async fn handle_message(msg: tungstenite::protocol::Message, args: Args, owners: Vec) { match msg { tungstenite::Message::Text(text) => { - rs_println!("Received from Python: {}", text); + rs_println!("{}", lang!("socket_received_python", text.clone())); if let Some(stripped) = text.strip_prefix("json:") { let t_json: Value = serde_json::from_str(stripped).unwrap(); if t_json.get("error").is_some() { - send_dm("Unknown internal Python error occurred: Websocket response error.".to_string(), args, owners).await; + send_dm(lang!("python_socket_err"), args, owners).await; } } unsafe { if !REPLY_HELLO { - send_msg("[Connection test] Hello from Rust!").await; + send_msg(&lang!("socket_rust_connection_test")).await; REPLY_HELLO = true; HAS_CONNECTED = true; } @@ -148,12 +148,12 @@ async fn handle_message(msg: tungstenite::protocol::Message, args: Args, owners: } tungstenite::Message::Binary(bytes) => { if args.dev && !args.noping { - rs_println!("[Binary] from Python: {:?}", bytes); + rs_println!("{}", lang!("python_socket_binary_response", format!("{:?}", bytes))); } } _ => { if args.dev && !args.noping { - rs_println!("Received from Python: [UNKNOWN / OTHER]"); + rs_println!("{}", lang!("python_socket_unknown_response")); } } }