From 44e8340dc578cc66429c048db69383e1ca8b1e1b Mon Sep 17 00:00:00 2001 From: portalBlock Date: Sun, 7 May 2023 14:24:39 -0500 Subject: [PATCH 01/36] Add Overwatch profile rules cog. --- ow_profile/__init__.py | 7 +++ ow_profile/info.json | 15 +++++ ow_profile/owprofile.py | 121 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 ow_profile/__init__.py create mode 100644 ow_profile/info.json create mode 100644 ow_profile/owprofile.py diff --git a/ow_profile/__init__.py b/ow_profile/__init__.py new file mode 100644 index 00000000..0ae92f28 --- /dev/null +++ b/ow_profile/__init__.py @@ -0,0 +1,7 @@ +from redbot.core.bot import Red + +from .owprofile import OWProfileCog + + +async def setup(bot: Red): + await bot.add_cog(OWProfileCog(bot)) diff --git a/ow_profile/info.json b/ow_profile/info.json new file mode 100644 index 00000000..3b2d1f0a --- /dev/null +++ b/ow_profile/info.json @@ -0,0 +1,15 @@ +{ + "author": [ + "portalBlock" + ], + "short": "[Overwatch] Monitor profile names for patterns.", + "description": "[Overwatch] Utilize regex pattern matching to check new users' names and nicknames.", + "disabled": false, + "name": "owprofile", + "tags": [ + "utility", + "mod" + ], + "install_msg": "Usage: `[p]owprofile add`", + "min_bot_version": "3.5.1" +} \ No newline at end of file diff --git a/ow_profile/owprofile.py b/ow_profile/owprofile.py new file mode 100644 index 00000000..b60448aa --- /dev/null +++ b/ow_profile/owprofile.py @@ -0,0 +1,121 @@ +"""discord red-bot overwatch profile""" +import re +from datetime import datetime + +import discord +import discord.utils +from redbot.core import Config, checks, commands +from redbot.core.bot import Red +from redbot.core.utils.chat_formatting import escape + + +class OWProfileCog(commands.Cog): + """Overwatch Profile Cog""" + + def __init__(self, bot: Red): + self.bot = bot + self.config = Config.get_conf(self, identifier=128986274420752384001) + + default_guild_config = { + "logchannel": "", # Channel to send alerts to + "matchers": { + "spammer1": { # Name of rule + "pattern": "", # Regex pattern to match against + "check_nick": False, # Whether the user's nickname should be checked too + "alert_level": "", # Severity of alerts (use: HIGH or LOW) + "reason": "" # Reason for the match, used to add context to alerts + } + } + } + + self.config.register_guild(**default_guild_config) + + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member): + + matcher_list = await self.config.guild(member.guild).matchers() + + for rule, matcher in matcher_list.items(): + hits = len(re.findall(matcher.pattern(), member.name)) + if matcher.check_nick(): + hits += len(re.findall(matcher.pattern(), member.nick)) + if hits > 0: + + # Credit: Taken from report Cog + log_id = await self.config.guild(member.guild).logchannel() + log = None + if log_id: + log = member.guild.get_channel(log_id) + if not log: + # Failed to get the channel + return + + data = self.make_alert_embed(member, rule, matcher) + + mod_pings = "" + # Alert level logic added + if matcher.alert_level() == "HIGH": + mod_pings = " ".join( + [i.mention for i in log.members if not i.bot and str(i.status) in ["online", "idle"]]) + if not mod_pings: # If no online/idle mods + mod_pings = " ".join([i.mention for i in log.members if not i.bot]) + + await log.send(content=mod_pings, embed=data) + # End credit + + # Command groups + + @checks.admin() + @commands.group(name="owprofile", pass_context=True) + async def _owprofile(self, ctx): + """Monitor for flagged member name formats""" + + # Commands + + @_owprofile.command(name="add") + async def _add(self, ctx, name: str = "", regex: str = "", reason: str = "", alert_level: str = "", check_nick: str = ""): + """Add member name trigger""" + + usage = "Usage: `[p]owprofile add " + if (not name and not regex and not reason and not alert_level and not check_nick) or \ + (alert_level != "HIGH" and alert_level != "LOW") or (check_nick != "YES" and check_nick != "NO"): + await ctx.send(usage) + else: + async with self.config.guild(ctx.guild).matchers() as matchers: + matchers[name] = { + "pattern": regex, + "check_nick": True if check_nick == "YES" else False, + "alert_level": alert_level, + "reason": reason + } + await ctx.send("✅ Matcher rule successfully added!") + + @_owprofile.command("delete") + async def _delete(self, ctx, name: str = ""): + """Delete member name trigger""" + + usage = "Usage: [p]owprofile delete " + if not name: + await ctx.send(usage) + else: + async with self.config.guild(ctx.guild).matchers() as matchers: + if name in matchers: + found = True + del matchers[name] + if found: + await ctx.send("Matcher rule deleted!") + else: + await ctx.send("Specified matcher rule not found.") + + def make_alert_embed(self, member: discord.Member, rule: str, matcher) -> discord.Embed: + """Construct the alert embed to be sent""" + # Copied from the report Cog. + return ( + discord.Embed( + colour=discord.Colour.red() if matcher.alert_level() == "HIGH" else discord.Colour.orange(), + description=escape("Rule: " + rule + "\nReason: "+matcher.reason() or "") + ) + .set_author(name="Profile Violation Detected", icon_url=member.avatar.url) + .add_field(name="Server", value=member.guild.name) + .add_field(name="Timestamp", value=f"") + ) From 4678785414f6d5ed51ebede18ae73d20500ca8cd Mon Sep 17 00:00:00 2001 From: portalBlock Date: Sun, 7 May 2023 14:37:57 -0500 Subject: [PATCH 02/36] Add the log channel command to set the output channel (oops, missed that). Also fix some message formatting. --- ow_profile/owprofile.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ow_profile/owprofile.py b/ow_profile/owprofile.py index b60448aa..95964ebb 100644 --- a/ow_profile/owprofile.py +++ b/ow_profile/owprofile.py @@ -76,7 +76,7 @@ async def _owprofile(self, ctx): async def _add(self, ctx, name: str = "", regex: str = "", reason: str = "", alert_level: str = "", check_nick: str = ""): """Add member name trigger""" - usage = "Usage: `[p]owprofile add " + usage = "Usage: `[p]owprofile add `" if (not name and not regex and not reason and not alert_level and not check_nick) or \ (alert_level != "HIGH" and alert_level != "LOW") or (check_nick != "YES" and check_nick != "NO"): await ctx.send(usage) @@ -94,7 +94,7 @@ async def _add(self, ctx, name: str = "", regex: str = "", reason: str = "", ale async def _delete(self, ctx, name: str = ""): """Delete member name trigger""" - usage = "Usage: [p]owprofile delete " + usage = "Usage: `[p]owprofile delete `" if not name: await ctx.send(usage) else: @@ -107,6 +107,13 @@ async def _delete(self, ctx, name: str = ""): else: await ctx.send("Specified matcher rule not found.") + @_owprofile.command("channel") + async def _channel(self, ctx, channel: discord.TextChannel): + """Set the alert channel""" + + await self.config.guild(ctx.guild).logchannel().set(channel.id) + await ctx.send("Alert channel set to current channel!") + def make_alert_embed(self, member: discord.Member, rule: str, matcher) -> discord.Embed: """Construct the alert embed to be sent""" # Copied from the report Cog. From a5ab5a599d97e2320eac2722f3bcb09f07414be3 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Sun, 7 May 2023 14:44:20 -0500 Subject: [PATCH 03/36] Use the correct config access when loading rules. --- ow_profile/owprofile.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ow_profile/owprofile.py b/ow_profile/owprofile.py index 95964ebb..5c56d8e5 100644 --- a/ow_profile/owprofile.py +++ b/ow_profile/owprofile.py @@ -36,9 +36,9 @@ async def on_member_join(self, member: discord.Member): matcher_list = await self.config.guild(member.guild).matchers() for rule, matcher in matcher_list.items(): - hits = len(re.findall(matcher.pattern(), member.name)) - if matcher.check_nick(): - hits += len(re.findall(matcher.pattern(), member.nick)) + hits = len(re.findall(matcher['pattern'], member.name)) + if matcher['check_nick']: + hits += len(re.findall(matcher['pattern'], member.nick)) if hits > 0: # Credit: Taken from report Cog @@ -119,8 +119,8 @@ def make_alert_embed(self, member: discord.Member, rule: str, matcher) -> discor # Copied from the report Cog. return ( discord.Embed( - colour=discord.Colour.red() if matcher.alert_level() == "HIGH" else discord.Colour.orange(), - description=escape("Rule: " + rule + "\nReason: "+matcher.reason() or "") + colour=discord.Colour.red() if matcher['alert_level'] == "HIGH" else discord.Colour.orange(), + description=escape("Rule: " + rule + "\nReason: "+matcher['reason'] or "") ) .set_author(name="Profile Violation Detected", icon_url=member.avatar.url) .add_field(name="Server", value=member.guild.name) From a6e0b00c804541cdde372f8df994e2ad6e3921c9 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Sun, 7 May 2023 14:52:48 -0500 Subject: [PATCH 04/36] Correct a breaking typo. --- ow_profile/owprofile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ow_profile/owprofile.py b/ow_profile/owprofile.py index 5c56d8e5..5eeec051 100644 --- a/ow_profile/owprofile.py +++ b/ow_profile/owprofile.py @@ -111,7 +111,7 @@ async def _delete(self, ctx, name: str = ""): async def _channel(self, ctx, channel: discord.TextChannel): """Set the alert channel""" - await self.config.guild(ctx.guild).logchannel().set(channel.id) + await self.config.guild(ctx.guild).logchannel.set(channel.id) await ctx.send("Alert channel set to current channel!") def make_alert_embed(self, member: discord.Member, rule: str, matcher) -> discord.Embed: From 0574223d8bbd64f27e31dc6329aed78736b448a2 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Sun, 7 May 2023 14:57:01 -0500 Subject: [PATCH 05/36] Correct a missed config access error. --- ow_profile/owprofile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ow_profile/owprofile.py b/ow_profile/owprofile.py index 5eeec051..4d8e35fb 100644 --- a/ow_profile/owprofile.py +++ b/ow_profile/owprofile.py @@ -54,7 +54,7 @@ async def on_member_join(self, member: discord.Member): mod_pings = "" # Alert level logic added - if matcher.alert_level() == "HIGH": + if matcher['alert_level'] == "HIGH": mod_pings = " ".join( [i.mention for i in log.members if not i.bot and str(i.status) in ["online", "idle"]]) if not mod_pings: # If no online/idle mods From 7d3ec2aef35d3e335f750ddac573be43e084daa3 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Sun, 7 May 2023 15:07:25 -0500 Subject: [PATCH 06/36] Change matchers to rules for more clarity, remove some default configs that appear to be causing an issue. --- ow_profile/owprofile.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/ow_profile/owprofile.py b/ow_profile/owprofile.py index 4d8e35fb..b1d16794 100644 --- a/ow_profile/owprofile.py +++ b/ow_profile/owprofile.py @@ -18,9 +18,9 @@ def __init__(self, bot: Red): default_guild_config = { "logchannel": "", # Channel to send alerts to - "matchers": { + "rules": { "spammer1": { # Name of rule - "pattern": "", # Regex pattern to match against + "pattern": "^portalBlock$", # Regex pattern to match against "check_nick": False, # Whether the user's nickname should be checked too "alert_level": "", # Severity of alerts (use: HIGH or LOW) "reason": "" # Reason for the match, used to add context to alerts @@ -28,17 +28,17 @@ def __init__(self, bot: Red): } } - self.config.register_guild(**default_guild_config) + # self.config.register_guild(**default_guild_config) @commands.Cog.listener() async def on_member_join(self, member: discord.Member): - matcher_list = await self.config.guild(member.guild).matchers() + matcher_list = await self.config.guild(member.guild).rules() - for rule, matcher in matcher_list.items(): - hits = len(re.findall(matcher['pattern'], member.name)) - if matcher['check_nick']: - hits += len(re.findall(matcher['pattern'], member.nick)) + for ruleName, rule in matcher_list.items(): + hits = len(re.findall(rule['pattern'], member.name)) + # if rule['check_nick']: + # hits += len(re.findall(rule['pattern'], member.nick)) if hits > 0: # Credit: Taken from report Cog @@ -50,11 +50,11 @@ async def on_member_join(self, member: discord.Member): # Failed to get the channel return - data = self.make_alert_embed(member, rule, matcher) + data = self.make_alert_embed(member, ruleName, rule) mod_pings = "" # Alert level logic added - if matcher['alert_level'] == "HIGH": + if rule['alert_level'] == "HIGH": mod_pings = " ".join( [i.mention for i in log.members if not i.bot and str(i.status) in ["online", "idle"]]) if not mod_pings: # If no online/idle mods From 586983c02eb2bd2a1ba5720b318eb4c19eecb8e2 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Sun, 7 May 2023 15:14:17 -0500 Subject: [PATCH 07/36] Missed the adding logic while renaming before. --- ow_profile/owprofile.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ow_profile/owprofile.py b/ow_profile/owprofile.py index b1d16794..145432d6 100644 --- a/ow_profile/owprofile.py +++ b/ow_profile/owprofile.py @@ -77,12 +77,13 @@ async def _add(self, ctx, name: str = "", regex: str = "", reason: str = "", ale """Add member name trigger""" usage = "Usage: `[p]owprofile add `" + usage += "\nNote: Reason is temporarily limited to 1 word. This is a WIP. Nick checking is also WIP." if (not name and not regex and not reason and not alert_level and not check_nick) or \ (alert_level != "HIGH" and alert_level != "LOW") or (check_nick != "YES" and check_nick != "NO"): await ctx.send(usage) else: - async with self.config.guild(ctx.guild).matchers() as matchers: - matchers[name] = { + async with self.config.guild(ctx.guild).rules() as rules: + rules[name] = { "pattern": regex, "check_nick": True if check_nick == "YES" else False, "alert_level": alert_level, From 5cce5b43ca77b3cb4e50a04da9b40f6fbfbcbd03 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Sun, 7 May 2023 15:20:04 -0500 Subject: [PATCH 08/36] Add back the default config? I'm not even sure what's going on with this now. It's more broken than when I started. --- ow_profile/owprofile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ow_profile/owprofile.py b/ow_profile/owprofile.py index 145432d6..2b362357 100644 --- a/ow_profile/owprofile.py +++ b/ow_profile/owprofile.py @@ -28,7 +28,7 @@ def __init__(self, bot: Red): } } - # self.config.register_guild(**default_guild_config) + self.config.register_guild(**default_guild_config) @commands.Cog.listener() async def on_member_join(self, member: discord.Member): From 1fe7a57a5282a142286f97e3f13844a68bc9a9b5 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Mon, 15 May 2023 10:21:54 -0500 Subject: [PATCH 09/36] Fill out the default config more as a sample. Restore (and hopefully fix) nickname checking. Adjust rule parameter order to allow full collection of the reason. --- ow_profile/owprofile.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/ow_profile/owprofile.py b/ow_profile/owprofile.py index 2b362357..43b81341 100644 --- a/ow_profile/owprofile.py +++ b/ow_profile/owprofile.py @@ -22,8 +22,8 @@ def __init__(self, bot: Red): "spammer1": { # Name of rule "pattern": "^portalBlock$", # Regex pattern to match against "check_nick": False, # Whether the user's nickname should be checked too - "alert_level": "", # Severity of alerts (use: HIGH or LOW) - "reason": "" # Reason for the match, used to add context to alerts + "alert_level": "HIGH", # Severity of alerts (use: HIGH or LOW) + "reason": "Impostor :sus:" # Reason for the match, used to add context to alerts } } } @@ -37,8 +37,8 @@ async def on_member_join(self, member: discord.Member): for ruleName, rule in matcher_list.items(): hits = len(re.findall(rule['pattern'], member.name)) - # if rule['check_nick']: - # hits += len(re.findall(rule['pattern'], member.nick)) + if rule['check_nick'] and member.nick is not None: + hits += len(re.findall(rule['pattern'], member.nick)) if hits > 0: # Credit: Taken from report Cog @@ -73,11 +73,12 @@ async def _owprofile(self, ctx): # Commands @_owprofile.command(name="add") - async def _add(self, ctx, name: str = "", regex: str = "", reason: str = "", alert_level: str = "", check_nick: str = ""): - """Add member name trigger""" + async def _add(self, ctx, name: str = "", regex: str = "", alert_level: str = "", + check_nick: str = "", *, reason: str = ""): + """Add/edit member name trigger""" - usage = "Usage: `[p]owprofile add `" - usage += "\nNote: Reason is temporarily limited to 1 word. This is a WIP. Nick checking is also WIP." + usage = "Usage: `[p]owprofile add `" + usage += "\nNote: Name & regex fields are limited to 1 word (no spaces)." if (not name and not regex and not reason and not alert_level and not check_nick) or \ (alert_level != "HIGH" and alert_level != "LOW") or (check_nick != "YES" and check_nick != "NO"): await ctx.send(usage) From 29220d29b9aa26ffa16d8bd60f8061c60d2e9e86 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Mon, 15 May 2023 15:27:45 -0500 Subject: [PATCH 10/36] Add rule list command. --- ow_profile/owprofile.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/ow_profile/owprofile.py b/ow_profile/owprofile.py index 43b81341..ad4fad10 100644 --- a/ow_profile/owprofile.py +++ b/ow_profile/owprofile.py @@ -1,12 +1,14 @@ """discord red-bot overwatch profile""" import re from datetime import datetime +from typing import List import discord import discord.utils from redbot.core import Config, checks, commands from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import escape +from redbot.core.utils.chat_formatting import escape, pagify +from redbot.core.utils.menus import menu, prev_page, close_menu, next_page class OWProfileCog(commands.Cog): @@ -37,7 +39,7 @@ async def on_member_join(self, member: discord.Member): for ruleName, rule in matcher_list.items(): hits = len(re.findall(rule['pattern'], member.name)) - if rule['check_nick'] and member.nick is not None: + if rule['check_nick'] and member.nick: hits += len(re.findall(rule['pattern'], member.nick)) if hits > 0: @@ -92,6 +94,23 @@ async def _add(self, ctx, name: str = "", regex: str = "", alert_level: str = "" } await ctx.send("✅ Matcher rule successfully added!") + @_owprofile.command("list") + async def _list(self, ctx: commands.Context): + """List current name triggers""" + rules = await self.config.guild(ctx.guild).rules() + pages = list(pagify("\n\n".join(self.rule_to_string(rn, r) for rn, r in rules))) + base_embed_options = {"title": "Overwatch Profile Name Rules", "colour": await ctx.embed_colour()} + embeds = [ + discord.Embed(**base_embed_options, description=page).set_footer(text=f"Page {index} of {len(pages)}") + for index, page in enumerate(pages, start=1) + ] + if len(embeds) == 1: + await ctx.send(embed=embeds[0]) + else: + ctx.bot.loop.create_task( + menu(ctx=ctx, pages=embeds, controls={"⬅️": prev_page, "⏹️": close_menu, "➡️": next_page}, + timeout=180.0) + ) @_owprofile.command("delete") async def _delete(self, ctx, name: str = ""): """Delete member name trigger""" @@ -128,3 +147,7 @@ def make_alert_embed(self, member: discord.Member, rule: str, matcher) -> discor .add_field(name="Server", value=member.guild.name) .add_field(name="Timestamp", value=f"") ) + + def rule_to_string(self, rule_name: str, rule) -> str: + return f"{rule_name}:\n\tPattern: `{rule['pattern']}`\n\tCheck Nick: `{rule['check_nic']}`\n\tAlert Level: " \ + f"{rule['alert_level']}\n\tReason: {rule['reason']}" From f9ace2af971a635495cdc428b5ed62b1cf135fc9 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Mon, 15 May 2023 15:31:04 -0500 Subject: [PATCH 11/36] For a thing --- ow_profile/owprofile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ow_profile/owprofile.py b/ow_profile/owprofile.py index ad4fad10..3d8bc54c 100644 --- a/ow_profile/owprofile.py +++ b/ow_profile/owprofile.py @@ -98,7 +98,7 @@ async def _add(self, ctx, name: str = "", regex: str = "", alert_level: str = "" async def _list(self, ctx: commands.Context): """List current name triggers""" rules = await self.config.guild(ctx.guild).rules() - pages = list(pagify("\n\n".join(self.rule_to_string(rn, r) for rn, r in rules))) + pages = list(pagify("\n\n".join(self.rule_to_string(rn, r) for rn, r in rules.items()))) base_embed_options = {"title": "Overwatch Profile Name Rules", "colour": await ctx.embed_colour()} embeds = [ discord.Embed(**base_embed_options, description=page).set_footer(text=f"Page {index} of {len(pages)}") From c76a81e9c15cd5e57e9a17cb103232d64ba1a462 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Mon, 15 May 2023 15:32:26 -0500 Subject: [PATCH 12/36] Fix a typo... --- ow_profile/owprofile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ow_profile/owprofile.py b/ow_profile/owprofile.py index 3d8bc54c..be2719bd 100644 --- a/ow_profile/owprofile.py +++ b/ow_profile/owprofile.py @@ -149,5 +149,5 @@ def make_alert_embed(self, member: discord.Member, rule: str, matcher) -> discor ) def rule_to_string(self, rule_name: str, rule) -> str: - return f"{rule_name}:\n\tPattern: `{rule['pattern']}`\n\tCheck Nick: `{rule['check_nic']}`\n\tAlert Level: " \ + return f"{rule_name}:\n\tPattern: `{rule['pattern']}`\n\tCheck Nick: `{rule['check_nick']}`\n\tAlert Level: " \ f"{rule['alert_level']}\n\tReason: {rule['reason']}" From 44a86219009872dba399710f2c60ef90bfae3a1a Mon Sep 17 00:00:00 2001 From: portalBlock Date: Mon, 15 May 2023 16:05:35 -0500 Subject: [PATCH 13/36] Rename ow_profile to owprofile and create owvoice cog base. --- {ow_profile => owprofile}/__init__.py | 0 {ow_profile => owprofile}/info.json | 0 {ow_profile => owprofile}/owprofile.py | 5 ++--- owvoice/__init__.py | 7 +++++++ owvoice/info.json | 15 +++++++++++++++ owvoice/owvoice.py | 18 ++++++++++++++++++ 6 files changed, 42 insertions(+), 3 deletions(-) rename {ow_profile => owprofile}/__init__.py (100%) rename {ow_profile => owprofile}/info.json (100%) rename {ow_profile => owprofile}/owprofile.py (96%) create mode 100644 owvoice/__init__.py create mode 100644 owvoice/info.json create mode 100644 owvoice/owvoice.py diff --git a/ow_profile/__init__.py b/owprofile/__init__.py similarity index 100% rename from ow_profile/__init__.py rename to owprofile/__init__.py diff --git a/ow_profile/info.json b/owprofile/info.json similarity index 100% rename from ow_profile/info.json rename to owprofile/info.json diff --git a/ow_profile/owprofile.py b/owprofile/owprofile.py similarity index 96% rename from ow_profile/owprofile.py rename to owprofile/owprofile.py index be2719bd..d5f35612 100644 --- a/ow_profile/owprofile.py +++ b/owprofile/owprofile.py @@ -1,7 +1,6 @@ """discord red-bot overwatch profile""" import re from datetime import datetime -from typing import List import discord import discord.utils @@ -149,5 +148,5 @@ def make_alert_embed(self, member: discord.Member, rule: str, matcher) -> discor ) def rule_to_string(self, rule_name: str, rule) -> str: - return f"{rule_name}:\n\tPattern: `{rule['pattern']}`\n\tCheck Nick: `{rule['check_nick']}`\n\tAlert Level: " \ - f"{rule['alert_level']}\n\tReason: {rule['reason']}" + return f"**{rule_name}**:\nPattern: `{rule['pattern']}`\nCheck Nick: `{rule['check_nick']}`\nAlert Level: " \ + f"{rule['alert_level']}\nReason: {rule['reason']}" diff --git a/owvoice/__init__.py b/owvoice/__init__.py new file mode 100644 index 00000000..f44338b1 --- /dev/null +++ b/owvoice/__init__.py @@ -0,0 +1,7 @@ +from redbot.core.bot import Red + +from .owvoice import OWVoiceCog + + +async def setup(bot: Red): + await bot.add_cog(OWVoiceCog(bot)) diff --git a/owvoice/info.json b/owvoice/info.json new file mode 100644 index 00000000..a9493516 --- /dev/null +++ b/owvoice/info.json @@ -0,0 +1,15 @@ +{ + "author": [ + "portalBlock" + ], + "short": "[Overwatch] Detect suspicious voice channel activity.", + "description": "[Overwatch] Analyzes voice and video activity in applicable channels to detect suspicious actions.", + "disabled": false, + "name": "owvoice", + "tags": [ + "utility", + "mod" + ], + "install_msg": "Usage: `[p]owvoice add`", + "min_bot_version": "3.5.1" +} \ No newline at end of file diff --git a/owvoice/owvoice.py b/owvoice/owvoice.py new file mode 100644 index 00000000..e7027a47 --- /dev/null +++ b/owvoice/owvoice.py @@ -0,0 +1,18 @@ +from redbot.core import Config +from redbot.core.bot import Red +from redbot.core.commands import commands + + +class OWVoiceCog(commands.Cog): + """Overwatch Voice Cog""" + + def __init__(self, bot: Red): + self.bot = bot + self.config = Config.get_conf(self, identifier=128986274420752384003) + + default_guild_config = { + "logchannel": "", # Channel to send alerts to + "": "" + } + + self.config.register_guild(**default_guild_config) \ No newline at end of file From 66f5245a03876938e8366ba172991a5e69ef7e94 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Mon, 15 May 2023 20:11:22 -0500 Subject: [PATCH 14/36] Implement Overwatch voice capabilities. --- owvoice/owvoice.py | 87 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/owvoice/owvoice.py b/owvoice/owvoice.py index e7027a47..b119ddde 100644 --- a/owvoice/owvoice.py +++ b/owvoice/owvoice.py @@ -1,4 +1,8 @@ -from redbot.core import Config +from datetime import datetime, timezone, timedelta +from typing import Optional + +import discord +from redbot.core import Config, checks from redbot.core.bot import Red from redbot.core.commands import commands @@ -9,10 +13,85 @@ class OWVoiceCog(commands.Cog): def __init__(self, bot: Red): self.bot = bot self.config = Config.get_conf(self, identifier=128986274420752384003) - + self.alertCache = {} default_guild_config = { "logchannel": "", # Channel to send alerts to - "": "" + "min_joined_hours": 8760 # Default to one year so the mods set it up :) } - self.config.register_guild(**default_guild_config) \ No newline at end of file + self.config.register_guild(**default_guild_config) + + @checks.admin() + @commands.group("owvoice", pass_context=True) + async def _owvoice(self, ctx: commands.Context): + pass + + @_owvoice.command(name="time") + async def _time(self, ctx: commands.Context, hours: int): + """Set/update the minimum hours users must be in the server before without triggering an alert.""" + + self.config.guild(ctx.guild).min_joined_hours.set(hours) + await ctx.send("✅ Time requirement successfully updated!") + + @_owvoice.command(name="logchannel") + async def _time(self, ctx: commands.Context, channel: Optional[discord.TextChannel]): + """Set/update the channel to send voice activity alerts to.""" + + chanId = ctx.channel.id + if channel: + chanId = channel.id + self.config.guild(ctx.guild).logchannel.set(chanId) + await ctx.send("✅ Alert channel successfully updated!") + + @commands.Cog.listener() + async def _on_voice_state_update(self, ctx: commands.Context, member: discord.Member, + before: discord.VoiceState, after: discord.VoiceState): + + if self.alertCache[member.id]: # Ignore if we've already alerted on this user. Can probably find a better way. + return + + # Check if we're missing before or after objects or the respective channels. + # If everything is there, check if the user switched channels (we don't care about that) + if (before is None or after is None) or (before.channel is None or after.channel is None) or\ + (before.channel is not after.channel): + return + + allowable = datetime.now(timezone.utc) + timedelta(hours=await self.config.guild(ctx.guild).min_join_hours()) + if datetime.now(timezone.utc) < allowable: + if after.self_stream or after.self_video: + self.alertCache[member.id] = True # Update the cache to indicate we've alerted on this user. + # Credit: Taken from report Cog + log_id = await self.config.guild(member.guild).logchannel() + log = None + if log_id: + log = member.guild.get_channel(log_id) + if not log: + # Failed to get the channel + return + + data = self.make_alert_embed(member, after.channel) + + mod_pings = "" + # Alert level logic added + mod_pings = " ".join( + [i.mention for i in log.members if not i.bot and str(i.status) in ["online", "idle"]]) + if not mod_pings: # If no online/idle mods + mod_pings = " ".join([i.mention for i in log.members if not i.bot]) + + await log.send(content=mod_pings, embed=data) + # End credit + + def make_alert_embed(self, member: discord.Member, chan: discord.VoiceChannel) -> discord.Embed: + """Construct the alert embed to be sent""" + # Copied from the report Cog. + return ( + discord.Embed( + colour=discord.Colour.orange(), + description="New user joined a voice channel and started streaming or enabled their camera." + ) + .set_author(name="Suspicious User Activity", icon_url=member.avatar.url) + .add_field(name="Server", value=member.guild.name) + .add_field(name="User", value=member.mention) + .add_field(name="Channel", value=chan.mention) + .add_field(name="Timestamp", value=f"") + ) \ No newline at end of file From b3e08e4712b01ef70d358a60b0b3a1ec7a820700 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Mon, 15 May 2023 20:15:26 -0500 Subject: [PATCH 15/36] Attempt to fix a perfectly sensible error. --- owvoice/owvoice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/owvoice/owvoice.py b/owvoice/owvoice.py index b119ddde..87eb69ea 100644 --- a/owvoice/owvoice.py +++ b/owvoice/owvoice.py @@ -23,7 +23,7 @@ def __init__(self, bot: Red): @checks.admin() @commands.group("owvoice", pass_context=True) - async def _owvoice(self, ctx: commands.Context): + async def _owvoice(self, ctx): pass @_owvoice.command(name="time") From 1560083ba2b9b31e0552fa5a18ce97a3fb545a01 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Mon, 15 May 2023 20:17:17 -0500 Subject: [PATCH 16/36] IDE auto import at it again. --- owvoice/owvoice.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/owvoice/owvoice.py b/owvoice/owvoice.py index 87eb69ea..6f70658b 100644 --- a/owvoice/owvoice.py +++ b/owvoice/owvoice.py @@ -4,7 +4,7 @@ import discord from redbot.core import Config, checks from redbot.core.bot import Red -from redbot.core.commands import commands +from redbot.core import commands class OWVoiceCog(commands.Cog): @@ -23,7 +23,7 @@ def __init__(self, bot: Red): @checks.admin() @commands.group("owvoice", pass_context=True) - async def _owvoice(self, ctx): + async def _owvoice(self, ctx: commands.Context): pass @_owvoice.command(name="time") From 56678590a689c927f0b3669b52a3306973b41ff8 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Mon, 15 May 2023 20:21:21 -0500 Subject: [PATCH 17/36] Oh wait, I forgot to await. --- owvoice/owvoice.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/owvoice/owvoice.py b/owvoice/owvoice.py index 6f70658b..0a2c6371 100644 --- a/owvoice/owvoice.py +++ b/owvoice/owvoice.py @@ -30,7 +30,7 @@ async def _owvoice(self, ctx: commands.Context): async def _time(self, ctx: commands.Context, hours: int): """Set/update the minimum hours users must be in the server before without triggering an alert.""" - self.config.guild(ctx.guild).min_joined_hours.set(hours) + await self.config.guild(ctx.guild).min_joined_hours.set(hours) await ctx.send("✅ Time requirement successfully updated!") @_owvoice.command(name="logchannel") @@ -40,7 +40,7 @@ async def _time(self, ctx: commands.Context, channel: Optional[discord.TextChann chanId = ctx.channel.id if channel: chanId = channel.id - self.config.guild(ctx.guild).logchannel.set(chanId) + await self.config.guild(ctx.guild).logchannel.set(chanId) await ctx.send("✅ Alert channel successfully updated!") @commands.Cog.listener() From 29b04e97bbb9295a31181f87ed4d4f4f9f1e3953 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Mon, 15 May 2023 20:32:32 -0500 Subject: [PATCH 18/36] Fix owvoice cog issues. --- owvoice/owvoice.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/owvoice/owvoice.py b/owvoice/owvoice.py index 0a2c6371..c185bc01 100644 --- a/owvoice/owvoice.py +++ b/owvoice/owvoice.py @@ -27,14 +27,17 @@ async def _owvoice(self, ctx: commands.Context): pass @_owvoice.command(name="time") - async def _time(self, ctx: commands.Context, hours: int): + async def _time(self, ctx: commands.Context, hours: str): """Set/update the minimum hours users must be in the server before without triggering an alert.""" - - await self.config.guild(ctx.guild).min_joined_hours.set(hours) - await ctx.send("✅ Time requirement successfully updated!") + try: + hrs = int(hours) + await self.config.guild(ctx.guild).min_joined_hours.set(hrs) + await ctx.send("✅ Time requirement successfully updated!") + except ValueError: + await ctx.send("Error: Non-number hour argument supplied.") @_owvoice.command(name="logchannel") - async def _time(self, ctx: commands.Context, channel: Optional[discord.TextChannel]): + async def _logchannel(self, ctx: commands.Context, channel: Optional[discord.TextChannel]): """Set/update the channel to send voice activity alerts to.""" chanId = ctx.channel.id @@ -44,24 +47,24 @@ async def _time(self, ctx: commands.Context, channel: Optional[discord.TextChann await ctx.send("✅ Alert channel successfully updated!") @commands.Cog.listener() - async def _on_voice_state_update(self, ctx: commands.Context, member: discord.Member, + async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState): - - if self.alertCache[member.id]: # Ignore if we've already alerted on this user. Can probably find a better way. + if member.id in self.alertCache and self.alertCache[member.id]: # Ignore if we've already alerted on this user. Can probably find a better way. + # Todo: Make guild-aware return + else: + self.alertCache[member.id] = False - # Check if we're missing before or after objects or the respective channels. - # If everything is there, check if the user switched channels (we don't care about that) - if (before is None or after is None) or (before.channel is None or after.channel is None) or\ - (before.channel is not after.channel): + if after is None or after.channel is None: # Check if we're missing the after data or associated channel. return - allowable = datetime.now(timezone.utc) + timedelta(hours=await self.config.guild(ctx.guild).min_join_hours()) + allowable = datetime.now(timezone.utc) + \ + timedelta(hours=await self.config.guild(after.channel.guild).min_joined_hours()) if datetime.now(timezone.utc) < allowable: if after.self_stream or after.self_video: self.alertCache[member.id] = True # Update the cache to indicate we've alerted on this user. # Credit: Taken from report Cog - log_id = await self.config.guild(member.guild).logchannel() + log_id = await self.config.guild(after.channel.guild).logchannel() log = None if log_id: log = member.guild.get_channel(log_id) @@ -71,7 +74,6 @@ async def _on_voice_state_update(self, ctx: commands.Context, member: discord.Me data = self.make_alert_embed(member, after.channel) - mod_pings = "" # Alert level logic added mod_pings = " ".join( [i.mention for i in log.members if not i.bot and str(i.status) in ["online", "idle"]]) From e38d1678bc11e467a64cd24be973e51b1f741465 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Tue, 16 May 2023 04:28:54 -0500 Subject: [PATCH 19/36] Used a relative time not anchored time, oops. --- owvoice/owvoice.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/owvoice/owvoice.py b/owvoice/owvoice.py index c185bc01..4b5403d1 100644 --- a/owvoice/owvoice.py +++ b/owvoice/owvoice.py @@ -58,8 +58,7 @@ async def on_voice_state_update(self, member: discord.Member, if after is None or after.channel is None: # Check if we're missing the after data or associated channel. return - allowable = datetime.now(timezone.utc) + \ - timedelta(hours=await self.config.guild(after.channel.guild).min_joined_hours()) + allowable = member.joined_at + timedelta(hours=await self.config.guild(after.channel.guild).min_joined_hours()) if datetime.now(timezone.utc) < allowable: if after.self_stream or after.self_video: self.alertCache[member.id] = True # Update the cache to indicate we've alerted on this user. From 55c6fd4860291e783ecb024adf3994280dec524a Mon Sep 17 00:00:00 2001 From: portalBlock Date: Sat, 20 May 2023 09:33:00 -0500 Subject: [PATCH 20/36] Remove ow naming. --- owprofile/__init__.py | 7 ------ owprofile/info.json | 15 ------------ owvoice/__init__.py | 7 ------ owvoice/info.json | 15 ------------ profilewatch/__init__.py | 7 ++++++ profilewatch/info.json | 15 ++++++++++++ .../profilewatch.py | 23 +++++++++---------- voicewatch/__init__.py | 7 ++++++ voicewatch/info.json | 15 ++++++++++++ .../owvoice.py => voicewatch/voicewatch.py | 12 +++++----- 10 files changed, 61 insertions(+), 62 deletions(-) delete mode 100644 owprofile/__init__.py delete mode 100644 owprofile/info.json delete mode 100644 owvoice/__init__.py delete mode 100644 owvoice/info.json create mode 100644 profilewatch/__init__.py create mode 100644 profilewatch/info.json rename owprofile/owprofile.py => profilewatch/profilewatch.py (90%) create mode 100644 voicewatch/__init__.py create mode 100644 voicewatch/info.json rename owvoice/owvoice.py => voicewatch/voicewatch.py (93%) diff --git a/owprofile/__init__.py b/owprofile/__init__.py deleted file mode 100644 index 0ae92f28..00000000 --- a/owprofile/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from redbot.core.bot import Red - -from .owprofile import OWProfileCog - - -async def setup(bot: Red): - await bot.add_cog(OWProfileCog(bot)) diff --git a/owprofile/info.json b/owprofile/info.json deleted file mode 100644 index 3b2d1f0a..00000000 --- a/owprofile/info.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "author": [ - "portalBlock" - ], - "short": "[Overwatch] Monitor profile names for patterns.", - "description": "[Overwatch] Utilize regex pattern matching to check new users' names and nicknames.", - "disabled": false, - "name": "owprofile", - "tags": [ - "utility", - "mod" - ], - "install_msg": "Usage: `[p]owprofile add`", - "min_bot_version": "3.5.1" -} \ No newline at end of file diff --git a/owvoice/__init__.py b/owvoice/__init__.py deleted file mode 100644 index f44338b1..00000000 --- a/owvoice/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from redbot.core.bot import Red - -from .owvoice import OWVoiceCog - - -async def setup(bot: Red): - await bot.add_cog(OWVoiceCog(bot)) diff --git a/owvoice/info.json b/owvoice/info.json deleted file mode 100644 index a9493516..00000000 --- a/owvoice/info.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "author": [ - "portalBlock" - ], - "short": "[Overwatch] Detect suspicious voice channel activity.", - "description": "[Overwatch] Analyzes voice and video activity in applicable channels to detect suspicious actions.", - "disabled": false, - "name": "owvoice", - "tags": [ - "utility", - "mod" - ], - "install_msg": "Usage: `[p]owvoice add`", - "min_bot_version": "3.5.1" -} \ No newline at end of file diff --git a/profilewatch/__init__.py b/profilewatch/__init__.py new file mode 100644 index 00000000..d7eeb5e6 --- /dev/null +++ b/profilewatch/__init__.py @@ -0,0 +1,7 @@ +from redbot.core.bot import Red + +from .profilewatch import ProfileWatchCog + + +async def setup(bot: Red): + await bot.add_cog(ProfileWatchCog(bot)) diff --git a/profilewatch/info.json b/profilewatch/info.json new file mode 100644 index 00000000..51125778 --- /dev/null +++ b/profilewatch/info.json @@ -0,0 +1,15 @@ +{ + "author": [ + "portalBlock" + ], + "short": "Monitor profile names for patterns.", + "description": "Utilize regex pattern matching to check new users' names and nicknames.", + "disabled": false, + "name": "profilewatch", + "tags": [ + "utility", + "mod" + ], + "install_msg": "Usage: `[p]profilewatch add`", + "min_bot_version": "3.5.1" +} \ No newline at end of file diff --git a/owprofile/owprofile.py b/profilewatch/profilewatch.py similarity index 90% rename from owprofile/owprofile.py rename to profilewatch/profilewatch.py index d5f35612..2b60ab4e 100644 --- a/owprofile/owprofile.py +++ b/profilewatch/profilewatch.py @@ -1,4 +1,3 @@ -"""discord red-bot overwatch profile""" import re from datetime import datetime @@ -10,8 +9,8 @@ from redbot.core.utils.menus import menu, prev_page, close_menu, next_page -class OWProfileCog(commands.Cog): - """Overwatch Profile Cog""" +class ProfileWatchCog(commands.Cog): + """ProfileWatch Cog""" def __init__(self, bot: Red): self.bot = bot @@ -67,18 +66,18 @@ async def on_member_join(self, member: discord.Member): # Command groups @checks.admin() - @commands.group(name="owprofile", pass_context=True) - async def _owprofile(self, ctx): + @commands.group(name="profilewatch", aliases=["pw"], pass_context=True) + async def _profilewatch(self, ctx): """Monitor for flagged member name formats""" # Commands - @_owprofile.command(name="add") + @_profilewatch.command(name="add") async def _add(self, ctx, name: str = "", regex: str = "", alert_level: str = "", check_nick: str = "", *, reason: str = ""): """Add/edit member name trigger""" - usage = "Usage: `[p]owprofile add `" + usage = "Usage: `[p]profilewatch add `" usage += "\nNote: Name & regex fields are limited to 1 word (no spaces)." if (not name and not regex and not reason and not alert_level and not check_nick) or \ (alert_level != "HIGH" and alert_level != "LOW") or (check_nick != "YES" and check_nick != "NO"): @@ -93,12 +92,12 @@ async def _add(self, ctx, name: str = "", regex: str = "", alert_level: str = "" } await ctx.send("✅ Matcher rule successfully added!") - @_owprofile.command("list") + @_profilewatch.command("list") async def _list(self, ctx: commands.Context): """List current name triggers""" rules = await self.config.guild(ctx.guild).rules() pages = list(pagify("\n\n".join(self.rule_to_string(rn, r) for rn, r in rules.items()))) - base_embed_options = {"title": "Overwatch Profile Name Rules", "colour": await ctx.embed_colour()} + base_embed_options = {"title": "Profile Watch Name Rules", "colour": await ctx.embed_colour()} embeds = [ discord.Embed(**base_embed_options, description=page).set_footer(text=f"Page {index} of {len(pages)}") for index, page in enumerate(pages, start=1) @@ -110,11 +109,11 @@ async def _list(self, ctx: commands.Context): menu(ctx=ctx, pages=embeds, controls={"⬅️": prev_page, "⏹️": close_menu, "➡️": next_page}, timeout=180.0) ) - @_owprofile.command("delete") + @_profilewatch.command("delete") async def _delete(self, ctx, name: str = ""): """Delete member name trigger""" - usage = "Usage: `[p]owprofile delete `" + usage = "Usage: `[p]profilewatch delete `" if not name: await ctx.send(usage) else: @@ -127,7 +126,7 @@ async def _delete(self, ctx, name: str = ""): else: await ctx.send("Specified matcher rule not found.") - @_owprofile.command("channel") + @_profilewatch.command("channel") async def _channel(self, ctx, channel: discord.TextChannel): """Set the alert channel""" diff --git a/voicewatch/__init__.py b/voicewatch/__init__.py new file mode 100644 index 00000000..73d52055 --- /dev/null +++ b/voicewatch/__init__.py @@ -0,0 +1,7 @@ +from redbot.core.bot import Red + +from .voicewatch import VoiceWatchCog + + +async def setup(bot: Red): + await bot.add_cog(VoiceWatchCog(bot)) diff --git a/voicewatch/info.json b/voicewatch/info.json new file mode 100644 index 00000000..fd4d8dab --- /dev/null +++ b/voicewatch/info.json @@ -0,0 +1,15 @@ +{ + "author": [ + "portalBlock" + ], + "short": "Detect suspicious voice channel activity.", + "description": "Analyzes voice and video activity in applicable channels to detect suspicious actions.", + "disabled": false, + "name": "voicewatch", + "tags": [ + "utility", + "mod" + ], + "install_msg": "Usage: `[p]voicewatch add`", + "min_bot_version": "3.5.1" +} \ No newline at end of file diff --git a/owvoice/owvoice.py b/voicewatch/voicewatch.py similarity index 93% rename from owvoice/owvoice.py rename to voicewatch/voicewatch.py index 4b5403d1..a45d12a0 100644 --- a/owvoice/owvoice.py +++ b/voicewatch/voicewatch.py @@ -7,8 +7,8 @@ from redbot.core import commands -class OWVoiceCog(commands.Cog): - """Overwatch Voice Cog""" +class VoiceWatchCog(commands.Cog): + """VoiceWatch Cog""" def __init__(self, bot: Red): self.bot = bot @@ -22,11 +22,11 @@ def __init__(self, bot: Red): self.config.register_guild(**default_guild_config) @checks.admin() - @commands.group("owvoice", pass_context=True) - async def _owvoice(self, ctx: commands.Context): + @commands.group("voicewatch", aliases=["vw"], pass_context=True) + async def _voicewatch(self, ctx: commands.Context): pass - @_owvoice.command(name="time") + @_voicewatch.command(name="time") async def _time(self, ctx: commands.Context, hours: str): """Set/update the minimum hours users must be in the server before without triggering an alert.""" try: @@ -36,7 +36,7 @@ async def _time(self, ctx: commands.Context, hours: str): except ValueError: await ctx.send("Error: Non-number hour argument supplied.") - @_owvoice.command(name="logchannel") + @_voicewatch.command(name="logchannel") async def _logchannel(self, ctx: commands.Context, channel: Optional[discord.TextChannel]): """Set/update the channel to send voice activity alerts to.""" From 5ae3dc5709c511b298a94d42893094bf834d0995 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Sat, 20 May 2023 10:00:21 -0500 Subject: [PATCH 21/36] Standardize the command to set log channels. --- profilewatch/profilewatch.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/profilewatch/profilewatch.py b/profilewatch/profilewatch.py index 2b60ab4e..fa58bbc0 100644 --- a/profilewatch/profilewatch.py +++ b/profilewatch/profilewatch.py @@ -1,5 +1,6 @@ import re from datetime import datetime +from typing import Optional import discord import discord.utils @@ -126,12 +127,15 @@ async def _delete(self, ctx, name: str = ""): else: await ctx.send("Specified matcher rule not found.") - @_profilewatch.command("channel") - async def _channel(self, ctx, channel: discord.TextChannel): - """Set the alert channel""" + @_profilewatch.command(name="logchannel") + async def _logchannel(self, ctx: commands.Context, channel: Optional[discord.TextChannel]): + """Set/update the channel to send profile violation alerts to.""" - await self.config.guild(ctx.guild).logchannel.set(channel.id) - await ctx.send("Alert channel set to current channel!") + chanId = ctx.channel.id + if channel: + chanId = channel.id + await self.config.guild(ctx.guild).logchannel.set(chanId) + await ctx.send("✅ Alert channel successfully updated!") def make_alert_embed(self, member: discord.Member, rule: str, matcher) -> discord.Embed: """Construct the alert embed to be sent""" From 688679185f8b6b9d7a035571897503385db1c0e0 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Sat, 20 May 2023 10:34:28 -0500 Subject: [PATCH 22/36] Add the base for MessageWatch - config only, no logic. --- messagewatch/__init__.py | 7 +++ messagewatch/info.json | 15 ++++++ messagewatch/messagewatch.py | 99 ++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 messagewatch/__init__.py create mode 100644 messagewatch/info.json create mode 100644 messagewatch/messagewatch.py diff --git a/messagewatch/__init__.py b/messagewatch/__init__.py new file mode 100644 index 00000000..2827aa71 --- /dev/null +++ b/messagewatch/__init__.py @@ -0,0 +1,7 @@ +from redbot.core.bot import Red + +from .messagewatch import MessageWatchCog + + +async def setup(bot: Red): + await bot.add_cog(MessageWatchCog(bot)) diff --git a/messagewatch/info.json b/messagewatch/info.json new file mode 100644 index 00000000..64fece8b --- /dev/null +++ b/messagewatch/info.json @@ -0,0 +1,15 @@ +{ + "author": [ + "portalBlock" + ], + "short": "Monitor the frequency of messages and embeds.", + "description": "Analyzes message and embed activity in channels to detect suspicious actions.", + "disabled": false, + "name": "messagewatch", + "tags": [ + "utility", + "mod" + ], + "install_msg": "Usage: `[p]messagewatch add`", + "min_bot_version": "3.5.1" +} \ No newline at end of file diff --git a/messagewatch/messagewatch.py b/messagewatch/messagewatch.py new file mode 100644 index 00000000..5d7f9870 --- /dev/null +++ b/messagewatch/messagewatch.py @@ -0,0 +1,99 @@ +from typing import Optional + +import discord +from redbot.core import Config, checks +from redbot.core.bot import Red +from redbot.core import commands +from redbot.core.utils.mod import is_mod_or_superior + + +class MessageWatchCog(commands.Cog): + """MessageWatch Cog""" + + def __init__(self, bot: Red): + self.bot = bot + self.config = Config.get_conf(self, identifier=128986274420752384004) + self.alertCache = {} + default_guild_config = { + "logchannel": "", # Channel to send alerts to + "recent_fetch_time": 15000, # Time, in milliseconds, to fetch recent prior messages used for calculations. + "frequencies": { # Collection of allowable frequencies + "embed": 1 # Allowable frequency for embeds + }, + "exemptions": { + "member_duration": 30, # Minimum member joined duration required to qualify for any exemptions + "text_messages": 1, # Minimum text-only message frequency required to exempt a user + } + } + + self.config.register_guild(**default_guild_config) + + @checks.admin() + @commands.group("messagewatch", aliases=["mw"], pass_context=True) + async def _messagewatch(self, ctx: commands.Context): + pass + + @_messagewatch.command(name="logchannel") + async def _logchannel(self, ctx: commands.Context, channel: Optional[discord.TextChannel]): + """Set/update the channel to send message activity alerts to.""" + + chanId = ctx.channel.id + if channel: + chanId = channel.id + await self.config.guild(ctx.guild).logchannel.set(chanId) + await ctx.send("✅ Alert channel successfully updated!") + + @_messagewatch.command(name="fetchtime") + async def _fetch_time(self, ctx: commands.Context, time: str): + """Set/update the recent message fetch time (in milliseconds).""" + try: + val = float(time) + await self.config.guild(ctx.guild).recent_fetch_time.set(val) + await ctx.send("Recent message fetch time successfully updated!") + except ValueError: + await ctx.send("Recent message fetch time FAILED to update. Please specify a `float` value only!") + + @_messagewatch.group("frequencies", aliases=["freq", "freqs"]) + async def _messagewatch_frequencies(self, ctx: commands.Context): + pass + + @_messagewatch_frequencies.command(name="embed") + async def _fetch_time(self, ctx: commands.Context, frequency: str): + """Set/update the allowable embed frequency.""" + try: + val = float(frequency) + await self.config.guild(ctx.guild).frequencies.embed.set(val) + await ctx.send("Allowable embed frequency successfully updated!") + except ValueError: + await ctx.send("Allowable embed frequency FAILED to update. Please specify a `float` value only!") + + @_messagewatch.group("exemptions", aliases=["exempt", "exempts"]) + async def _messagewatch_exemptions(self, ctx: commands.Context): + pass + + @_messagewatch_exemptions.command(name="member_duration", aliases="md") + async def _fetch_time(self, ctx: commands.Context, time: str): + """Set/update the minimum member duration, in hours, to qualify for exemptions.""" + try: + val = int(time) + await self.config.guild(ctx.guild).exemptions.member_duration.set(val) + await ctx.send("Minimum member duration successfully updated!") + except ValueError: + await ctx.send("Minimum member duration FAILED to update. Please specify a `integer` value only!") + + @_messagewatch_exemptions.command(name="text_messages", aliases="text") + async def _fetch_time(self, ctx: commands.Context, frequency: str): + """Set/update the minimum frequency of text-only messages to be exempt.""" + try: + val = float(frequency) + await self.config.guild(ctx.guild).exemptions.text_messages.set(val) + await ctx.send("Text-only message frequency exemption successfully updated!") + except ValueError: + await ctx.send("Text-only message frequency exemption FAILED to update. Please specify a `float` value " + "only!") + + @commands.Cog.listener() + async def on_message(self, ctx: commands.Context, message: discord.Message): + if is_mod_or_superior(self.bot, message): # Automatically exempt mods/admin + return + pass From f3416a6031675a065378614940d70450383465c8 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Sat, 20 May 2023 15:44:45 -0500 Subject: [PATCH 23/36] Complete most of the MessageWatch implementation. Entirely untested. --- messagewatch/messagewatch.py | 95 ++++++++++++++++++++++++++++++++++-- 1 file changed, 91 insertions(+), 4 deletions(-) diff --git a/messagewatch/messagewatch.py b/messagewatch/messagewatch.py index 5d7f9870..000118c4 100644 --- a/messagewatch/messagewatch.py +++ b/messagewatch/messagewatch.py @@ -1,4 +1,5 @@ -from typing import Optional +from datetime import datetime, timedelta +from typing import Optional, List import discord from redbot.core import Config, checks @@ -13,10 +14,10 @@ class MessageWatchCog(commands.Cog): def __init__(self, bot: Red): self.bot = bot self.config = Config.get_conf(self, identifier=128986274420752384004) - self.alertCache = {} + self.embed_speeds = {} default_guild_config = { "logchannel": "", # Channel to send alerts to - "recent_fetch_time": 15000, # Time, in milliseconds, to fetch recent prior messages used for calculations. + "recent_fetch_time": 15000, # Time in milliseconds to fetch recent prior embed times used for calculations. "frequencies": { # Collection of allowable frequencies "embed": 1 # Allowable frequency for embeds }, @@ -96,4 +97,90 @@ async def _fetch_time(self, ctx: commands.Context, frequency: str): async def on_message(self, ctx: commands.Context, message: discord.Message): if is_mod_or_superior(self.bot, message): # Automatically exempt mods/admin return - pass + for i in range(len(message.attachments)): + self.add_embed_time(message.guild, message.author, datetime.utcnow()) # TODO: Use message timestamp + for i in range(len(message.embeds)): + self.add_embed_time(message.guild, message.author, datetime.utcnow()) # TODO: Use message timestamp + self.analyze_speed(ctx, message) + + @commands.Cog.listener() + async def on_message_edit(self, ctx: commands.Context, before: discord.Message, after: discord.Message): + if is_mod_or_superior(self.bot, before): # Automatically exempt mods/admins + return + total_increase = len(after.attachments) - len(before.attachments) + total_increase += len(after.embeds) - len(before.attachments) + if total_increase > 0: + for i in range(total_increase): + self.add_embed_time(ctx.guild, # Use the ctx guild because edits are inconsistent, TODO: Message time + after.author if after.author is not None else before.author, datetime.utcnow()) + self.analyze_speed(ctx, after) + + def get_embed_times(self, guild: discord.Guild, user: discord.User) -> List[datetime]: + if guild.id not in self.embed_speeds: + self.embed_speeds[guild.id] = [] + if user.id not in self.embed_speeds[guild.id]: + self.embed_speeds[guild.id][user.id] = [] + return self.embed_speeds[guild.id][user.id] + + def add_embed_time(self, guild: discord.Guild, user: discord.User, time: datetime): + self.get_embed_times(guild, user) # Call to get the times to build the user's cache if not already exists + self.embed_speeds[guild.id][user.id].append(time) + + def get_recent_embed_times(self, guild: discord.Guild, user: discord.User) -> List[datetime]: + filter_time = datetime.utcnow() - timedelta(milliseconds=await self.config.guild(guild).recent_fetch_time()) + return [time for time in self.get_embed_times(guild, user) if time >= filter_time] + + def analyze_speed(self, ctx: commands.Context, trigger: discord.Message): + """Analyzes the frequency of embeds & attachments by a user. Should only be called upon message create/edit.""" + embed_times = self.get_recent_embed_times(ctx.guild, trigger.author) + if len(embed_times) < 2: + return # Return because we don't have enough data to calculate the frequency + first_time = embed_times[0] + last_time = embed_times[len(embed_times) - 1] + embed_frequency = len(embed_times) / (last_time - first_time).microseconds # may need to convert to nano + if embed_frequency > await self.config.guild(ctx.guild).frequencies.embed(): + # Alert triggered, send unless exempt + + # Membership duration exemption + allowable = trigger.author.joined_at + timedelta( + hours=await self.config.guild(ctx.guild).exemptions.member_duration()) + if datetime.utcnow() < allowable: + return + + # Text-only message exemption (aka active participation exemption) + # TODO + + # No exemptions at this point, alert! + # Credit: Taken from report Cog + log_id = await self.config.guild(ctx.guild).logchannel() + log = None + if log_id: + log = ctx.guild.get_channel(log_id) + if not log: + # Failed to get the channel + return + + data = self.make_alert_embed(trigger.author, trigger) + + mod_pings = " ".join( + [i.mention for i in log.members if not i.bot and str(i.status) in ["online", "idle"]]) + if not mod_pings: # If no online/idle mods + mod_pings = " ".join([i.mention for i in log.members if not i.bot]) + + await log.send(content=mod_pings, embed=data) + # End credit + def make_alert_embed(self, member: discord.Member, message: discord.Message) -> discord.Embed: + """Construct the alert embed to be sent""" + # Copied from the report Cog. + return ( + discord.Embed( + colour=discord.Colour.orange(), + description="High frequency of embeds detected from a user." + ) + .set_author(name="Suspicious User Activity", icon_url=member.avatar.url) + .add_field(name="Server", value=member.guild.name) + .add_field(name="User", value=member.mention) + .add_field(name="Message", + value=f"https://discord.com/channels/{message.guild.id}/{message.channel.id}/{message.id}") + .add_field(name="Timestamp", value=f"") + ) \ No newline at end of file From 1d2f20a06376efd5e1d191cef456416c23fe84b4 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Sat, 20 May 2023 18:48:41 -0500 Subject: [PATCH 24/36] Make things async. --- messagewatch/messagewatch.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/messagewatch/messagewatch.py b/messagewatch/messagewatch.py index 000118c4..e8b279b0 100644 --- a/messagewatch/messagewatch.py +++ b/messagewatch/messagewatch.py @@ -98,10 +98,10 @@ async def on_message(self, ctx: commands.Context, message: discord.Message): if is_mod_or_superior(self.bot, message): # Automatically exempt mods/admin return for i in range(len(message.attachments)): - self.add_embed_time(message.guild, message.author, datetime.utcnow()) # TODO: Use message timestamp + await self.add_embed_time(message.guild, message.author, datetime.utcnow()) # TODO: Use message timestamp for i in range(len(message.embeds)): - self.add_embed_time(message.guild, message.author, datetime.utcnow()) # TODO: Use message timestamp - self.analyze_speed(ctx, message) + await self.add_embed_time(message.guild, message.author, datetime.utcnow()) # TODO: Use message timestamp + await self.analyze_speed(ctx, message) @commands.Cog.listener() async def on_message_edit(self, ctx: commands.Context, before: discord.Message, after: discord.Message): @@ -111,28 +111,28 @@ async def on_message_edit(self, ctx: commands.Context, before: discord.Message, total_increase += len(after.embeds) - len(before.attachments) if total_increase > 0: for i in range(total_increase): - self.add_embed_time(ctx.guild, # Use the ctx guild because edits are inconsistent, TODO: Message time + await self.add_embed_time(ctx.guild, # Use the ctx guild because edits are inconsistent, TODO: Message time after.author if after.author is not None else before.author, datetime.utcnow()) - self.analyze_speed(ctx, after) + await self.analyze_speed(ctx, after) - def get_embed_times(self, guild: discord.Guild, user: discord.User) -> List[datetime]: + async def get_embed_times(self, guild: discord.Guild, user: discord.User) -> List[datetime]: if guild.id not in self.embed_speeds: self.embed_speeds[guild.id] = [] if user.id not in self.embed_speeds[guild.id]: self.embed_speeds[guild.id][user.id] = [] return self.embed_speeds[guild.id][user.id] - def add_embed_time(self, guild: discord.Guild, user: discord.User, time: datetime): - self.get_embed_times(guild, user) # Call to get the times to build the user's cache if not already exists + async def add_embed_time(self, guild: discord.Guild, user: discord.User, time: datetime): + await self.get_embed_times(guild, user) # Call to get the times to build the user's cache if not already exists self.embed_speeds[guild.id][user.id].append(time) - def get_recent_embed_times(self, guild: discord.Guild, user: discord.User) -> List[datetime]: + async def get_recent_embed_times(self, guild: discord.Guild, user: discord.User) -> List[datetime]: filter_time = datetime.utcnow() - timedelta(milliseconds=await self.config.guild(guild).recent_fetch_time()) - return [time for time in self.get_embed_times(guild, user) if time >= filter_time] + return [time for time in await self.get_embed_times(guild, user) if time >= filter_time] - def analyze_speed(self, ctx: commands.Context, trigger: discord.Message): + async def analyze_speed(self, ctx: commands.Context, trigger: discord.Message): """Analyzes the frequency of embeds & attachments by a user. Should only be called upon message create/edit.""" - embed_times = self.get_recent_embed_times(ctx.guild, trigger.author) + embed_times = await self.get_recent_embed_times(ctx.guild, trigger.author) if len(embed_times) < 2: return # Return because we don't have enough data to calculate the frequency first_time = embed_times[0] From ccaaa1aa77485d7867518dec65026cb9dc99a915 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Sat, 20 May 2023 18:52:16 -0500 Subject: [PATCH 25/36] Typo --- messagewatch/messagewatch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/messagewatch/messagewatch.py b/messagewatch/messagewatch.py index e8b279b0..dfe0aa09 100644 --- a/messagewatch/messagewatch.py +++ b/messagewatch/messagewatch.py @@ -72,7 +72,7 @@ async def _fetch_time(self, ctx: commands.Context, frequency: str): async def _messagewatch_exemptions(self, ctx: commands.Context): pass - @_messagewatch_exemptions.command(name="member_duration", aliases="md") + @_messagewatch_exemptions.command(name="member_duration", aliases=["md"]) async def _fetch_time(self, ctx: commands.Context, time: str): """Set/update the minimum member duration, in hours, to qualify for exemptions.""" try: @@ -82,7 +82,7 @@ async def _fetch_time(self, ctx: commands.Context, time: str): except ValueError: await ctx.send("Minimum member duration FAILED to update. Please specify a `integer` value only!") - @_messagewatch_exemptions.command(name="text_messages", aliases="text") + @_messagewatch_exemptions.command(name="text_messages", aliases=["text"]) async def _fetch_time(self, ctx: commands.Context, frequency: str): """Set/update the minimum frequency of text-only messages to be exempt.""" try: From 3c3bb2372a256d4e2c4a039632bf3522a0417b06 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Sat, 20 May 2023 19:00:28 -0500 Subject: [PATCH 26/36] Change context, update/standardize naming. --- messagewatch/messagewatch.py | 40 ++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/messagewatch/messagewatch.py b/messagewatch/messagewatch.py index dfe0aa09..65da00b3 100644 --- a/messagewatch/messagewatch.py +++ b/messagewatch/messagewatch.py @@ -35,7 +35,7 @@ async def _messagewatch(self, ctx: commands.Context): pass @_messagewatch.command(name="logchannel") - async def _logchannel(self, ctx: commands.Context, channel: Optional[discord.TextChannel]): + async def _messagewatch_logchannel(self, ctx: commands.Context, channel: Optional[discord.TextChannel]): """Set/update the channel to send message activity alerts to.""" chanId = ctx.channel.id @@ -45,7 +45,7 @@ async def _logchannel(self, ctx: commands.Context, channel: Optional[discord.Tex await ctx.send("✅ Alert channel successfully updated!") @_messagewatch.command(name="fetchtime") - async def _fetch_time(self, ctx: commands.Context, time: str): + async def _messagewatch_fetchtime(self, ctx: commands.Context, time: str): """Set/update the recent message fetch time (in milliseconds).""" try: val = float(time) @@ -54,12 +54,12 @@ async def _fetch_time(self, ctx: commands.Context, time: str): except ValueError: await ctx.send("Recent message fetch time FAILED to update. Please specify a `float` value only!") - @_messagewatch.group("frequencies", aliases=["freq", "freqs"]) + @_messagewatch.group("frequencies", aliases=["freq", "freqs"], pass_context=True) async def _messagewatch_frequencies(self, ctx: commands.Context): pass @_messagewatch_frequencies.command(name="embed") - async def _fetch_time(self, ctx: commands.Context, frequency: str): + async def _messagewatch_frequencies_embed(self, ctx: commands.Context, frequency: str): """Set/update the allowable embed frequency.""" try: val = float(frequency) @@ -68,12 +68,12 @@ async def _fetch_time(self, ctx: commands.Context, frequency: str): except ValueError: await ctx.send("Allowable embed frequency FAILED to update. Please specify a `float` value only!") - @_messagewatch.group("exemptions", aliases=["exempt", "exempts"]) + @_messagewatch.group("exemptions", aliases=["exempt", "exempts"], pass_context=True) async def _messagewatch_exemptions(self, ctx: commands.Context): pass - @_messagewatch_exemptions.command(name="member_duration", aliases=["md"]) - async def _fetch_time(self, ctx: commands.Context, time: str): + @_messagewatch_exemptions.command(name="memberduration", aliases=["md"]) + async def _messagewatch_exemptions_memberduration(self, ctx: commands.Context, time: str): """Set/update the minimum member duration, in hours, to qualify for exemptions.""" try: val = int(time) @@ -82,8 +82,8 @@ async def _fetch_time(self, ctx: commands.Context, time: str): except ValueError: await ctx.send("Minimum member duration FAILED to update. Please specify a `integer` value only!") - @_messagewatch_exemptions.command(name="text_messages", aliases=["text"]) - async def _fetch_time(self, ctx: commands.Context, frequency: str): + @_messagewatch_exemptions.command(name="textmessages", aliases=["text"]) + async def _messagewatch_expemptions_textmessages(self, ctx: commands.Context, frequency: str): """Set/update the minimum frequency of text-only messages to be exempt.""" try: val = float(frequency) @@ -94,26 +94,26 @@ async def _fetch_time(self, ctx: commands.Context, frequency: str): "only!") @commands.Cog.listener() - async def on_message(self, ctx: commands.Context, message: discord.Message): + async def on_message(self, message: discord.Message): if is_mod_or_superior(self.bot, message): # Automatically exempt mods/admin return for i in range(len(message.attachments)): await self.add_embed_time(message.guild, message.author, datetime.utcnow()) # TODO: Use message timestamp for i in range(len(message.embeds)): await self.add_embed_time(message.guild, message.author, datetime.utcnow()) # TODO: Use message timestamp - await self.analyze_speed(ctx, message) + await self.analyze_speed(message.guild, message) @commands.Cog.listener() - async def on_message_edit(self, ctx: commands.Context, before: discord.Message, after: discord.Message): + async def on_message_edit(self, before: discord.Message, after: discord.Message): if is_mod_or_superior(self.bot, before): # Automatically exempt mods/admins return total_increase = len(after.attachments) - len(before.attachments) total_increase += len(after.embeds) - len(before.attachments) if total_increase > 0: for i in range(total_increase): - await self.add_embed_time(ctx.guild, # Use the ctx guild because edits are inconsistent, TODO: Message time + await self.add_embed_time(after.guild, # Use the ctx guild because edits are inconsistent, TODO: Message time after.author if after.author is not None else before.author, datetime.utcnow()) - await self.analyze_speed(ctx, after) + await self.analyze_speed(after.guild, after) async def get_embed_times(self, guild: discord.Guild, user: discord.User) -> List[datetime]: if guild.id not in self.embed_speeds: @@ -130,20 +130,20 @@ async def get_recent_embed_times(self, guild: discord.Guild, user: discord.User) filter_time = datetime.utcnow() - timedelta(milliseconds=await self.config.guild(guild).recent_fetch_time()) return [time for time in await self.get_embed_times(guild, user) if time >= filter_time] - async def analyze_speed(self, ctx: commands.Context, trigger: discord.Message): + async def analyze_speed(self, guild: discord.Guild, trigger: discord.Message): """Analyzes the frequency of embeds & attachments by a user. Should only be called upon message create/edit.""" - embed_times = await self.get_recent_embed_times(ctx.guild, trigger.author) + embed_times = await self.get_recent_embed_times(guild, trigger.author) if len(embed_times) < 2: return # Return because we don't have enough data to calculate the frequency first_time = embed_times[0] last_time = embed_times[len(embed_times) - 1] embed_frequency = len(embed_times) / (last_time - first_time).microseconds # may need to convert to nano - if embed_frequency > await self.config.guild(ctx.guild).frequencies.embed(): + if embed_frequency > await self.config.guild(guild).frequencies.embed(): # Alert triggered, send unless exempt # Membership duration exemption allowable = trigger.author.joined_at + timedelta( - hours=await self.config.guild(ctx.guild).exemptions.member_duration()) + hours=await self.config.guild(guild).exemptions.member_duration()) if datetime.utcnow() < allowable: return @@ -152,10 +152,10 @@ async def analyze_speed(self, ctx: commands.Context, trigger: discord.Message): # No exemptions at this point, alert! # Credit: Taken from report Cog - log_id = await self.config.guild(ctx.guild).logchannel() + log_id = await self.config.guild(guild).logchannel() log = None if log_id: - log = ctx.guild.get_channel(log_id) + log = guild.get_channel(log_id) if not log: # Failed to get the channel return From d0385dbd9d80d1747fdbd233965d348831317e61 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Sat, 20 May 2023 19:08:16 -0500 Subject: [PATCH 27/36] For to await --- messagewatch/messagewatch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/messagewatch/messagewatch.py b/messagewatch/messagewatch.py index 65da00b3..e74f256a 100644 --- a/messagewatch/messagewatch.py +++ b/messagewatch/messagewatch.py @@ -95,7 +95,7 @@ async def _messagewatch_expemptions_textmessages(self, ctx: commands.Context, fr @commands.Cog.listener() async def on_message(self, message: discord.Message): - if is_mod_or_superior(self.bot, message): # Automatically exempt mods/admin + if await is_mod_or_superior(self.bot, message): # Automatically exempt mods/admin return for i in range(len(message.attachments)): await self.add_embed_time(message.guild, message.author, datetime.utcnow()) # TODO: Use message timestamp @@ -105,7 +105,7 @@ async def on_message(self, message: discord.Message): @commands.Cog.listener() async def on_message_edit(self, before: discord.Message, after: discord.Message): - if is_mod_or_superior(self.bot, before): # Automatically exempt mods/admins + if await is_mod_or_superior(self.bot, before): # Automatically exempt mods/admins return total_increase = len(after.attachments) - len(before.attachments) total_increase += len(after.embeds) - len(before.attachments) From 7a33dd1ccb1bcf76a5fbd56ffa4f93d602f34e80 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Sat, 20 May 2023 19:11:51 -0500 Subject: [PATCH 28/36] Typo --- messagewatch/messagewatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messagewatch/messagewatch.py b/messagewatch/messagewatch.py index e74f256a..2031f328 100644 --- a/messagewatch/messagewatch.py +++ b/messagewatch/messagewatch.py @@ -117,7 +117,7 @@ async def on_message_edit(self, before: discord.Message, after: discord.Message) async def get_embed_times(self, guild: discord.Guild, user: discord.User) -> List[datetime]: if guild.id not in self.embed_speeds: - self.embed_speeds[guild.id] = [] + self.embed_speeds[guild.id] = {} if user.id not in self.embed_speeds[guild.id]: self.embed_speeds[guild.id][user.id] = [] return self.embed_speeds[guild.id][user.id] From fb2cb4e68a4bd63267c53bfd8b34283af485a6bb Mon Sep 17 00:00:00 2001 From: portalBlock Date: Sun, 21 May 2023 10:14:02 -0500 Subject: [PATCH 29/36] Correct timezone for comparison --- messagewatch/messagewatch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/messagewatch/messagewatch.py b/messagewatch/messagewatch.py index 2031f328..4bab88b9 100644 --- a/messagewatch/messagewatch.py +++ b/messagewatch/messagewatch.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Optional, List import discord @@ -144,7 +144,7 @@ async def analyze_speed(self, guild: discord.Guild, trigger: discord.Message): # Membership duration exemption allowable = trigger.author.joined_at + timedelta( hours=await self.config.guild(guild).exemptions.member_duration()) - if datetime.utcnow() < allowable: + if datetime.now(timezone.utc) < allowable: return # Text-only message exemption (aka active participation exemption) From eecbfe3edf64ce53a9139142fc2a5c1f715b85ab Mon Sep 17 00:00:00 2001 From: portalBlock Date: Sun, 21 May 2023 10:40:46 -0500 Subject: [PATCH 30/36] Correct timezone for comparison --- messagewatch/messagewatch.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/messagewatch/messagewatch.py b/messagewatch/messagewatch.py index 4bab88b9..63301212 100644 --- a/messagewatch/messagewatch.py +++ b/messagewatch/messagewatch.py @@ -132,16 +132,27 @@ async def get_recent_embed_times(self, guild: discord.Guild, user: discord.User) async def analyze_speed(self, guild: discord.Guild, trigger: discord.Message): """Analyzes the frequency of embeds & attachments by a user. Should only be called upon message create/edit.""" + embed_times = await self.get_recent_embed_times(guild, trigger.author) + + # Check if we have enough basic data to calculate the frequency (prevents some config fetches below) if len(embed_times) < 2: - return # Return because we don't have enough data to calculate the frequency + return + + # This is a bit of a hack but check if the total embeds, regardless of times, could exceed the frequency limit + # This is needed because one message with N > 1 embeds and no prior embed times would always trigger. + allowable_embed_frequency = await self.config.guild(guild).frequencies.embed() + fetch_time = await self.config.guild(guild).recent_fetch_time() + if len(embed_times) < allowable_embed_frequency * fetch_time: + return + first_time = embed_times[0] last_time = embed_times[len(embed_times) - 1] - embed_frequency = len(embed_times) / (last_time - first_time).microseconds # may need to convert to nano - if embed_frequency > await self.config.guild(guild).frequencies.embed(): + embed_frequency = len(embed_times) / ((last_time - first_time).microseconds / 1000) # convert to milliseconds + if embed_frequency > allowable_embed_frequency: # Alert triggered, send unless exempt - # Membership duration exemption + # Membership duration exemption TODO: remove this once other logic is bolstered allowable = trigger.author.joined_at + timedelta( hours=await self.config.guild(guild).exemptions.member_duration()) if datetime.now(timezone.utc) < allowable: From 8790e4de5dcb182db4229dcfef42111853ebecce Mon Sep 17 00:00:00 2001 From: portalBlock Date: Sun, 21 May 2023 14:19:15 -0500 Subject: [PATCH 31/36] Update docs for PR. --- README.md | 32 ++++++++++++++++++++++++++++++++ messagewatch/messagewatch.py | 9 ++++----- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d6ee1dad..51fdb0d2 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,11 @@ Cogs for the [RED](https://github.com/Cog-Creators/Red-DiscordBot/)-based [Homel - [Google](#google) - [Latex](#latex) - [Letters](#letters) + - [MessageWatch](#messagewatch) - [Notes](#notes) - [Penis](#penis) - [Phishingdetection](#phishingdetection) + - [ProfileWatch](#profilewatch) - [Purge](#purge) - [Quotes](#quotes) - [Reactrole](#reactrole) @@ -39,6 +41,7 @@ Cogs for the [RED](https://github.com/Cog-Creators/Red-DiscordBot/)-based [Homel - [Sentry](#sentry) - [Timeout](#timeout) - [Verify](#verify) + - [VoiceWatch](#voicewatch) - [xkcd](#xkcd) - [License](#license) - [Contributing](#contributing) @@ -79,8 +82,10 @@ A massive thank you to all who've helped out with this project ❤️ - **[Google](#google):** Send a google link to someone. - **[LaTeX](#latex):** Render a LaTeX statement. - **[Letters](#letters):** Outputs large emote letters/numbers from input text. +- **[MessageWatch](#messagewatch):** Analyzes message components to detect automated activity. - **[Notes](#notes):** Manage notes and warnings against users. - **[Penis](#penis):** Allows users to check the size of their penis. +- **[ProfileWatch](#profilewatch):** Analyzes profile data and alerts upon recognized patterns. - **[Purge](#purge):** This will purge users based on criteria. - **[Quotes](#quotes):** Allows users to quote other users' messages in a quotes channel. - **[Reactrole](#reactrole):** Allows roles to be applied and removed using reactions. @@ -89,6 +94,7 @@ A massive thank you to all who've helped out with this project ❤️ - **[Sentry](#sentry):** Send unhandled errors to sentry. - **[Timeout](#timeout):** Manage users' timeout status. - **[Verify](#verify):** Allows users to verify themselves. +- **[VoiceWatch](#voicewatch):** Analyzes voice channel activity and alerts upon suspicious patterns. - **[xkcd](#xkcd):** Allows users to look at xkcd comics. ## Cog Documentation @@ -187,6 +193,16 @@ This cog converts a string of letters/numbers into large emote letters ("regiona `[p]letters I would like this text as emotes 123` `[p]letters -raw I would like this text as raw emote code 123` +### MessageWatch + +Analyzes message components to detect automated activity. + +- `[p]messagewatch logchannel [channel]` - Set the log output channel to the current (or specified) channel. +- `[p]messagewatch fetchtime ` - Set the recent message timeframe used in calculations. +- `[p]messagewatch frequencies embed ` - Set the maximum allowable frequency of embeds/attachments. +- `[p]messagewatch exemptions memberduration ` - Set the minimum membership duration to qualify for exemptions. +- `[p]messagewatch exemptions textmessages ` - Set the minimum text-only frequency for participation exemptions. + ### Notes Manage notes and warnings against users. @@ -213,6 +229,15 @@ This cog allows users to check the size of their penis. This cog automatically deletes any messages containing suspected phishing/scam links. This information is sourced from [phish.sinking.yachts](https://phish.sinking.yachts/) +### ProfileWatch + +Analyzes profile data and alerts upon recognized name/nickname patterns. + +- `[p]profilewatch logchannel [channel]` - Set the log output channel to the current (or specified) channel. +- `[p]profilewatch add ` - Add/edit a rule with the specified name. Pattern is a python-style regex. +- `[p]profilewatch list` - List the current rules. +- `[p]profilewatch delete ` - Delete the specified rule. + ### Purge This cog will purge users that hold no roles as a way to combat accounts being created and left in an un-verified state. @@ -297,6 +322,13 @@ This cog will allow users to prove they're not a bot by having to read rules and Further configuration options can be seen with `[p]verify help` +### VoiceWatch + +Analyzes voice channel activity and alerts upon suspicious patterns. + +- `[p]voicewatch logchannel [channel]` - Set the log output channel to the current (or specified) channel. +- `[p]voicewatch time ` - Set/update the minimum hours users must be in the server before without triggering an alert. + ### xkcd This cog allows users to look at xkcd comics diff --git a/messagewatch/messagewatch.py b/messagewatch/messagewatch.py index 63301212..f9ba2d3b 100644 --- a/messagewatch/messagewatch.py +++ b/messagewatch/messagewatch.py @@ -152,15 +152,14 @@ async def analyze_speed(self, guild: discord.Guild, trigger: discord.Message): if embed_frequency > allowable_embed_frequency: # Alert triggered, send unless exempt - # Membership duration exemption TODO: remove this once other logic is bolstered + # Membership duration exemption allowable = trigger.author.joined_at + timedelta( hours=await self.config.guild(guild).exemptions.member_duration()) - if datetime.now(timezone.utc) < allowable: + if datetime.now(timezone.utc) < allowable: # Todo: this isn't supposed to exempt them, just allow exempts + # Text-only message exemption (aka active participation exemption) + # TODO return - # Text-only message exemption (aka active participation exemption) - # TODO - # No exemptions at this point, alert! # Credit: Taken from report Cog log_id = await self.config.guild(guild).logchannel() From b06dd81337f05e0351a2ee6e877dde207cf2fdb5 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Mon, 16 Oct 2023 11:30:03 -0500 Subject: [PATCH 32/36] Meld all watcher cogs into one large watcher cog --- watcher/__init__.py | 7 + watcher/info.json | 15 ++ watcher/watcher.py | 398 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 420 insertions(+) create mode 100644 watcher/__init__.py create mode 100644 watcher/info.json create mode 100644 watcher/watcher.py diff --git a/watcher/__init__.py b/watcher/__init__.py new file mode 100644 index 00000000..2324819c --- /dev/null +++ b/watcher/__init__.py @@ -0,0 +1,7 @@ +from redbot.core.bot import Red + +from .watcher import WatcherCog + + +async def setup(bot: Red): + await bot.add_cog(WatcherCog(bot)) diff --git a/watcher/info.json b/watcher/info.json new file mode 100644 index 00000000..8b5ded33 --- /dev/null +++ b/watcher/info.json @@ -0,0 +1,15 @@ +{ + "author": [ + "portalBlock" + ], + "short": "Detect suspicious user activity.", + "description": "Analyzes voice, text, and profile activity to detect suspicious actions.", + "disabled": false, + "name": "watcher", + "tags": [ + "utility", + "mod" + ], + "install_msg": "Usage: `[p]watcher help`", + "min_bot_version": "3.5.1" +} \ No newline at end of file diff --git a/watcher/watcher.py b/watcher/watcher.py new file mode 100644 index 00000000..e6d53b00 --- /dev/null +++ b/watcher/watcher.py @@ -0,0 +1,398 @@ +from datetime import timedelta, timezone, datetime +from typing import Optional, List + +import discord +import re +from redbot.core import Config, checks +from redbot.core.bot import Red +from redbot.core.commands import commands +from redbot.core.utils.chat_formatting import pagify, escape +from redbot.core.utils.menus import menu, prev_page, close_menu, next_page +from redbot.core.utils.mod import is_mod_or_superior + + +class WatcherCog(commands.Cog): + """Watcher Cog""" + + def __init__(self, bot: Red): + self.bot = bot + self.config = Config.get_conf(self, identifier=128986274420752384001) + self.embed_speeds = {} + self.alert_cache = {} + default_guild_config = { + "logchannel": "", # Channel to send alerts to + "messagewatcher": { + "recent_fetch_time": 15000, # Time (milliseconds) to fetch recent embed times used for calculations. + "frequencies": { # Collection of allowable frequencies + "embed": 1 # Allowable frequency for embeds + }, + "exemptions": { + "member_duration": 30, # Minimum member joined duration required to qualify for any exemptions + "text_messages": 1, # Minimum text-only message frequency required to exempt a user + } + }, + "voicewatcher": { + "min_joined_hours": 8760 # Default to one year so the mods set it up :) + }, + "profilewatcher": { + "rules": { + "spammer1": { # Name of rule + "pattern": "^portalBlock$", # Regex pattern to match against + "check_nick": False, # Whether the user's nickname should be checked too + "alert_level": "HIGH", # Severity of alerts (use: HIGH or LOW) + "reason": "Impostor :sus:" # Reason for the match, used to add context to alerts + } + } + } + } + + self.config.register_guild(**default_guild_config) + + @checks.admin() + @commands.group("watcher", pass_context=True) + async def _watcher(self, ctx: commands.Context): + pass + + @_watcher.command(name="logchannel") + async def _logchannel(self, ctx: commands.Context, channel: Optional[discord.TextChannel]): + """Set/update the channel to send activity alerts to.""" + + chanId = ctx.channel.id + if channel: + chanId = channel.id + await self.config.guild(ctx.guild).logchannel.set(chanId) + await ctx.send("✅ Alert channel successfully updated!") + + @_watcher.group("voicewatch", aliases=["vw"], pass_context=True) + async def _voicewatch(self, ctx: commands.Context): + pass + + @_voicewatch.command(name="time") + async def _voicewatch_time(self, ctx: commands.Context, hours: str): + """Set/update the minimum hours users must be in the server before without triggering an alert.""" + try: + hrs = int(hours) + await self.config.guild(ctx.guild).voicewatcher.min_joined_hours.set(hrs) + await ctx.send("✅ Time requirement successfully updated!") + except ValueError: + await ctx.send("Error: Non-number hour argument supplied.") + + @_watcher.group(name="profilewatch", aliases=["pw"], pass_context=True) + async def _profilewatch(self, ctx): + """Monitor for flagged member name formats""" + pass + + @_profilewatch.command(name="add") + async def _profilewatch_add(self, ctx, name: str = "", regex: str = "", alert_level: str = "", + check_nick: str = "", *, reason: str = ""): + """Add/edit member name trigger""" + + usage = "Usage: `[p]profilewatch add `" + usage += "\nNote: Name & regex fields are limited to 1 word (no spaces)." + if (not name and not regex and not reason and not alert_level and not check_nick) or \ + (alert_level != "HIGH" and alert_level != "LOW") or (check_nick != "YES" and check_nick != "NO"): + await ctx.send(usage) + else: + async with self.config.guild(ctx.guild).profilewatcher.rules() as rules: + rules[name] = { + "pattern": regex, + "check_nick": True if check_nick == "YES" else False, + "alert_level": alert_level, + "reason": reason + } + await ctx.send("✅ Matcher rule successfully added!") + + @_profilewatch.command("list") + async def _profilewatch_list(self, ctx: commands.Context): + """List current name triggers""" + rules = await self.config.guild(ctx.guild).profilewatcher.rules() + pages = list(pagify("\n\n".join(WatcherCog.profile_rule_to_string(rn, r) for rn, r in rules.items()))) + base_embed_options = {"title": "Profile Watch Name Rules", "colour": await ctx.embed_colour()} + embeds = [ + discord.Embed(**base_embed_options, description=page).set_footer(text=f"Page {index} of {len(pages)}") + for index, page in enumerate(pages, start=1) + ] + if len(embeds) == 1: + await ctx.send(embed=embeds[0]) + else: + ctx.bot.loop.create_task( + menu(ctx=ctx, pages=embeds, controls={"⬅️": prev_page, "⏹️": close_menu, "➡️": next_page}, + timeout=180.0) + ) + + @_profilewatch.command("delete") + async def _profilewatch_delete(self, ctx, name: str = ""): + """Delete member name trigger""" + + usage = "Usage: `[p]profilewatch delete `" + if not name: + await ctx.send(usage) + else: + async with self.config.guild(ctx.guild).profilewatcher.rules() as rules: + if name in rules: + found = True + del rules[name] + if found: + await ctx.send("Rule deleted!") + else: + await ctx.send("Specified rule not found.") + + @_watcher.group("messagewatch", aliases=["mw"], pass_context=True) + async def _messagewatch(self, ctx: commands.Context): + pass + + @_messagewatch.command(name="fetchtime") + async def _messagewatch_fetchtime(self, ctx: commands.Context, time: str): + """Set/update the recent message fetch time (in milliseconds).""" + try: + val = float(time) + await self.config.guild(ctx.guild).messagewatcher.recent_fetch_time.set(val) + await ctx.send("Recent message fetch time successfully updated!") + except ValueError: + await ctx.send("Recent message fetch time FAILED to update. Please specify a `float` value only!") + + @_messagewatch.group("frequencies", aliases=["freq", "freqs"], pass_context=True) + async def _messagewatch_frequencies(self, ctx: commands.Context): + pass + + @_messagewatch_frequencies.command(name="embed") + async def _messagewatch_frequencies_embed(self, ctx: commands.Context, frequency: str): + """Set/update the allowable embed frequency.""" + try: + val = float(frequency) + await self.config.guild(ctx.guild).messagewatcher.frequencies.embed.set(val) + await ctx.send("Allowable embed frequency successfully updated!") + except ValueError: + await ctx.send("Allowable embed frequency FAILED to update. Please specify a `float` value only!") + + @_messagewatch.group("exemptions", aliases=["exempt", "exempts"], pass_context=True) + async def _messagewatch_exemptions(self, ctx: commands.Context): + pass + + @_messagewatch_exemptions.command(name="memberduration", aliases=["md"]) + async def _messagewatch_exemptions_memberduration(self, ctx: commands.Context, time: str): + """Set/update the minimum member duration, in hours, to qualify for exemptions.""" + try: + val = int(time) + await self.config.guild(ctx.guild).messagewatcher.exemptions.member_duration.set(val) + await ctx.send("Minimum member duration successfully updated!") + except ValueError: + await ctx.send("Minimum member duration FAILED to update. Please specify a `integer` value only!") + + @_messagewatch_exemptions.command(name="textmessages", aliases=["text"]) + async def _messagewatch_expemptions_textmessages(self, ctx: commands.Context, frequency: str): + """Set/update the minimum frequency of text-only messages to be exempt.""" + try: + val = float(frequency) + await self.config.guild(ctx.guild).messagewatcher.exemptions.text_messages.set(val) + await ctx.send("Text-only message frequency exemption successfully updated!") + except ValueError: + await ctx.send("Text-only message frequency exemption FAILED to update. Please specify a `float` value " + "only!") + + # Helper Functions + @staticmethod + def profile_rule_to_string(rule_name: str, rule) -> str: + return f"**{rule_name}**:\nPattern: `{rule['pattern']}`\nCheck Nick: `{rule['check_nick']}`\nAlert Level: " \ + f"{rule['alert_level']}\nReason: {rule['reason']}" + + def make_voice_alert_embed(self, member: discord.Member, chan: discord.VoiceChannel) -> discord.Embed: + """Construct the alert embed to be sent""" + # Copied from the report Cog. + return ( + discord.Embed( + colour=discord.Colour.orange(), + description="New user joined a voice channel and started streaming or enabled their camera." + ) + .set_author(name="Suspicious User Activity", icon_url=member.avatar.url) + .add_field(name="Server", value=member.guild.name) + .add_field(name="User", value=member.mention) + .add_field(name="Channel", value=chan.mention) + .add_field(name="Timestamp", value=f"") + ) + + def make_profile_alert_embed(self, member: discord.Member, rule: str, matcher) -> discord.Embed: + """Construct the alert embed to be sent""" + # Copied from the report Cog. + return ( + discord.Embed( + colour=discord.Colour.red() if matcher['alert_level'] == "HIGH" else discord.Colour.orange(), + description=escape("Rule: " + rule + "\nReason: "+matcher['reason'] or "") + ) + .set_author(name="Profile Violation Detected", icon_url=member.avatar.url) + .add_field(name="Server", value=member.guild.name) + .add_field(name="Timestamp", value=f"") + ) + + def make_message_alert_embed(self, member: discord.Member, message: discord.Message) -> discord.Embed: + """Construct the alert embed to be sent""" + # Copied from the report Cog. + return ( + discord.Embed( + colour=discord.Colour.orange(), + description="High frequency of embeds detected from a user." + ) + .set_author(name="Suspicious User Activity", icon_url=member.avatar.url) + .add_field(name="Server", value=member.guild.name) + .add_field(name="User", value=member.mention) + .add_field(name="Message", + value=f"https://discord.com/channels/{message.guild.id}/{message.channel.id}/{message.id}") + .add_field(name="Timestamp", value=f"") + ) + + async def get_embed_times(self, guild: discord.Guild, user: discord.User) -> List[datetime]: + if guild.id not in self.embed_speeds: + self.embed_speeds[guild.id] = {} + if user.id not in self.embed_speeds[guild.id]: + self.embed_speeds[guild.id][user.id] = [] + return self.embed_speeds[guild.id][user.id] + + async def add_embed_time(self, guild: discord.Guild, user: discord.User, time: datetime): + await self.get_embed_times(guild, user) # Call to get the times to build the user's cache if not already exists + self.embed_speeds[guild.id][user.id].append(time) + + async def get_recent_embed_times(self, guild: discord.Guild, user: discord.User) -> List[datetime]: + filter_time = datetime.utcnow() - timedelta(milliseconds=await self.config.guild(guild).messagewatcher.recent_fetch_time()) + return [time for time in await self.get_embed_times(guild, user) if time >= filter_time] + + async def analyze_speed(self, guild: discord.Guild, trigger: discord.Message): + """Analyzes the frequency of embeds & attachments by a user. Should only be called upon message create/edit.""" + + embed_times = await self.get_recent_embed_times(guild, trigger.author) + + # Check if we have enough basic data to calculate the frequency (prevents some config fetches below) + if len(embed_times) < 2: + return + + # This is a bit of a hack but check if the total embeds, regardless of times, could exceed the frequency limit + # This is needed because one message with N > 1 embeds and no prior embed times would always trigger. + allowable_embed_frequency = await self.config.guild(guild).messagewatcher.frequencies.embed() + fetch_time = await self.config.guild(guild).messagewatcher.recent_fetch_time() + if len(embed_times) < allowable_embed_frequency * fetch_time: + return + + first_time = embed_times[0] + last_time = embed_times[len(embed_times) - 1] + embed_frequency = len(embed_times) / ((last_time - first_time).microseconds / 1000) # convert to milliseconds + if embed_frequency > allowable_embed_frequency: + # Alert triggered, send unless exempt + + # Membership duration exemption + allowable = trigger.author.joined_at + timedelta( + hours=await self.config.guild(guild).messagewatcher.exemptions.member_duration()) + if datetime.now(timezone.utc) < allowable: # Todo: this isn't supposed to exempt them, just allow exempts + # Text-only message exemption (aka active participation exemption) + # TODO + return + + # No exemptions at this point, alert! + # Credit: Taken from report Cog + log_id = await self.config.guild(guild).logchannel() + log = None + if log_id: + log = guild.get_channel(log_id) + if not log: + # Failed to get the channel + return + + data = self.make_message_alert_embed(trigger.author, trigger) + + mod_pings = " ".join( + [i.mention for i in log.members if not i.bot and str(i.status) in ["online", "idle"]]) + if not mod_pings: # If no online/idle mods + mod_pings = " ".join([i.mention for i in log.members if not i.bot]) + + await log.send(content=mod_pings, embed=data) + # End credit + + # Listeners + @commands.Cog.listener() + async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState): + # Ignore if we've already alerted on this user. Can probably find a better way. + if member.id in self.alert_cache and self.alert_cache[member.id]: + # Todo: Make guild-aware + return + else: + self.alert_cache[member.id] = False + + if after is None or after.channel is None: # Check if we're missing the after data or associated channel. + return + + allowable = member.joined_at + timedelta(hours=await self.config.guild(after.channel.guild).min_joined_hours()) + if datetime.now(timezone.utc) < allowable: + if after.self_stream or after.self_video: + self.alert_cache[member.id] = True # Update the cache to indicate we've alerted on this user. + # Credit: Taken from report Cog + log_id = await self.config.guild(after.channel.guild).logchannel() + log = None + if log_id: + log = member.guild.get_channel(log_id) + if not log: + # Failed to get the channel + return + + data = self.make_voice_alert_embed(member, after.channel) + + # Alert level logic added + mod_pings = " ".join( + [i.mention for i in log.members if not i.bot and str(i.status) in ["online", "idle"]]) + if not mod_pings: # If no online/idle mods + mod_pings = " ".join([i.mention for i in log.members if not i.bot]) + + await log.send(content=mod_pings, embed=data) + # End credit + + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member): + + matcher_list = await self.config.guild(member.guild).profilewatcher.rules() + + for ruleName, rule in matcher_list.items(): + hits = len(re.findall(rule['pattern'], member.name)) + if rule['check_nick'] and member.nick: + hits += len(re.findall(rule['pattern'], member.nick)) + if hits > 0: + + # Credit: Taken from report Cog + log_id = await self.config.guild(member.guild).logchannel() + log = None + if log_id: + log = member.guild.get_channel(log_id) + if not log: + # Failed to get the channel + return + + data = self.make_profile_alert_embed(member, ruleName, rule) + + mod_pings = "" + # Alert level logic added + if rule['alert_level'] == "HIGH": + mod_pings = " ".join( + [i.mention for i in log.members if not i.bot and str(i.status) in ["online", "idle"]]) + if not mod_pings: # If no online/idle mods + mod_pings = " ".join([i.mention for i in log.members if not i.bot]) + + await log.send(content=mod_pings, embed=data) + # End credit + + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + if await is_mod_or_superior(self.bot, message): # Automatically exempt mods/admin + return + for i in range(len(message.attachments)): + await self.add_embed_time(message.guild, message.author, datetime.utcnow()) # TODO: Use message timestamp + for i in range(len(message.embeds)): + await self.add_embed_time(message.guild, message.author, datetime.utcnow()) # TODO: Use message timestamp + await self.analyze_speed(message.guild, message) + + @commands.Cog.listener() + async def on_message_edit(self, before: discord.Message, after: discord.Message): + if await is_mod_or_superior(self.bot, before): # Automatically exempt mods/admins + return + total_increase = len(after.attachments) - len(before.attachments) + total_increase += len(after.embeds) - len(before.attachments) + if total_increase > 0: + for i in range(total_increase): + await self.add_embed_time(after.guild, # Use the ctx guild because edits are inconsistent, TODO: Message time + after.author if after.author is not None else before.author, datetime.utcnow()) + await self.analyze_speed(after.guild, after) \ No newline at end of file From 7541465c0c4887ce55f184f9b5cddf0cd3776b89 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Mon, 16 Oct 2023 11:37:48 -0500 Subject: [PATCH 33/36] Fix imports --- watcher/watcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/watcher/watcher.py b/watcher/watcher.py index e6d53b00..b139bfa3 100644 --- a/watcher/watcher.py +++ b/watcher/watcher.py @@ -5,7 +5,7 @@ import re from redbot.core import Config, checks from redbot.core.bot import Red -from redbot.core.commands import commands +from redbot.core import commands from redbot.core.utils.chat_formatting import pagify, escape from redbot.core.utils.menus import menu, prev_page, close_menu, next_page from redbot.core.utils.mod import is_mod_or_superior From 41b4562201c06ac448de602c9db62f6c95740526 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Thu, 26 Oct 2023 14:00:57 -0500 Subject: [PATCH 34/36] Implement PR feedback, reduce other duplicate code. Untested with substantial changes to alerting and command processing (no logic changes). --- watcher/watcher.py | 135 ++++++++++++++++++--------------------------- 1 file changed, 54 insertions(+), 81 deletions(-) diff --git a/watcher/watcher.py b/watcher/watcher.py index b139bfa3..6b8ce01c 100644 --- a/watcher/watcher.py +++ b/watcher/watcher.py @@ -3,6 +3,8 @@ import discord import re + +from discord.ext.commands import CommandError from redbot.core import Config, checks from redbot.core.bot import Red from redbot.core import commands @@ -11,6 +13,15 @@ from redbot.core.utils.mod import is_mod_or_superior +def custom_float_converter(message: str): + async def convert(ctx: commands.Context, arg: str) -> float: + try: + return float(arg) + except ValueError: + raise CommandError(message) + return convert + + class WatcherCog(commands.Cog): """Watcher Cog""" @@ -84,10 +95,11 @@ async def _profilewatch(self, ctx): @_profilewatch.command(name="add") async def _profilewatch_add(self, ctx, name: str = "", regex: str = "", alert_level: str = "", - check_nick: str = "", *, reason: str = ""): + check_nick: bool = False, *, reason: str = ""): """Add/edit member name trigger""" - usage = "Usage: `[p]profilewatch add `" + usage = "Usage: `[p]profilewatch add " \ + "` " usage += "\nNote: Name & regex fields are limited to 1 word (no spaces)." if (not name and not regex and not reason and not alert_level and not check_nick) or \ (alert_level != "HIGH" and alert_level != "LOW") or (check_nick != "YES" and check_nick != "NO"): @@ -142,28 +154,28 @@ async def _messagewatch(self, ctx: commands.Context): pass @_messagewatch.command(name="fetchtime") - async def _messagewatch_fetchtime(self, ctx: commands.Context, time: str): + async def _messagewatch_fetchtime(self, ctx: commands.Context, time: custom_float_converter("Recent message fetch " + "time FAILED to " + "update. Please " + "specify a `float` " + "value only!")): """Set/update the recent message fetch time (in milliseconds).""" - try: - val = float(time) - await self.config.guild(ctx.guild).messagewatcher.recent_fetch_time.set(val) - await ctx.send("Recent message fetch time successfully updated!") - except ValueError: - await ctx.send("Recent message fetch time FAILED to update. Please specify a `float` value only!") + await self.config.guild(ctx.guild).messagewatcher.recent_fetch_time.set(time) + await ctx.send("Recent message fetch time successfully updated!") @_messagewatch.group("frequencies", aliases=["freq", "freqs"], pass_context=True) async def _messagewatch_frequencies(self, ctx: commands.Context): pass @_messagewatch_frequencies.command(name="embed") - async def _messagewatch_frequencies_embed(self, ctx: commands.Context, frequency: str): + async def _messagewatch_frequencies_embed(self, ctx: commands.Context, + frequency: custom_float_converter("Allowable embed frequency FAILED to " + "update. Please specify a `float` " + "value only!")): """Set/update the allowable embed frequency.""" - try: - val = float(frequency) - await self.config.guild(ctx.guild).messagewatcher.frequencies.embed.set(val) - await ctx.send("Allowable embed frequency successfully updated!") - except ValueError: - await ctx.send("Allowable embed frequency FAILED to update. Please specify a `float` value only!") + + await self.config.guild(ctx.guild).messagewatcher.frequencies.embed.set(frequency) + await ctx.send("Allowable embed frequency successfully updated!") @_messagewatch.group("exemptions", aliases=["exempt", "exempts"], pass_context=True) async def _messagewatch_exemptions(self, ctx: commands.Context): @@ -180,15 +192,14 @@ async def _messagewatch_exemptions_memberduration(self, ctx: commands.Context, t await ctx.send("Minimum member duration FAILED to update. Please specify a `integer` value only!") @_messagewatch_exemptions.command(name="textmessages", aliases=["text"]) - async def _messagewatch_expemptions_textmessages(self, ctx: commands.Context, frequency: str): + async def _messagewatch_expemptions_textmessages(self, ctx: commands.Context, + frequency: custom_float_converter("Text-only message frequency " + "exemption FAILED to update. " + "Please specify a `float` " + "value only!")): """Set/update the minimum frequency of text-only messages to be exempt.""" - try: - val = float(frequency) - await self.config.guild(ctx.guild).messagewatcher.exemptions.text_messages.set(val) - await ctx.send("Text-only message frequency exemption successfully updated!") - except ValueError: - await ctx.send("Text-only message frequency exemption FAILED to update. Please specify a `float` value " - "only!") + await self.config.guild(ctx.guild).messagewatcher.exemptions.text_messages.set(frequency) + await ctx.send("Text-only message frequency exemption successfully updated!") # Helper Functions @staticmethod @@ -286,24 +297,25 @@ async def analyze_speed(self, guild: discord.Guild, trigger: discord.Message): return # No exemptions at this point, alert! - # Credit: Taken from report Cog - log_id = await self.config.guild(guild).logchannel() - log = None - if log_id: - log = guild.get_channel(log_id) - if not log: - # Failed to get the channel - return - - data = self.make_message_alert_embed(trigger.author, trigger) + await self.send_mod_alert(guild, self.make_message_alert_embed(trigger.author, trigger)) + + async def send_mod_alert(self, guild, data): + # Credit: Taken from report Cog + log_id = await self.config.guild(guild).logchannel() + log = None + if log_id: + log = guild.get_channel(log_id) + if not log: + # Failed to get the channel + return - mod_pings = " ".join( - [i.mention for i in log.members if not i.bot and str(i.status) in ["online", "idle"]]) - if not mod_pings: # If no online/idle mods - mod_pings = " ".join([i.mention for i in log.members if not i.bot]) + mod_pings = " ".join( + i.mention for i in log.members if not i.bot and str(i.status) in ["online", "idle"]) + if not mod_pings: # If no online/idle mods + mod_pings = " ".join([i.mention for i in log.members if not i.bot]) - await log.send(content=mod_pings, embed=data) - # End credit + await log.send(content=mod_pings, embed=data) + # End credit # Listeners @commands.Cog.listener() @@ -322,25 +334,7 @@ async def on_voice_state_update(self, member: discord.Member, before: discord.Vo if datetime.now(timezone.utc) < allowable: if after.self_stream or after.self_video: self.alert_cache[member.id] = True # Update the cache to indicate we've alerted on this user. - # Credit: Taken from report Cog - log_id = await self.config.guild(after.channel.guild).logchannel() - log = None - if log_id: - log = member.guild.get_channel(log_id) - if not log: - # Failed to get the channel - return - - data = self.make_voice_alert_embed(member, after.channel) - - # Alert level logic added - mod_pings = " ".join( - [i.mention for i in log.members if not i.bot and str(i.status) in ["online", "idle"]]) - if not mod_pings: # If no online/idle mods - mod_pings = " ".join([i.mention for i in log.members if not i.bot]) - - await log.send(content=mod_pings, embed=data) - # End credit + await self.send_mod_alert(after.channel.guild, self.make_voice_alert_embed(member, after.channel)) @commands.Cog.listener() async def on_member_join(self, member: discord.Member): @@ -352,28 +346,7 @@ async def on_member_join(self, member: discord.Member): if rule['check_nick'] and member.nick: hits += len(re.findall(rule['pattern'], member.nick)) if hits > 0: - - # Credit: Taken from report Cog - log_id = await self.config.guild(member.guild).logchannel() - log = None - if log_id: - log = member.guild.get_channel(log_id) - if not log: - # Failed to get the channel - return - - data = self.make_profile_alert_embed(member, ruleName, rule) - - mod_pings = "" - # Alert level logic added - if rule['alert_level'] == "HIGH": - mod_pings = " ".join( - [i.mention for i in log.members if not i.bot and str(i.status) in ["online", "idle"]]) - if not mod_pings: # If no online/idle mods - mod_pings = " ".join([i.mention for i in log.members if not i.bot]) - - await log.send(content=mod_pings, embed=data) - # End credit + await self.send_mod_alert(member.guild, self.make_profile_alert_embed(member, ruleName, rule)) @commands.Cog.listener() async def on_message(self, message: discord.Message): From 7d7f0074db8862de91b32e7a419733ee0496a117 Mon Sep 17 00:00:00 2001 From: portalBlock Date: Thu, 26 Oct 2023 14:12:36 -0500 Subject: [PATCH 35/36] Fix arg parser for profile watcher. --- watcher/watcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/watcher/watcher.py b/watcher/watcher.py index 6b8ce01c..4f78b1ca 100644 --- a/watcher/watcher.py +++ b/watcher/watcher.py @@ -102,7 +102,7 @@ async def _profilewatch_add(self, ctx, name: str = "", regex: str = "", alert_le "` " usage += "\nNote: Name & regex fields are limited to 1 word (no spaces)." if (not name and not regex and not reason and not alert_level and not check_nick) or \ - (alert_level != "HIGH" and alert_level != "LOW") or (check_nick != "YES" and check_nick != "NO"): + (alert_level != "HIGH" and alert_level != "LOW"): await ctx.send(usage) else: async with self.config.guild(ctx.guild).profilewatcher.rules() as rules: From 02996659c3e87a8cd9a434dc2a19da86ae03586e Mon Sep 17 00:00:00 2001 From: portalBlock Date: Sat, 24 Feb 2024 13:13:04 -0600 Subject: [PATCH 36/36] Correct format for profile rule to string conversion. --- watcher/watcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/watcher/watcher.py b/watcher/watcher.py index 4f78b1ca..6facb608 100644 --- a/watcher/watcher.py +++ b/watcher/watcher.py @@ -205,7 +205,7 @@ async def _messagewatch_expemptions_textmessages(self, ctx: commands.Context, @staticmethod def profile_rule_to_string(rule_name: str, rule) -> str: return f"**{rule_name}**:\nPattern: `{rule['pattern']}`\nCheck Nick: `{rule['check_nick']}`\nAlert Level: " \ - f"{rule['alert_level']}\nReason: {rule['reason']}" + f"`{rule['alert_level']}`\nReason: `{rule['reason']}`" def make_voice_alert_embed(self, member: discord.Member, chan: discord.VoiceChannel) -> discord.Embed: """Construct the alert embed to be sent"""