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"),