From 947592ce8ccc558c1a9857e83b06c95f5ed4cb61 Mon Sep 17 00:00:00 2001 From: Benjamin Scherer Date: Thu, 3 Sep 2020 02:56:50 -0600 Subject: [PATCH] change keyword system This changes keywords to be added to the entire server by default, instead of requiring users to follow channels they want to keep up with. User configuration options are still provided through channel-specific keywords and the ability to mute channels to prevent notifications about server-wide keywords there. Because of the added complexity posed by this system, the commands module was refactored into several submodules. Started explicitly passing the gateway intents that highlights requires to perhaps have a small performance benefit. Renamed the removeserver command to remove-server because squishedcase is ugly. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/commands.rs | 844 --------------------------------------- src/commands/keywords.rs | 623 +++++++++++++++++++++++++++++ src/commands/mod.rs | 270 +++++++++++++ src/commands/mutes.rs | 357 +++++++++++++++++ src/commands/util.rs | 174 ++++++++ src/db.rs | 270 ++++++++++--- src/main.rs | 16 +- src/util.rs | 7 +- 10 files changed, 1649 insertions(+), 916 deletions(-) delete mode 100644 src/commands.rs create mode 100644 src/commands/keywords.rs create mode 100644 src/commands/mod.rs create mode 100644 src/commands/mutes.rs create mode 100644 src/commands/util.rs diff --git a/Cargo.lock b/Cargo.lock index ec22917..dc7c2d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -407,7 +407,7 @@ dependencies = [ [[package]] name = "highlights" -version = "1.0.0-beta.5" +version = "1.0.0-beta.6" dependencies = [ "chrono", "dotenv", diff --git a/Cargo.toml b/Cargo.toml index da8c451..f8caf30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "highlights" -version = "1.0.0-beta.5" +version = "1.0.0-beta.6" authors = ["Benjamin Scherer "] repository = "https://github.com/ThatsNoMoon/highlights" license = "OSL-3.0" diff --git a/src/commands.rs b/src/commands.rs deleted file mode 100644 index 7b6b476..0000000 --- a/src/commands.rs +++ /dev/null @@ -1,844 +0,0 @@ -// Copyright 2020 Benjamin Scherer -// Licensed under the Open Software License version 3.0 - -use once_cell::sync::Lazy; -use serenity::{ - client::Context, - model::{ - channel::{ChannelType, GuildChannel, Message}, - id::{ChannelId, GuildId}, - Permissions, - }, -}; - -use std::{collections::HashMap, convert::TryInto, fmt::Write}; - -use crate::{ - db::{Follow, Keyword}, - global::{EMBED_COLOR, MAX_KEYWORDS}, - monitoring::Timer, - util::{error, question, success}, - Error, -}; - -macro_rules! check_guild { - ($ctx:expr, $message:expr) => {{ - match $message.guild_id { - None => { - return error( - $ctx, - $message, - "You must run this command in a server!", - ) - .await - } - Some(id) => id, - } - }}; -} - -macro_rules! check_empty_args { - ($args:expr, $ctx:expr, $message:expr) => {{ - if $args == "" { - return question($ctx, $message).await; - } - }}; -} - -pub async fn add( - ctx: &Context, - message: &Message, - args: &str, -) -> Result<(), Error> { - let _timer = Timer::command("add"); - let guild_id = check_guild!(ctx, message); - - check_empty_args!(args, ctx, message); - - if args.len() <= 2 { - return error( - ctx, - message, - "You can't highlight keywords shorter than 3 characters!", - ) - .await; - } - - let keyword = Keyword { - keyword: args.to_lowercase(), - user_id: message.author.id.0.try_into().unwrap(), - guild_id: guild_id.0.try_into().unwrap(), - }; - - { - let keyword_count = - Keyword::user_keyword_count(message.author.id).await?; - - if keyword_count >= MAX_KEYWORDS { - static MSG: Lazy String> = Lazy::new(|| { - format!("You can't create more than {} keywords!", MAX_KEYWORDS) - }); - - return error(ctx, message, MSG.as_str()).await; - } - } - - if keyword.clone().exists().await? { - return error(ctx, message, "You already added that keyword!").await; - } - - keyword.insert().await?; - - success(ctx, message).await -} - -pub async fn remove( - ctx: &Context, - message: &Message, - args: &str, -) -> Result<(), Error> { - let _timer = Timer::command("remove"); - let guild_id = check_guild!(ctx, message); - - check_empty_args!(args, ctx, message); - - let keyword = Keyword { - keyword: args.to_lowercase(), - user_id: message.author.id.0.try_into().unwrap(), - guild_id: guild_id.0.try_into().unwrap(), - }; - - if !keyword.clone().exists().await? { - return error(ctx, message, "You haven't added that keyword!").await; - } - - keyword.delete().await?; - - success(ctx, message).await -} - -pub async fn remove_server( - ctx: &Context, - message: &Message, - args: &str, -) -> Result<(), Error> { - let _timer = Timer::command("removeserver"); - check_empty_args!(args, ctx, message); - - let guild_id = match args.parse() { - Ok(id) => GuildId(id), - Err(_) => return error(ctx, message, "Invalid server ID!").await, - }; - - match Keyword::delete_in_guild(message.author.id, guild_id).await? { - 0 => { - error( - ctx, - message, - "You didn't have any keywords with that server ID!", - ) - .await - } - _ => success(ctx, message).await, - } -} - -pub async fn follow( - ctx: &Context, - message: &Message, - args: &str, -) -> Result<(), Error> { - let _timer = Timer::command("follow"); - let guild_id = check_guild!(ctx, message); - - check_empty_args!(args, ctx, message); - - let user_id = message.author.id; - let self_id = ctx.cache.current_user_id().await; - - let channels = ctx.cache.guild_channels(guild_id).await.unwrap(); - let channels = channels - .into_iter() - .filter(|(_, channel)| channel.kind == ChannelType::Text) - .collect(); - - let mut followed = vec![]; - let mut already_followed = vec![]; - let mut not_found = vec![]; - let mut forbidden = vec![]; - - for arg in args.split_whitespace() { - let channel = get_channel_from_arg(&channels, arg); - - match channel { - None => not_found.push(arg), - Some(channel) => { - let user_can_read = channel - .permissions_for_user(ctx, user_id) - .await? - .read_messages(); - let self_can_read = channel - .permissions_for_user(ctx, self_id) - .await? - .read_messages(); - - if !user_can_read || !self_can_read { - forbidden.push(arg); - } else { - let user_id: i64 = user_id.0.try_into().unwrap(); - let channel_id: i64 = channel.id.0.try_into().unwrap(); - - let follow = Follow { - user_id, - channel_id, - }; - - if follow.clone().exists().await? { - already_followed.push(format!("<#{}>", channel_id)); - } else { - followed.push(format!("<#{}>", channel.id)); - follow.insert().await?; - } - } - } - } - } - - let mut msg = String::with_capacity(45); - - if !followed.is_empty() { - msg.push_str("Followed channels: "); - msg.push_str(&followed.join(", ")); - - message.react(ctx, '✅').await?; - } - - if !already_followed.is_empty() { - if !msg.is_empty() { - msg.push('\n'); - } - msg.push_str("Channels already followed: "); - msg.push_str(&already_followed.join(", ")); - - message.react(ctx, '❌').await?; - } - - if !not_found.is_empty() { - if !msg.is_empty() { - msg.push('\n'); - } - msg.push_str("Couldn't find channels: "); - msg.push_str(¬_found.join(", ")); - - message.react(ctx, '❓').await?; - } - - if !forbidden.is_empty() { - if !msg.is_empty() { - msg.push('\n'); - } - msg.push_str("Unable to follow channels: "); - msg.push_str(&forbidden.join(", ")); - - if already_followed.is_empty() { - message.react(ctx, '❌').await?; - } - } - - message - .channel_id - .send_message(ctx, |m| { - m.content(msg).allowed_mentions(|m| m.empty_parse()) - }) - .await?; - - Ok(()) -} - -pub async fn unfollow( - ctx: &Context, - message: &Message, - args: &str, -) -> Result<(), Error> { - let _timer = Timer::command("unfollow"); - check_empty_args!(args, ctx, message); - - let channels = match message.guild_id { - Some(guild_id) => { - ctx.cache.guild_channels(guild_id).await.map(|channels| { - channels - .into_iter() - .filter(|(_, channel)| channel.kind == ChannelType::Text) - .collect() - }) - } - None => None, - }; - - let user_id = message.author.id.0.try_into().unwrap(); - - let mut unfollowed = vec![]; - let mut not_followed = vec![]; - let mut not_found = vec![]; - - for arg in args.split_whitespace() { - let channel = channels - .as_ref() - .and_then(|channels| get_channel_from_arg(channels, arg)); - - match channel { - None => { - if let Ok(channel_id) = arg.parse::() { - let channel_id = channel_id.try_into().unwrap(); - - let follow = Follow { - user_id, - channel_id, - }; - - if !follow.clone().exists().await? { - not_found.push(arg); - } else { - unfollowed.push(format!("<#{0}> ({0})", channel_id)); - follow.delete().await?; - } - } else { - not_found.push(arg); - } - } - Some(channel) => { - let channel_id = channel.id.0.try_into().unwrap(); - - let follow = Follow { - user_id, - channel_id, - }; - - if !follow.clone().exists().await? { - not_followed.push(format!("<#{}>", channel_id)); - } else { - unfollowed.push(format!("<#{}>", channel.id)); - follow.delete().await?; - } - } - } - } - - let mut msg = String::with_capacity(50); - - if !unfollowed.is_empty() { - msg.push_str("Unfollowed channels: "); - msg.push_str(&unfollowed.join(", ")); - - message.react(ctx, '✅').await?; - } - - if !not_followed.is_empty() { - if !msg.is_empty() { - msg.push('\n'); - } - msg.push_str("You weren't following channels: "); - msg.push_str(¬_followed.join(", ")); - - message.react(ctx, '❌').await?; - } - - if !not_found.is_empty() { - if !msg.is_empty() { - msg.push('\n'); - } - msg.push_str("Couldn't find channels: "); - msg.push_str(¬_found.join(", ")); - - message.react(ctx, '❓').await?; - } - - message - .channel_id - .send_message(ctx, |m| { - m.content(msg).allowed_mentions(|m| m.empty_parse()) - }) - .await?; - - Ok(()) -} - -fn get_channel_from_arg<'c>( - channels: &'c HashMap, - arg: &str, -) -> Option<&'c GuildChannel> { - if let Ok(id) = arg.parse::() { - return channels.get(&ChannelId(id)); - } - - if let Some(id) = arg - .strip_prefix("<#") - .and_then(|arg| arg.strip_suffix(">")) - .and_then(|arg| arg.parse::().ok()) - { - return channels.get(&ChannelId(id)); - } - - let mut iter = channels - .iter() - .map(|(_, channel)| channel) - .filter(|channel| channel.name.as_str().eq_ignore_ascii_case(arg)); - - if let Some(first) = iter.next() { - if iter.next().is_none() { - return Some(first); - } - } - - None -} - -pub async fn keywords( - ctx: &Context, - message: &Message, - _: &str, -) -> Result<(), Error> { - let _timer = Timer::command("keywords"); - match message.guild_id { - Some(guild_id) => { - let keywords = - Keyword::user_keywords_in_guild(message.author.id, guild_id) - .await? - .into_iter() - .map(|keyword| keyword.keyword) - .collect::>(); - - if keywords.is_empty() { - return error( - ctx, - message, - "You haven't added any keywords yet!", - ) - .await; - } - - let guild_name = ctx - .cache - .guild_field(guild_id, |g| g.name.clone()) - .await - .ok_or("Couldn't get guild to list keywords")?; - - let response = format!( - "{}'s keywords in {}:\n - {}", - message.author.name, - guild_name, - keywords.join("\n - ") - ); - - message - .channel_id - .send_message(ctx, |m| { - m.content(response).allowed_mentions(|m| m.empty_parse()) - }) - .await?; - } - None => { - let keywords = Keyword::user_keywords(message.author.id).await?; - - if keywords.is_empty() { - return error( - ctx, - message, - "You haven't added any keywords yet!", - ) - .await; - } - - let mut keywords_by_guild = HashMap::new(); - - for keyword in keywords { - let guild_id = GuildId(keyword.guild_id.try_into().unwrap()); - - keywords_by_guild - .entry(guild_id) - .or_insert_with(Vec::new) - .push(keyword.keyword); - } - - let mut response = String::new(); - - for (guild_id, keywords) in keywords_by_guild { - if !response.is_empty() { - response.push_str("\n\n"); - } - - let guild_name = ctx - .cache - .guild_field(guild_id, |g| g.name.clone()) - .await - .unwrap_or_else(|| { - format!(" ({})", guild_id) - }); - - write!( - &mut response, - "Your keywords in {}:\n – {}", - guild_name, - keywords.join("\n – ") - ) - .unwrap(); - } - - message.channel_id.say(ctx, response).await?; - } - } - - Ok(()) -} - -pub async fn follows( - ctx: &Context, - message: &Message, - _: &str, -) -> Result<(), Error> { - let _timer = Timer::command("follows"); - match message.guild_id { - Some(guild_id) => { - let channels = ctx - .cache - .guild_channels(guild_id) - .await - .ok_or("Couldn't get guild channels to list follows")?; - - let follows = Follow::user_follows(message.author.id) - .await? - .into_iter() - .filter(|follow| { - let channel_id = - ChannelId(follow.channel_id.try_into().unwrap()); - channels.contains_key(&channel_id) - }) - .map(|follow| format!("<#{}>", follow.channel_id)) - .collect::>(); - - if follows.is_empty() { - return error( - ctx, - message, - "You haven't followed any channels yet!", - ) - .await; - } - - let guild_name = ctx - .cache - .guild_field(guild_id, |g| g.name.clone()) - .await - .ok_or("Couldn't get guild to list follows")?; - - let response = format!( - "{}'s follows in {}:\n - {}", - message.author.name, - guild_name, - follows.join("\n - ") - ); - - message.channel_id.say(ctx, response).await?; - } - None => { - let follows = Follow::user_follows(message.author.id).await?; - - if follows.is_empty() { - return error( - ctx, - message, - "You haven't followed any channels yet!", - ) - .await; - } - - let mut follows_by_guild = HashMap::new(); - let mut not_found = Vec::new(); - - for follow in follows { - let channel_id = - ChannelId(follow.channel_id.try_into().unwrap()); - let channel = match ctx.cache.guild_channel(channel_id).await { - Some(channel) => channel, - None => { - not_found.push(format!("<#{0}> ({0})", channel_id)); - continue; - } - }; - - follows_by_guild - .entry(channel.guild_id) - .or_insert_with(Vec::new) - .push(format!("<#{}>", channel_id)); - } - - let mut response = String::new(); - - for (guild_id, channel_ids) in follows_by_guild { - if !response.is_empty() { - response.push_str("\n\n"); - } - - let guild_name = ctx - .cache - .guild_field(guild_id, |g| g.name.clone()) - .await - .ok_or("Couldn't get guild to list follows")?; - - write!( - &mut response, - "Your follows in {}:\n – {}", - guild_name, - channel_ids.join("\n – ") - ) - .unwrap(); - } - - if !not_found.is_empty() { - write!( - &mut response, - "\n\nCouldn't find (deleted?) followed channels:\n – {}", - not_found.join("\n – ") - ) - .unwrap(); - } - - message.channel_id.say(ctx, response).await?; - } - } - - Ok(()) -} - -pub async fn about( - ctx: &Context, - message: &Message, - _: &str, -) -> Result<(), Error> { - let _timer = Timer::command("about"); - let invite_url = ctx - .cache - .current_user() - .await - .invite_url(&ctx, Permissions::empty()) - .await?; - message - .channel_id - .send_message(ctx, |m| { - m.embed(|e| { - e.title(concat!( - env!("CARGO_PKG_NAME"), - " ", - env!("CARGO_PKG_VERSION") - )) - .field("Source", env!("CARGO_PKG_REPOSITORY"), true) - .field("Author", "ThatsNoMoon#0175", true) - .field( - "Invite", - format!("[Add me to your server]({})", invite_url), - true, - ) - .color(EMBED_COLOR) - }) - }) - .await?; - - Ok(()) -} - -pub async fn help( - ctx: &Context, - message: &Message, - args: &str, -) -> Result<(), Error> { - let _timer = Timer::command("help"); - struct CommandInfo { - name: &'static str, - short_desc: &'static str, - long_desc: String, - } - - let username = ctx.cache.current_user_field(|u| u.name.clone()).await; - - let commands = [ - CommandInfo { - name: "add", - short_desc: "Add a keyword to highlight in the current server", - long_desc: format!( - "Use `@{name} add [keyword]` to add a keyword to highlight in the current server. \ - All of the text after `add` will be treated as one keyword. - - Keywords are case-insensitive. - - You're only notified of keywords when they appear in channels you follow. \ - You can follow a channel with `@{name} follow [channel]`; \ - see `@{name} help follow` for more information. - - You can remove keywords later with `@{name} remove [keyword]`. - - You can list your current keywords with `@{name} keywords`.", - name = username, - ) - }, - CommandInfo { - name: "follow", - short_desc: "Follow a channel to be notified when your keywords appear there", - long_desc: format!( - "Use `@{name} follow [channels]` to follow the specified channel and \ - be notified when your keywords appear there. \ - `[channels]` may be channel mentions, channel names, or channel IDs. \ - You can specify multiple channels, separated by spaces, \ - to follow all of them at once. - - You're only notified of your keywords in channels you follow. \ - You can add a keyword with `@{name} add [keyword]`; \ - see `@{name} help add` for more information. - - You can unfollow channels later with `@{name} unfollow [channels]`. - - You can list your current followed channels with `@{name} follows`.", - name = username, - ) - }, - CommandInfo { - name: "remove", - short_desc: "Remove a keyword to highlight in the current server", - long_desc: format!( - "Use `@{name} remove [keyword]` to remove a keyword that you \ - previously added with `@{name} add` in the current server. \ - All of the text after `remove` will be treated as one keyword. - - Keywords are case-insensitive. - - You can list your current keywords with `@{name} keywords`.", - name = username, - ) - }, - CommandInfo { - name: "unfollow", - short_desc: - "Unfollow a channel, stopping notifications about your keywords appearing there", - long_desc: format!( - "Use `@{name} unfollow [channels]` to unfollow channels and stop notifications \ - about your keywords appearing there. \ - `[channels]` may be channel mentions, channel names, or channel IDs. \ - You can specify multiple channels, separated by spaces, to follow all of them \ - at once. - - You can list your current followed channels with `@{name} follows`.", - name = username, - ) - }, - CommandInfo { - name: "keywords", - short_desc: "List your current highlighted keywords", - long_desc: format!( - "Use `@{name} keywords` to list your current highlighted keywords. - - Using `keywords` in a server will show you only the keywords you've highlighted \ - in that server. - - Using `keywords` in DMs with the bot will list keywords you've highlighted \ - across all shared servers, including potentially deleted servers or servers this \ - bot is no longer a member of. - - If the bot can't find information about a server you have keywords in, \ - its ID will be in parentheses, so you can remove them with `removeserver` \ - if desired. See `@{name} help removeserver` for more details.", - name = username - ) - }, - CommandInfo { - name: "follows", - short_desc: "List your current followed channels", - long_desc: format!( - "Use `@{name} follows` to list your current followed channels. - - Using `follows` in a server will show you only the channels you've followed \ - in that server. - - Using `follows` in DMs with the bot will list channels you've followed across \ - all servers, including deleted channels or channels in servers this bot is \ - no longer a member of. - - If the bot can't find information on a channel you previously followed, \ - its ID will be in parentheses, so you can investigate or unfollow.", - name = username - ) - }, - CommandInfo { - name: "removeserver", - short_desc: "Remove all keywords on a given server", - long_desc: format!( - "Use `@{name} removeserver [server ID]` to remove all keywords on the server \ - with the given ID. - - This is normally not necessary, but if you no longer share a server with the bot \ - where you added keywords, you can clean up your keywords list by using `keywords` \ - in DMs to see all keywords, and this command to remove any server IDs the bot \ - can't find.", - name = username - ) - }, - CommandInfo { - name: "help", - short_desc: "Show this help message", - long_desc: format!( - "Use `@{name} help` to see a list of commands and short descriptions. - Use `@{name} help [command]` to see additional information about \ - the specified command. - Use `@{name} about` to see information about this bot.", - name = username - ), - }, - CommandInfo { - name: "about", - short_desc: "Show some information about this bot", - long_desc: - "Show some information about this bot, like version and source code.".to_owned(), - }, - ]; - - if args == "" { - message - .channel_id - .send_message(&ctx, |m| { - m.embed(|e| { - e.title(format!("{} – Help", username)) - .description(format!( - "Use `@{} help [command]` to see more information \ - about a specified command", - username - )) - .fields( - commands - .iter() - .map(|info| (info.name, info.short_desc, true)), - ) - .color(EMBED_COLOR) - }) - }) - .await?; - } else { - let info = match commands - .iter() - .find(|info| info.name.eq_ignore_ascii_case(args)) - { - Some(info) => info, - None => return question(ctx, &message).await, - }; - - message - .channel_id - .send_message(&ctx, |m| { - m.embed(|e| { - e.title(format!("Help – {}", info.name)) - .description(&info.long_desc) - .color(EMBED_COLOR) - }) - }) - .await?; - } - - Ok(()) -} diff --git a/src/commands/keywords.rs b/src/commands/keywords.rs new file mode 100644 index 0000000..9c76598 --- /dev/null +++ b/src/commands/keywords.rs @@ -0,0 +1,623 @@ +// Copyright 2020 Benjamin Scherer +// Licensed under the Open Software License version 3.0 + +use once_cell::sync::Lazy; +use regex::Regex; +use serenity::{ + client::Context, + model::{ + channel::Message, + id::{ChannelId, GuildId}, + }, +}; + +use std::{collections::HashMap, convert::TryInto, fmt::Write}; + +use super::util::{ + get_readable_channels_from_args, get_text_channels_in_guild, +}; +use crate::{ + db::{Keyword, KeywordKind}, + global::MAX_KEYWORDS, + monitoring::Timer, + util::{error, success, MD_SYMBOL_REGEX}, + Error, +}; + +static CHANNEL_KEYWORD_REGEX: Lazy Regex> = Lazy::new(|| { + Regex::new(r#"^"((?:\\"|[^"])*)" (?:in|from) ((?:\S+(?:$| ))+)"#).unwrap() +}); + +pub async fn add( + ctx: &Context, + message: &Message, + args: &str, +) -> Result<(), Error> { + let _timer = Timer::command("add"); + let guild_id = check_guild!(ctx, message); + + check_empty_args!(args, ctx, message); + + { + let keyword_count = + Keyword::user_keyword_count(message.author.id).await?; + + if keyword_count >= MAX_KEYWORDS { + static MSG: Lazy String> = Lazy::new(|| { + format!("You can't create more than {} keywords!", MAX_KEYWORDS) + }); + + return error(ctx, message, MSG.as_str()).await; + } + } + + match CHANNEL_KEYWORD_REGEX.captures(args) { + Some(captures) => { + let keyword = captures + .get(1) + .ok_or("Captures didn't contain keyword")? + .as_str(); + let channel = captures + .get(2) + .ok_or("Captures didn't contain channel")? + .as_str(); + + add_channel_keyword(ctx, message, guild_id, keyword, channel).await + } + None => add_guild_keyword(ctx, message, guild_id, args).await, + } +} + +async fn add_guild_keyword( + ctx: &Context, + message: &Message, + guild_id: GuildId, + args: &str, +) -> Result<(), Error> { + if args.len() < 3 { + return error( + ctx, + message, + "You can't highlight keywords shorter than 3 characters!", + ) + .await; + } + + let keyword = Keyword { + keyword: args.to_lowercase(), + user_id: message.author.id.0.try_into().unwrap(), + kind: KeywordKind::Guild(guild_id.0.try_into().unwrap()), + }; + + if keyword.clone().exists().await? { + return error(ctx, message, "You already added that keyword!").await; + } + + keyword.insert().await?; + + success(ctx, message).await +} + +async fn add_channel_keyword( + ctx: &Context, + message: &Message, + guild_id: GuildId, + keyword: &str, + channels: &str, +) -> Result<(), Error> { + if keyword.len() < 3 { + return error( + ctx, + message, + "You can't highlight keywords shorter than 3 characters!", + ) + .await; + } + + let guild_channels = get_text_channels_in_guild(ctx, guild_id).await?; + + let user_id = message.author.id; + + let mut channel_args = get_readable_channels_from_args( + ctx, + user_id, + &guild_channels, + channels, + ) + .await?; + + channel_args + .not_found + .extend(channel_args.user_cant_read.drain(..).map(|(_, arg)| arg)); + + let cant_add = channel_args + .self_cant_read + .into_iter() + .map(|c| format!("<#{}>", c.id)) + .collect::>(); + + let mut added = vec![]; + let mut already_added = vec![]; + + let user_id = user_id.0.try_into().unwrap(); + + for channel in channel_args.found { + let keyword = Keyword { + keyword: keyword.to_lowercase(), + user_id, + kind: KeywordKind::Channel(channel.id.0.try_into().unwrap()), + }; + + if keyword.clone().exists().await? { + already_added.push(format!("<#{}>", channel.id)); + } else { + added.push(format!("<#{}>", channel.id)); + keyword.insert().await?; + } + } + + let mut msg = String::with_capacity(45); + + let keyword = MD_SYMBOL_REGEX.replace_all(keyword, r"\$0"); + + if !added.is_empty() { + write!( + &mut msg, + "Added {} in channels: {}", + keyword, + added.join(", ") + ) + .unwrap(); + + message.react(ctx, '✅').await?; + } + + if !already_added.is_empty() { + if !msg.is_empty() { + msg.push('\n'); + } + write!( + &mut msg, + "Already added {} in channels: {}", + keyword, + already_added.join(", ") + ) + .unwrap(); + + message.react(ctx, '❌').await?; + } + + if !channel_args.not_found.is_empty() { + if !msg.is_empty() { + msg.push('\n'); + } + msg.push_str("Couldn't find channels: "); + msg.push_str(&channel_args.not_found.join(", ")); + + message.react(ctx, '❓').await?; + } + + if !cant_add.is_empty() { + if !msg.is_empty() { + msg.push('\n'); + } + msg.push_str("Unable to add keywords in channels: "); + msg.push_str(&cant_add.join(", ")); + + if already_added.is_empty() { + message.react(ctx, '❌').await?; + } + } + + message + .channel_id + .send_message(ctx, |m| { + m.content(msg).allowed_mentions(|m| m.empty_parse()) + }) + .await?; + + Ok(()) +} + +pub async fn remove( + ctx: &Context, + message: &Message, + args: &str, +) -> Result<(), Error> { + let _timer = Timer::command("remove"); + let guild_id = check_guild!(ctx, message); + + check_empty_args!(args, ctx, message); + + match CHANNEL_KEYWORD_REGEX.captures(args) { + Some(captures) => { + let keyword = captures + .get(1) + .ok_or("Captures didn't contain keyword")? + .as_str(); + let channel = captures + .get(2) + .ok_or("Captures didn't contain channel")? + .as_str(); + + remove_channel_keyword(ctx, message, guild_id, keyword, channel) + .await + } + None => remove_guild_keyword(ctx, message, guild_id, args).await, + } +} + +async fn remove_guild_keyword( + ctx: &Context, + message: &Message, + guild_id: GuildId, + args: &str, +) -> Result<(), Error> { + let keyword = Keyword { + keyword: args.to_lowercase(), + user_id: message.author.id.0.try_into().unwrap(), + kind: KeywordKind::Guild(guild_id.0.try_into().unwrap()), + }; + + if !keyword.clone().exists().await? { + return error(ctx, message, "You haven't added that keyword!").await; + } + + keyword.delete().await?; + + success(ctx, message).await +} + +async fn remove_channel_keyword( + ctx: &Context, + message: &Message, + guild_id: GuildId, + keyword: &str, + channels: &str, +) -> Result<(), Error> { + let guild_channels = get_text_channels_in_guild(ctx, guild_id).await?; + + let user_id = message.author.id; + + let channel_args = get_readable_channels_from_args( + ctx, + user_id, + &guild_channels, + channels, + ) + .await?; + + let keyword = keyword.to_lowercase(); + + let mut removed = vec![]; + let mut not_added = vec![]; + let mut not_found = channel_args.not_found; + + let user_id = user_id.0.try_into().unwrap(); + + for channel in channel_args.found { + let keyword = Keyword { + keyword: keyword.to_owned(), + user_id, + kind: KeywordKind::Channel(channel.id.0.try_into().unwrap()), + }; + + if !keyword.clone().exists().await? { + not_added.push(format!("<#{}>", channel.id)); + } else { + removed.push(format!("<#{}>", channel.id)); + keyword.delete().await?; + } + } + + for (user_unreadable, arg) in channel_args.user_cant_read { + let keyword = Keyword { + keyword: keyword.clone(), + user_id, + kind: KeywordKind::Channel( + user_unreadable.id.0.try_into().unwrap(), + ), + }; + + if !keyword.clone().exists().await? { + not_found.push(arg); + } else { + removed.push(format!("<#{0}> ({0})", user_unreadable.id)); + keyword.delete().await?; + } + } + + for self_unreadable in channel_args.self_cant_read { + let keyword = Keyword { + keyword: keyword.clone(), + user_id, + kind: KeywordKind::Channel( + self_unreadable.id.0.try_into().unwrap(), + ), + }; + + if !keyword.clone().exists().await? { + not_added.push(format!("<#{0}>", self_unreadable.id)); + } else { + removed.push(format!("<#{0}>", self_unreadable.id)); + keyword.delete().await?; + } + } + + let mut msg = String::with_capacity(45); + + let keyword = MD_SYMBOL_REGEX.replace_all(&keyword, r"\$0"); + + if !removed.is_empty() { + write!( + &mut msg, + "Removed {} from channels: {}", + keyword, + removed.join(", ") + ) + .unwrap(); + + message.react(ctx, '✅').await?; + } + + if !not_added.is_empty() { + if !msg.is_empty() { + msg.push('\n'); + } + write!( + &mut msg, + "{} wasn't added to channels: {}", + keyword, + not_added.join(", ") + ) + .unwrap(); + + message.react(ctx, '❌').await?; + } + + if !not_found.is_empty() { + if !msg.is_empty() { + msg.push('\n'); + } + msg.push_str("Couldn't find channels: "); + msg.push_str(¬_found.join(", ")); + + message.react(ctx, '❓').await?; + } + + message + .channel_id + .send_message(ctx, |m| { + m.content(msg).allowed_mentions(|m| m.empty_parse()) + }) + .await?; + + Ok(()) +} + +pub async fn remove_server( + ctx: &Context, + message: &Message, + args: &str, +) -> Result<(), Error> { + let _timer = Timer::command("removeserver"); + check_empty_args!(args, ctx, message); + + let guild_id = match args.parse() { + Ok(id) => GuildId(id), + Err(_) => return error(ctx, message, "Invalid server ID!").await, + }; + + match Keyword::delete_in_guild(message.author.id, guild_id).await? { + 0 => { + error( + ctx, + message, + "You didn't have any keywords with that server ID!", + ) + .await + } + _ => success(ctx, message).await, + } +} + +pub async fn keywords( + ctx: &Context, + message: &Message, + _: &str, +) -> Result<(), Error> { + let _timer = Timer::command("keywords"); + match message.guild_id { + Some(guild_id) => { + let guild_keywords = + Keyword::user_guild_keywords(message.author.id, guild_id) + .await? + .into_iter() + .map(|keyword| keyword.keyword) + .collect::>(); + + let guild_channels = + get_text_channels_in_guild(ctx, guild_id).await?; + + let mut channel_keywords = HashMap::new(); + + for keyword in + Keyword::user_channel_keywords(message.author.id).await? + { + let channel_id = match keyword.kind { + KeywordKind::Channel(id) => { + ChannelId(id.try_into().unwrap()) + } + _ => { + panic!("user_channel_keywords returned a guild keyword") + } + }; + + if !guild_channels.contains_key(&channel_id) { + continue; + } + + channel_keywords + .entry(channel_id) + .or_insert_with(Vec::new) + .push(keyword.keyword); + } + + if guild_keywords.is_empty() && channel_keywords.is_empty() { + return error( + ctx, + message, + "You haven't added any keywords yet!", + ) + .await; + } + + let guild_name = ctx + .cache + .guild_field(guild_id, |g| g.name.clone()) + .await + .ok_or("Couldn't get guild to list keywords")?; + + let mut response = String::with_capacity(45); + + if guild_keywords.is_empty() { + write!(&mut response, "Your keywords in {}:", guild_name) + .unwrap(); + } else { + write!( + &mut response, + "Your keywords in {}:\n – {}", + guild_name, + guild_keywords.join("\n – ") + ) + .unwrap(); + } + + for (channel_id, channel_keywords) in channel_keywords { + response.push('\n'); + + write!( + &mut response, + " In <#{}>:\n - {1}", + channel_id, + channel_keywords.join("\n - "), + ) + .unwrap(); + } + + message + .channel_id + .send_message(ctx, |m| { + m.content(response).allowed_mentions(|m| m.empty_parse()) + }) + .await?; + } + None => { + let keywords = Keyword::user_keywords(message.author.id).await?; + + if keywords.is_empty() { + return error( + ctx, + message, + "You haven't added any keywords yet!", + ) + .await; + } + + let mut keywords_by_guild = HashMap::new(); + + let mut unknown_channel_keywords = HashMap::new(); + + for keyword in keywords { + match keyword.kind { + KeywordKind::Guild(guild_id) => { + let guild_id = GuildId(guild_id.try_into().unwrap()); + + let guild_keywords = &mut keywords_by_guild + .entry(guild_id) + .or_insert_with(|| (Vec::new(), HashMap::new())) + .0; + + guild_keywords.push(keyword.keyword); + } + KeywordKind::Channel(channel_id) => { + let channel_id = + ChannelId(channel_id.try_into().unwrap()); + + let guild_id = ctx + .cache + .guild_channel_field(channel_id, |c| c.guild_id) + .await; + + match guild_id { + Some(guild_id) => { + keywords_by_guild + .entry(guild_id) + .or_insert_with(|| { + (Vec::new(), HashMap::new()) + }) + .1 + .entry(channel_id) + .or_insert_with(Vec::new) + .push(keyword.keyword); + } + None => { + unknown_channel_keywords + .entry(channel_id) + .or_insert_with(Vec::new) + .push(keyword.keyword); + } + } + } + } + } + + let mut response = String::new(); + + for (guild_id, (guild_keywords, channel_keywords)) in + keywords_by_guild + { + if !response.is_empty() { + response.push_str("\n\n"); + } + + let guild_name = ctx + .cache + .guild_field(guild_id, |g| g.name.clone()) + .await + .unwrap_or_else(|| { + format!(" ({})", guild_id) + }); + + if guild_keywords.is_empty() { + write!(&mut response, "Your keywords in {}:", guild_name) + .unwrap(); + } else { + write!( + &mut response, + "Your keywords in {}:\n – {}", + guild_name, + guild_keywords.join("\n – ") + ) + .unwrap(); + } + + for (channel_id, channel_keywords) in channel_keywords { + response.push('\n'); + + write!( + &mut response, + " In <#{0}> ({0}):\n - {1}", + channel_id, + channel_keywords.join("\n - "), + ) + .unwrap(); + } + } + + message.channel_id.say(ctx, response).await?; + } + } + + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..d89d666 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,270 @@ +// Copyright 2020 Benjamin Scherer +// Licensed under the Open Software License version 3.0 + +#[macro_use] +mod util; + +mod keywords; +pub use keywords::{add, keywords, remove, remove_server}; + +mod mutes; +pub use mutes::{mute, mutes, unmute}; + +use serenity::{ + client::Context, + model::{channel::Message, Permissions}, +}; + +use crate::{global::EMBED_COLOR, monitoring::Timer, util::question, Error}; + +pub async fn about( + ctx: &Context, + message: &Message, + _: &str, +) -> Result<(), Error> { + let _timer = Timer::command("about"); + let invite_url = ctx + .cache + .current_user() + .await + .invite_url(&ctx, Permissions::empty()) + .await?; + message + .channel_id + .send_message(ctx, |m| { + m.embed(|e| { + e.title(concat!( + env!("CARGO_PKG_NAME"), + " ", + env!("CARGO_PKG_VERSION") + )) + .field("Source", env!("CARGO_PKG_REPOSITORY"), true) + .field("Author", "ThatsNoMoon#0175", true) + .field( + "Invite", + format!("[Add me to your server]({})", invite_url), + true, + ) + .color(EMBED_COLOR) + }) + }) + .await?; + + Ok(()) +} + +pub async fn help( + ctx: &Context, + message: &Message, + args: &str, +) -> Result<(), Error> { + let _timer = Timer::command("help"); + struct CommandInfo { + name: &'static str, + short_desc: &'static str, + long_desc: String, + } + + let username = ctx.cache.current_user_field(|u| u.name.clone()).await; + + let commands = [ + CommandInfo { + name: "add", + short_desc: "Add a keyword to highlight in the current server", + long_desc: format!( + "Use `@{name} add [keyword]` to add a keyword to highlight in the current server. \ + In this usage, all of the text after `add` will be treated as one keyword. + + Keywords are case-insensitive. + + You can also add a keyword in just a specific channel or channels with \ + `@{name} add \"[keyword]\" in [channels]`. \ + You'll only be notified of keywords added this way when they appear in the \ + specified channel(s) (not when they appear anywhere else). \ + The keyword must be surrounded with quotes, and you can use `\\\"` to add a \ + keyword with a quote in it. \ + `[channels]` may be channel mentions, channel names, or channel IDs. \ + You can specify multiple channels, separated by spaces, to add the keyword in \ + all of them at once. + + You can remove keywords later with `@{name} remove [keyword]`; see \ + `@{name} help remove` for more information. + + You can list your current keywords with `@{name} keywords`.", + name = username, + ) + }, + CommandInfo { + name: "remove", + short_desc: "Remove a keyword to highlight in the current server", + long_desc: format!( + "Use `@{name} remove [keyword]` to remove a keyword that you previously added \ + with `@{name} add` in the current server. \ + In this usage, all of the text after `remove` will be treated as one keyword. + + Keywords are case-insensitive. + + You can also remove a keyword that you added to a specific channel or channels \ + with `@{name} remove \"[keyword]\" from [channels]`. \ + The keyword must be surrounded with quotes, and you can use `\\\"` to remove a \ + keyword with a quote in it. \ + `[channels]` may be channel mentions, channel names, or channel IDs. \ + You can specify multiple channels, separated by spaces, to remove the keyword \ + from all of them at once. + + You can list your current keywords with `@{name} keywords`.", + name = username, + ) + }, + CommandInfo { + name: "mute", + short_desc: "Mute a channel to prevent server keywords from being highlighted there", + long_desc: format!( + "Use `@{name} mute [channels]` to mute the specified channel(s) and \ + prevent notifications about your server-wide keywords appearing there. \ + `[channels]` may be channel mentions, channel names, or channel IDs. \ + You can specify multiple channels, separated by spaces, to mute all of them \ + at once. + + You'll still be notified about any channel-specific keywords you add to muted \ + channels. \ + See `@{name} help add` for more information about channel-specific keywords. + + You can unmute channels later with `@{name} unmute [channels]`. + + You can list your currently muted channels with `@{name} mutes`.", + name = username, + ) + }, + CommandInfo { + name: "unmute", + short_desc: + "Unmite a channel, enabling notifications about server keywords appearing there", + long_desc: format!( + "Use `@{name} unmute [channels]` to unmute channels you previously muted and \ + re-enable notifications about your keywords appearing there. \ + `[channels]` may be channel mentions, channel names, or channel IDs. \ + You can specify multiple channels, separated by spaces, to unmute all of them at \ + once. + + You can list your currently muted channels with `@{name} mutes`.", + name = username, + ) + }, + CommandInfo { + name: "keywords", + short_desc: "List your current highlighted keywords", + long_desc: format!( + "Use `@{name} keywords` to list your current highlighted keywords. + + Using `keywords` in a server will show you only the keywords you've highlighted \ + in that server, including all channel-specific keywords there. + + Using `keywords` in DMs with the bot will list keywords you've highlighted \ + across all shared servers, including potentially deleted servers or servers this \ + bot is no longer a member of. + + If the bot can't find information about a server you have keywords in, \ + its ID will be in parentheses, so you can remove them with `remove-server` \ + if desired. \ + See `@{name} help remove-server` for more details.", + name = username + ) + }, + CommandInfo { + name: "mutes", + short_desc: "List your currently muted channels", + long_desc: format!( + "Use `@{name} mutes` to list your currently muted channels. + + Using `mutes` in a server will show you only the channels you've muted in that \ + server. + + Using `mutes` in DMs with the bot will list channels you've muted across \ + all servers, including deleted channels or channels in servers this bot is \ + no longer a member of. + + If the bot can't find information on a channel you previously followed, \ + its ID will be in parentheses, so you can investigate or unmute.", + name = username + ) + }, + CommandInfo { + name: "remove-server", + short_desc: "Remove all server-wide keywords on a given server", + long_desc: format!( + "Use `@{name} remove-server [server ID]` to remove all keywords on the server \ + with the given ID. + + This won't remove channel-specific keywords in the given server; you can use the \ + normal `remove` command for that. + + This is normally not necessary, but if you no longer share a server with the bot \ + where you added keywords, you can clean up your keywords list by using `keywords` \ + in DMs to see all keywords, and this command to remove any server IDs the bot \ + can't find.", + name = username + ) + }, + CommandInfo { + name: "help", + short_desc: "Show this help message", + long_desc: format!( + "Use `@{name} help` to see a list of commands and short descriptions. + Use `@{name} help [command]` to see additional information about \ + the specified command. + Use `@{name} about` to see information about this bot.", + name = username + ), + }, + CommandInfo { + name: "about", + short_desc: "Show some information about this bot", + long_desc: + "Show some information about this bot, like version and source code.".to_owned(), + }, + ]; + + if args == "" { + message + .channel_id + .send_message(&ctx, |m| { + m.embed(|e| { + e.title(format!("{} – Help", username)) + .description(format!( + "Use `@{} help [command]` to see more information \ + about a specified command", + username + )) + .fields( + commands + .iter() + .map(|info| (info.name, info.short_desc, true)), + ) + .color(EMBED_COLOR) + }) + }) + .await?; + } else { + let info = match commands + .iter() + .find(|info| info.name.eq_ignore_ascii_case(args)) + { + Some(info) => info, + None => return question(ctx, &message).await, + }; + + message + .channel_id + .send_message(&ctx, |m| { + m.embed(|e| { + e.title(format!("Help – {}", info.name)) + .description(&info.long_desc) + .color(EMBED_COLOR) + }) + }) + .await?; + } + + Ok(()) +} diff --git a/src/commands/mutes.rs b/src/commands/mutes.rs new file mode 100644 index 0000000..58d5d6e --- /dev/null +++ b/src/commands/mutes.rs @@ -0,0 +1,357 @@ +// Copyright 2020 Benjamin Scherer +// Licensed under the Open Software License version 3.0 + +use super::util::{ + get_ids_from_args, get_readable_channels_from_args, + get_text_channels_in_guild, +}; + +use serenity::{ + client::Context, + model::{ + channel::{ChannelType, Message}, + id::ChannelId, + }, +}; + +use std::{collections::HashMap, convert::TryInto, fmt::Write}; + +use crate::{db::Mute, monitoring::Timer, util::error, Error}; + +pub async fn mute( + ctx: &Context, + message: &Message, + args: &str, +) -> Result<(), Error> { + let _timer = Timer::command("mute"); + let guild_id = check_guild!(ctx, message); + + check_empty_args!(args, ctx, message); + + let channels = get_text_channels_in_guild(ctx, guild_id).await?; + + let channel_args = get_readable_channels_from_args( + ctx, + message.author.id, + &channels, + args, + ) + .await?; + + let mut not_found = channel_args.not_found; + not_found + .extend(channel_args.user_cant_read.into_iter().map(|(_, arg)| arg)); + + let cant_mute = channel_args + .self_cant_read + .into_iter() + .map(|c| format!("<#{}>", c.id)) + .collect::>(); + + let mut muted = vec![]; + let mut already_muted = vec![]; + + for channel in channel_args.found { + let mute = Mute { + user_id: message.author.id.0.try_into().unwrap(), + channel_id: channel.id.0.try_into().unwrap(), + }; + + if mute.clone().exists().await? { + already_muted.push(format!("<#{}>", channel.id)); + } else { + muted.push(format!("<#{}>", channel.id)); + mute.insert().await?; + } + } + + let mut msg = String::with_capacity(45); + + if !muted.is_empty() { + msg.push_str("Muted channels: "); + msg.push_str(&muted.join(", ")); + + message.react(ctx, '✅').await?; + } + + if !already_muted.is_empty() { + if !msg.is_empty() { + msg.push('\n'); + } + msg.push_str("Channels already muted: "); + msg.push_str(&already_muted.join(", ")); + + message.react(ctx, '❌').await?; + } + + if !not_found.is_empty() { + if !msg.is_empty() { + msg.push('\n'); + } + msg.push_str("Couldn't find channels: "); + msg.push_str(¬_found.join(", ")); + + message.react(ctx, '❓').await?; + } + + if !cant_mute.is_empty() { + if !msg.is_empty() { + msg.push('\n'); + } + msg.push_str("Unable to mute channels: "); + msg.push_str(&cant_mute.join(", ")); + + if already_muted.is_empty() { + message.react(ctx, '❌').await?; + } + } + + message + .channel_id + .send_message(ctx, |m| { + m.content(msg).allowed_mentions(|m| m.empty_parse()) + }) + .await?; + + Ok(()) +} + +pub async fn unmute( + ctx: &Context, + message: &Message, + args: &str, +) -> Result<(), Error> { + let _timer = Timer::command("unmute"); + check_empty_args!(args, ctx, message); + + let channels = match message.guild_id { + Some(guild_id) => { + ctx.cache.guild_channels(guild_id).await.map(|channels| { + channels + .into_iter() + .filter(|(_, channel)| channel.kind == ChannelType::Text) + .collect() + }) + } + None => None, + }; + + let user_id: i64 = message.author.id.0.try_into().unwrap(); + + let mut unmuted = vec![]; + let mut not_muted = vec![]; + let mut not_found = vec![]; + + match channels.as_ref() { + Some(channels) => { + let channel_args = get_readable_channels_from_args( + ctx, + message.author.id, + channels, + args, + ) + .await?; + + not_found = channel_args.not_found; + + for (user_unreadable, arg) in channel_args.user_cant_read { + let mute = Mute { + user_id, + channel_id: user_unreadable.id.0.try_into().unwrap(), + }; + + if !mute.clone().exists().await? { + not_found.push(arg); + } else { + unmuted.push(format!("<#{0}> ({0})", user_unreadable.id)); + mute.delete().await?; + } + } + + for self_unreadable in channel_args.self_cant_read { + let mute = Mute { + user_id, + channel_id: self_unreadable.id.0.try_into().unwrap(), + }; + + if !mute.clone().exists().await? { + not_muted.push(format!("<#{0}>", self_unreadable.id)); + } else { + unmuted.push(format!("<#{0}>", self_unreadable.id)); + mute.delete().await?; + } + } + } + None => { + for result in get_ids_from_args(args) { + match result { + Ok((channel_id, arg)) => { + let channel_id = channel_id.0.try_into().unwrap(); + let mute = Mute { + user_id, + channel_id, + }; + + if !mute.clone().exists().await? { + not_found.push(arg); + } else { + unmuted.push(format!("<#{0}> ({0})", channel_id)); + mute.delete().await?; + } + } + Err(arg) => { + not_found.push(arg); + } + } + } + } + } + + let mut msg = String::with_capacity(50); + + if !unmuted.is_empty() { + msg.push_str("Unmuted channels: "); + msg.push_str(&unmuted.join(", ")); + + message.react(ctx, '✅').await?; + } + + if !not_muted.is_empty() { + if !msg.is_empty() { + msg.push('\n'); + } + msg.push_str("Channels weren't muted: "); + msg.push_str(¬_muted.join(", ")); + + message.react(ctx, '❌').await?; + } + + if !not_found.is_empty() { + if !msg.is_empty() { + msg.push('\n'); + } + msg.push_str("Couldn't find channels: "); + msg.push_str(¬_found.join(", ")); + + message.react(ctx, '❓').await?; + } + + message + .channel_id + .send_message(ctx, |m| { + m.content(msg).allowed_mentions(|m| m.empty_parse()) + }) + .await?; + + Ok(()) +} + +pub async fn mutes( + ctx: &Context, + message: &Message, + _: &str, +) -> Result<(), Error> { + let _timer = Timer::command("mutes"); + match message.guild_id { + Some(guild_id) => { + let channels = ctx + .cache + .guild_channels(guild_id) + .await + .ok_or("Couldn't get guild channels to list mutes")?; + + let mutes = Mute::user_mutes(message.author.id) + .await? + .into_iter() + .filter(|mute| { + let channel_id = + ChannelId(mute.channel_id.try_into().unwrap()); + channels.contains_key(&channel_id) + }) + .map(|mute| format!("<#{}>", mute.channel_id)) + .collect::>(); + + if mutes.is_empty() { + return error(ctx, message, "You haven't muted any channels!") + .await; + } + + let guild_name = ctx + .cache + .guild_field(guild_id, |g| g.name.clone()) + .await + .ok_or("Couldn't get guild to list mutes")?; + + let response = format!( + "{}'s muted channels in {}:\n - {}", + message.author.name, + guild_name, + mutes.join("\n - ") + ); + + message.channel_id.say(ctx, response).await?; + } + None => { + let mutes = Mute::user_mutes(message.author.id).await?; + + if mutes.is_empty() { + return error(ctx, message, "You haven't muted any channels!") + .await; + } + + let mut mutes_by_guild = HashMap::new(); + let mut not_found = Vec::new(); + + for mute in mutes { + let channel_id = ChannelId(mute.channel_id.try_into().unwrap()); + let channel = match ctx.cache.guild_channel(channel_id).await { + Some(channel) => channel, + None => { + not_found.push(format!("<#{0}> ({0})", channel_id)); + continue; + } + }; + + mutes_by_guild + .entry(channel.guild_id) + .or_insert_with(Vec::new) + .push(format!("<#{}>", channel_id)); + } + + let mut response = String::new(); + + for (guild_id, channel_ids) in mutes_by_guild { + if !response.is_empty() { + response.push_str("\n\n"); + } + + let guild_name = ctx + .cache + .guild_field(guild_id, |g| g.name.clone()) + .await + .ok_or("Couldn't get guild to list mutes")?; + + write!( + &mut response, + "Your muted channels in {}:\n – {}", + guild_name, + channel_ids.join("\n – ") + ) + .unwrap(); + } + + if !not_found.is_empty() { + write!( + &mut response, + "\n\nCouldn't find (deleted?) muted channels:\n – {}", + not_found.join("\n – ") + ) + .unwrap(); + } + + message.channel_id.say(ctx, response).await?; + } + } + + Ok(()) +} diff --git a/src/commands/util.rs b/src/commands/util.rs new file mode 100644 index 0000000..7684bd6 --- /dev/null +++ b/src/commands/util.rs @@ -0,0 +1,174 @@ +// Copyright 2020 Benjamin Scherer +// Licensed under the Open Software License version 3.0 + +use serenity::{ + client::Context, + model::{ + channel::{ChannelType, GuildChannel}, + id::{ChannelId, GuildId, UserId}, + }, +}; + +use crate::Error; +use std::{collections::HashMap, iter::FromIterator}; + +#[macro_export] +macro_rules! check_guild { + ($ctx:expr, $message:expr) => {{ + match $message.guild_id { + None => { + return $crate::util::error( + $ctx, + $message, + "You must run this command in a server!", + ) + .await + } + Some(id) => id, + } + }}; +} + +#[macro_export] +macro_rules! check_empty_args { + ($args:expr, $ctx:expr, $message:expr) => {{ + if $args == "" { + return $crate::util::question($ctx, $message).await; + } + }}; +} + +pub async fn get_text_channels_in_guild( + ctx: &Context, + guild_id: GuildId, +) -> Result, Error> { + let channels = ctx + .cache + .guild_channels(guild_id) + .await + .ok_or("Couldn't get guild to get channels")?; + let channels = channels + .into_iter() + .filter(|(_, channel)| channel.kind == ChannelType::Text) + .collect(); + + Ok(channels) +} + +pub async fn get_readable_channels_from_args<'args, 'c>( + ctx: &Context, + author_id: UserId, + channels: &'c HashMap, + args: &'args str, +) -> Result, Error> { + let all_channels = get_channels_from_args(channels, args); + + let mut result = ReadableChannelsFromArgs::default(); + + result.not_found = all_channels.not_found; + + for (channel, arg) in all_channels.found { + let user_can_read = channel + .permissions_for_user(&ctx.cache, author_id) + .await? + .read_messages(); + + let self_can_read = channel + .permissions_for_user(&ctx.cache, ctx.cache.current_user_id().await) + .await? + .read_messages(); + + if !user_can_read { + result.user_cant_read.push((channel, arg)); + } else if !self_can_read { + result.self_cant_read.push(channel); + } else { + result.found.push(channel); + } + } + + Ok(result) +} + +pub fn get_ids_from_args(args: &str) -> Vec> { + args.split_whitespace() + .map(|arg| arg.parse().map(|id| (ChannelId(id), arg)).map_err(|_| arg)) + .collect() +} + +fn get_channels_from_args<'args, 'c>( + channels: &'c HashMap, + args: &'args str, +) -> ChannelsFromArgs<'args, 'c> { + dbg!(args + .split_whitespace() + .map(|arg| get_channel_from_arg(channels, arg)) + .collect()) +} + +fn get_channel_from_arg<'arg, 'c>( + channels: &'c HashMap, + arg: &'arg str, +) -> Result<(&'c GuildChannel, &'arg str), &'arg str> { + if let Ok(id) = arg.parse::() { + return match channels.get(&ChannelId(id)) { + Some(c) => Ok((c, arg)), + None => Err(arg), + }; + } + + if let Some(id) = arg + .strip_prefix("<#") + .and_then(|arg| arg.strip_suffix(">")) + .and_then(|arg| arg.parse::().ok()) + { + return match channels.get(&ChannelId(id)) { + Some(c) => Ok((c, arg)), + None => Err(arg), + }; + } + + let mut iter = channels + .iter() + .map(|(_, channel)| channel) + .filter(|channel| channel.name.as_str().eq_ignore_ascii_case(arg)); + + if let Some(first) = iter.next() { + if iter.next().is_none() { + return Ok((first, arg)); + } + } + + Err(arg) +} + +#[derive(Debug, Default)] +struct ChannelsFromArgs<'args, 'c> { + not_found: Vec<&'args str>, + found: Vec<(&'c GuildChannel, &'args str)>, +} + +impl<'args, 'c> FromIterator> + for ChannelsFromArgs<'args, 'c> +{ + fn from_iter< + T: IntoIterator>, + >( + iter: T, + ) -> Self { + let mut result = Self::default(); + iter.into_iter().for_each(|res| match res { + Ok(c) => result.found.push(c), + Err(arg) => result.not_found.push(arg), + }); + result + } +} + +#[derive(Debug, Default)] +pub struct ReadableChannelsFromArgs<'args, 'c> { + pub not_found: Vec<&'args str>, + pub found: Vec<&'c GuildChannel>, + pub user_cant_read: Vec<(&'c GuildChannel, &'args str)>, + pub self_cant_read: Vec<&'c GuildChannel>, +} diff --git a/src/db.rs b/src/db.rs index 4ebfb3f..b1adcc0 100644 --- a/src/db.rs +++ b/src/db.rs @@ -44,8 +44,8 @@ pub fn init() { POOL.set(pool).unwrap(); - Follow::create_table(); - Keyword::create_table(); + Mute::create_table(); + Keyword::create_tables(); if env::var_os("HIGHLIGHTS_DONT_BACKUP").is_none() { let backup_dir = data_dir.join("backup"); @@ -67,35 +67,60 @@ macro_rules! await_db { }}; } +#[derive(Debug, Clone, Copy)] +pub enum KeywordKind { + Channel(i64), + Guild(i64), +} + #[derive(Debug, Clone)] pub struct Keyword { pub keyword: String, pub user_id: i64, - pub guild_id: i64, + pub kind: KeywordKind, } impl Keyword { - fn from_row(row: &Row) -> Result { + fn from_guild_row(row: &Row) -> Result { + Ok(Keyword { + keyword: row.get(0)?, + user_id: row.get(1)?, + kind: KeywordKind::Guild(row.get(2)?), + }) + } + + fn from_channel_row(row: &Row) -> Result { Ok(Keyword { keyword: row.get(0)?, user_id: row.get(1)?, - guild_id: row.get(2)?, + kind: KeywordKind::Channel(row.get(2)?), }) } - pub fn create_table() { + fn create_tables() { let conn = connection(); conn.execute( - "CREATE TABLE IF NOT EXISTS keywords ( - keyword TEXT NOT NULL, - user_id INTEGER NOT NULL, - guild_id INTEGER NOT NULL, - PRIMARY KEY (keyword, user_id, guild_id) + "CREATE TABLE IF NOT EXISTS guild_keywords ( + keyword TEXT NOT NULL, + user_id INTEGER NOT NULL, + guild_id INTEGER NOT NULL, + PRIMARY KEY (keyword, user_id, guild_id) )", params![], ) - .expect("Failed to create keywords table"); + .expect("Failed to create guild_keywords table"); + + conn.execute( + "CREATE TABLE IF NOT EXISTS channel_keywords ( + keyword TEXT NOT NULL, + user_id INTEGER NOT NULL, + channel_id INTEGER NOT NULL, + PRIMARY KEY (keyword, user_id, channel_id) + )", + params![], + ) + .expect("Failed to create channel_keywords table"); } pub async fn get_relevant_keywords( @@ -109,21 +134,47 @@ impl Keyword { let author_id: i64 = author_id.0.try_into().unwrap(); let mut stmt = conn.prepare( - "SELECT keywords.keyword, keywords.user_id, keywords.guild_id - FROM keywords - INNER JOIN follows - ON keywords.user_id = follows.user_id - WHERE keywords.guild_id = ? AND follows.channel_id = ? AND keywords.user_id != ?", + "SELECT guild_keywords.keyword, guild_keywords.user_id, guild_keywords.guild_id + FROM guild_keywords + WHERE guild_keywords.guild_id = ? + AND guild_keywords.user_id != ? + AND NOT EXISTS ( + SELECT mutes.user_id + FROM mutes + WHERE mutes.user_id = guild_keywords.user_id + AND mutes.channel_id = ? + ) + ", )?; - let keywords = - stmt.query_map(params![guild_id, channel_id, author_id], Keyword::from_row)?; + let guild_keywords = stmt.query_map( + params![guild_id, author_id, channel_id], + Keyword::from_guild_row + )?; - keywords.collect() + let mut keywords = guild_keywords.collect::, _>>()?; + + let mut stmt = conn.prepare( + "SELECT keyword, user_id, channel_id + FROM channel_keywords + WHERE user_id != ? + AND channel_id = ?" + )?; + + let channel_keywords = stmt.query_map( + params![author_id, channel_id], + Keyword::from_channel_row + )?; + + let channel_keywords = channel_keywords.collect::, _>>()?; + + keywords.extend(channel_keywords); + + Ok(keywords) }) } - pub async fn user_keywords_in_guild( + pub async fn user_guild_keywords( user_id: UserId, guild_id: GuildId, ) -> Result, Error> { @@ -133,11 +184,29 @@ impl Keyword { let mut stmt = conn.prepare( "SELECT keyword, user_id, guild_id - FROM keywords + FROM guild_keywords WHERE user_id = ? AND guild_id = ?" )?; - let keywords = stmt.query_map(params![user_id, guild_id], Keyword::from_row)?; + let keywords = stmt.query_map(params![user_id, guild_id], Keyword::from_guild_row)?; + + keywords.collect() + }) + } + + pub async fn user_channel_keywords( + user_id: UserId, + ) -> Result, Error> { + await_db!("user channel keywords": |conn| { + let user_id: i64 = user_id.0.try_into().unwrap(); + + let mut stmt = conn.prepare( + "SELECT keyword, user_id, channel_id + FROM channel_keywords + WHERE user_id = ?" + )?; + + let keywords = stmt.query_map(params![user_id], Keyword::from_channel_row)?; keywords.collect() }) @@ -149,45 +218,92 @@ impl Keyword { let mut stmt = conn.prepare( "SELECT keyword, user_id, guild_id - FROM keywords + FROM guild_keywords WHERE user_id = ?" )?; - let keywords = stmt.query_map(params![user_id], Keyword::from_row)?; + let guild_keywords = stmt.query_map(params![user_id], Keyword::from_guild_row)?; - keywords.collect() + let mut keywords = guild_keywords.collect::, _>>()?; + + let mut stmt = conn.prepare( + "SELECT keyword, user_id, channel_id + FROM channel_keywords + WHERE user_id = ?" + )?; + + let channel_keywords = stmt.query_map(params![user_id], Keyword::from_channel_row)?; + + keywords.extend(channel_keywords.collect::, _>>()?); + + Ok(keywords) }) } pub async fn exists(self) -> Result { await_db!("keyword exists": |conn| { - conn.query_row( - "SELECT COUNT(*) FROM keywords - WHERE keyword = ? AND user_id = ? AND guild_id = ?", - params![&self.keyword, self.user_id, self.guild_id], - |row| Ok(row.get::<_, u32>(0)? == 1), - ) + match self.kind { + KeywordKind::Channel(channel_id) => { + conn.query_row( + "SELECT COUNT(*) FROM channel_keywords + WHERE keyword = ? AND user_id = ? AND channel_id = ?", + params![&self.keyword, self.user_id, channel_id], + |row| Ok(row.get::<_, u32>(0)? == 1), + ) + } + KeywordKind::Guild(guild_id) => { + conn.query_row( + "SELECT COUNT(*) FROM guild_keywords + WHERE keyword = ? AND user_id = ? AND guild_id = ?", + params![&self.keyword, self.user_id, guild_id], + |row| Ok(row.get::<_, u32>(0)? == 1), + ) + } + } }) } pub async fn user_keyword_count(user_id: UserId) -> Result { await_db!("count user keywords": |conn| { let user_id: i64 = user_id.0.try_into().unwrap(); - conn.query_row( - "SELECT COUNT(*) FROM keywords WHERE user_id = ?", + let guild_keywords = conn.query_row( + "SELECT COUNT(*) + FROM guild_keywords + WHERE user_id = ?", params![user_id], |row| row.get::<_, u32>(0), - ) + )?; + + let channel_keywords = conn.query_row( + "SELECT COUNT(*) + FROM channel_keywords + WHERE user_id = ?", + params![user_id], + |row| row.get::<_, u32>(0), + )?; + + Ok(guild_keywords + channel_keywords) }) } pub async fn insert(self) -> Result<(), Error> { await_db!("insert keyword": |conn| { - conn.execute( - "INSERT INTO keywords (keyword, user_id, guild_id) - VALUES (?, ?, ?)", - params![&self.keyword, self.user_id, self.guild_id], - )?; + match self.kind { + KeywordKind::Guild(guild_id) => { + conn.execute( + "INSERT INTO guild_keywords (keyword, user_id, guild_id) + VALUES (?, ?, ?)", + params![&self.keyword, self.user_id, guild_id], + )?; + } + KeywordKind::Channel(channel_id) => { + conn.execute( + "INSERT INTO channel_keywords (keyword, user_id, channel_id) + VALUES (?, ?, ?)", + params![&self.keyword, self.user_id, channel_id], + )?; + } + } Ok(()) }) @@ -195,11 +311,22 @@ impl Keyword { pub async fn delete(self) -> Result<(), Error> { await_db!("delete keyword": |conn| { - conn.execute( - "DELETE FROM keywords - WHERE keyword = ? AND user_id = ? AND guild_id = ?", - params![&self.keyword, self.user_id, self.guild_id], - )?; + match self.kind { + KeywordKind::Guild(guild_id) => { + conn.execute( + "DELETE FROM guild_keywords + WHERE keyword = ? AND user_id = ? AND guild_id = ?", + params![&self.keyword, self.user_id, guild_id], + )?; + } + KeywordKind::Channel(channel_id) => { + conn.execute( + "DELETE FROM channel_keywords + WHERE keyword = ? AND user_id = ? AND channel_id = ?", + params![&self.keyword, self.user_id, channel_id], + )?; + } + } Ok(()) }) @@ -213,32 +340,47 @@ impl Keyword { let user_id: i64 = user_id.0.try_into().unwrap(); let guild_id: i64 = guild_id.0.try_into().unwrap(); conn.execute( - "DELETE FROM keywords - WHERE user_id = ? AND guild_id = ?", + "DELETE FROM guild_keywords + WHERE user_id = ? AND guild_id = ?", params![user_id, guild_id] ) }) } + + pub async fn delete_in_channel( + user_id: UserId, + channel_id: ChannelId, + ) -> Result { + await_db!("delete keywords in channel": |conn| { + let user_id: i64 = user_id.0.try_into().unwrap(); + let channel_id: i64 = channel_id.0.try_into().unwrap(); + conn.execute( + "DELETE FROM channel_keywords + WHERE user_id = ? AND channel_id = ?", + params![user_id, channel_id] + ) + }) + } } #[derive(Debug, Clone)] -pub struct Follow { - pub channel_id: i64, +pub struct Mute { pub user_id: i64, + pub channel_id: i64, } -impl Follow { +impl Mute { fn from_row(row: &Row) -> Result { - Ok(Follow { + Ok(Mute { user_id: row.get(0)?, channel_id: row.get(1)?, }) } - pub fn create_table() { + fn create_table() { let conn = connection(); conn.execute( - "CREATE TABLE IF NOT EXISTS follows ( + "CREATE TABLE IF NOT EXISTS mutes ( user_id INTEGER NOT NULL, channel_id INTEGER NOT NULL, PRIMARY KEY (user_id, channel_id) @@ -248,26 +390,26 @@ impl Follow { .expect("Failed to create follows table"); } - pub async fn user_follows(user_id: UserId) -> Result, Error> { - await_db!("user follows": |conn| { + pub async fn user_mutes(user_id: UserId) -> Result, Error> { + await_db!("user mutes": |conn| { let user_id: i64 = user_id.0.try_into().unwrap(); let mut stmt = conn.prepare( "SELECT user_id, channel_id - FROM follows + FROM mutes WHERE user_id = ?" )?; - let follows = stmt.query_map(params![user_id], Follow::from_row)?; + let mutes = stmt.query_map(params![user_id], Mute::from_row)?; - follows.collect() + mutes.collect() }) } pub async fn exists(self) -> Result { - await_db!("follow exists": |conn| { + await_db!("mute exists": |conn| { conn.query_row( - "SELECT COUNT(*) FROM follows + "SELECT COUNT(*) FROM mutes WHERE user_id = ? AND channel_id = ?", params![self.user_id, self.channel_id], |row| Ok(row.get::<_, u32>(0)? == 1), @@ -276,9 +418,9 @@ impl Follow { } pub async fn insert(self) -> Result<(), Error> { - await_db!("insert follow": |conn| { + await_db!("insert mute": |conn| { conn.execute( - "INSERT INTO follows (user_id, channel_id) + "INSERT INTO mutes (user_id, channel_id) VALUES (?, ?)", params![self.user_id, self.channel_id], )?; @@ -288,9 +430,9 @@ impl Follow { } pub async fn delete(self) -> Result<(), Error> { - await_db!("delete follow": |conn| { + await_db!("delete mute": |conn| { conn.execute( - "DELETE FROM follows + "DELETE FROM mutes WHERE user_id = ? AND channel_id = ?", params![self.user_id, self.channel_id], )?; diff --git a/src/main.rs b/src/main.rs index 7984ad5..d11791b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,7 +21,7 @@ pub mod util; use util::{error, notify_keyword, question}; use serenity::{ - client::{Client, Context, EventHandler}, + client::{bridge::gateway::GatewayIntents, Client, Context, EventHandler}, model::{ channel::Message, gateway::{Activity, Ready}, @@ -90,12 +90,12 @@ async fn handle_command( let result = match command { "add" => commands::add(ctx, message, args).await, - "follow" => commands::follow(ctx, message, args).await, "remove" => commands::remove(ctx, message, args).await, - "removeserver" => commands::remove_server(ctx, message, args).await, - "unfollow" => commands::unfollow(ctx, message, args).await, + "mute" => commands::mute(ctx, message, args).await, + "unmute" => commands::unmute(ctx, message, args).await, + "remove-server" => commands::remove_server(ctx, message, args).await, "keywords" => commands::keywords(ctx, message, args).await, - "follows" => commands::follows(ctx, message, args).await, + "mutes" => commands::mutes(ctx, message, args).await, "help" => commands::help(ctx, message, args).await, "about" => commands::about(ctx, message, args).await, _ => question(ctx, message).await, @@ -188,6 +188,12 @@ async fn main() { let mut client = Client::new(token) .event_handler(Handler) + .intents( + GatewayIntents::DIRECT_MESSAGES + | GatewayIntents::GUILD_MESSAGES + | GatewayIntents::GUILDS + | GatewayIntents::GUILD_MEMBERS, + ) .await .expect("Failed to create client"); diff --git a/src/util.rs b/src/util.rs index 28c8bbe..835b0e7 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,6 +1,7 @@ // Copyright 2020 Benjamin Scherer // Licensed under the Open Software License version 3.0 +use once_cell::sync::Lazy; use serenity::{ client::Context, model::{channel::Message, id::UserId}, @@ -11,6 +12,7 @@ use crate::{ global::{EMBED_COLOR, PATIENCE_DURATION}, Error, }; +use regex::Regex; use std::{convert::TryInto, fmt::Display, ops::Range}; #[macro_export] @@ -34,6 +36,9 @@ macro_rules! regex { }}; } +pub static MD_SYMBOL_REGEX: Lazy Regex> = + Lazy::new(|| Regex::new(r"[_*()\[\]~`]").unwrap()); + pub async fn notify_keyword( ctx: Context, message: Message, @@ -51,8 +56,8 @@ pub async fn notify_keyword( .timeout(PATIENCE_DURATION); if new_message.await.is_none() { let result: Result<(), Error> = async { - let re = regex!(r"[_*()\[\]~`]"); let msg = &message.content; + let re = &*MD_SYMBOL_REGEX; let formatted_content = format!( "{}__**{}**__{}", re.replace_all(&msg[..keyword_range.start], r"\$0"),