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/__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..f9ba2d3b --- /dev/null +++ b/messagewatch/messagewatch.py @@ -0,0 +1,196 @@ +from datetime import datetime, timedelta, timezone +from typing import Optional, List + +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.embed_speeds = {} + default_guild_config = { + "logchannel": "", # Channel to send alerts to + "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 + }, + "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 _messagewatch_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 _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).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).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).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).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, 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) + + 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).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).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 / 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).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_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 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/profilewatch/profilewatch.py b/profilewatch/profilewatch.py new file mode 100644 index 00000000..fa58bbc0 --- /dev/null +++ b/profilewatch/profilewatch.py @@ -0,0 +1,155 @@ +import re +from datetime import datetime +from typing import Optional + +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, pagify +from redbot.core.utils.menus import menu, prev_page, close_menu, next_page + + +class ProfileWatchCog(commands.Cog): + """ProfileWatch 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 + "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) + + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member): + + matcher_list = await self.config.guild(member.guild).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_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 + + # Command groups + + @checks.admin() + @commands.group(name="profilewatch", aliases=["pw"], pass_context=True) + async def _profilewatch(self, ctx): + """Monitor for flagged member name formats""" + + # Commands + + @_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]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).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 _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": "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 _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).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.") + + @_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.""" + + 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""" + # 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 rule_to_string(self, 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']}" 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/voicewatch/voicewatch.py b/voicewatch/voicewatch.py new file mode 100644 index 00000000..a45d12a0 --- /dev/null +++ b/voicewatch/voicewatch.py @@ -0,0 +1,98 @@ +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 import commands + + +class VoiceWatchCog(commands.Cog): + """VoiceWatch 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) + + @checks.admin() + @commands.group("voicewatch", aliases=["vw"], pass_context=True) + async def _voicewatch(self, ctx: commands.Context): + pass + + @_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: + 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.") + + @_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.""" + + 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!") + + @commands.Cog.listener() + async def on_voice_state_update(self, member: discord.Member, + before: discord.VoiceState, after: discord.VoiceState): + 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 + + 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.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(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_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 + + 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 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..6facb608 --- /dev/null +++ b/watcher/watcher.py @@ -0,0 +1,371 @@ +from datetime import timedelta, timezone, datetime +from typing import Optional, List + +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 +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 + + +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""" + + 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: bool = False, *, 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"): + 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: 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).""" + 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: custom_float_converter("Allowable embed frequency FAILED to " + "update. Please specify a `float` " + "value only!")): + """Set/update the allowable embed frequency.""" + + 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): + 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: 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.""" + 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 + 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! + 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]) + + 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. + 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): + + 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: + 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): + 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