From e17d05873e06cc16591b9a55ee808949620c64b9 Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Tue, 3 May 2022 01:05:25 -0600 Subject: [PATCH] Add smarter moderation cases --- jarvis/client.py | 73 ++++++++++++++++++++++++++++++- jarvis/config.py | 87 +------------------------------------ jarvis/utils/__init__.py | 4 +- jarvis/utils/cogs.py | 58 +++++++++++++++++++------ jarvis/utils/permissions.py | 7 +-- poetry.lock | 27 ++++++++++-- pyproject.toml | 1 + 7 files changed, 147 insertions(+), 110 deletions(-) diff --git a/jarvis/client.py b/jarvis/client.py index f5275b7..3c07e9c 100644 --- a/jarvis/client.py +++ b/jarvis/client.py @@ -6,7 +6,16 @@ from datetime import datetime, timedelta, timezone from aiohttp import ClientSession from jarvis_core.db import q -from jarvis_core.db.models import Autopurge, Autoreact, Roleping, Setting, Warning +from jarvis_core.db.models import ( + Action, + Autopurge, + Autoreact, + Modlog, + Note, + Roleping, + Setting, + Warning, +) from jarvis_core.filters import invites, url from jarvis_core.util.ansi import RESET, Fore, Format, fmt from naff import Client, listen @@ -17,6 +26,7 @@ from naff.api.events.discord import ( MessageDelete, MessageUpdate, ) +from naff.api.events.internal import Button from naff.client.errors import CommandCheckFailure, CommandOnCooldown, HTTPException from naff.client.utils.misc_utils import find_all from naff.models.discord.channel import DMChannel @@ -57,7 +67,9 @@ CMD_FMT = fmt(Fore.GREEN, Format.BOLD) class Jarvis(Client): def __init__(self, *args, **kwargs): # noqa: ANN002 ANN003 + redis = kwargs.pop("redis") super().__init__(*args, **kwargs) + self.redis = redis self.logger = logging.getLogger(__name__) self.phishing_domains = [] self.pre_run_callback = self._prerun @@ -634,3 +646,62 @@ class Jarvis(Client): self.logger.warning( f"Failed to process edit {message.guild.id}/{message.channel.id}/{message.id}: {e}" ) + + @listen() + async def on_button(self, event: Button) -> None: + """Process button events.""" + context = event.context + await context.defer(ephemeral=True) + if not context.custom_id.startswith("modcase|"): + return await super().on_button(event) + + if not context.author.has_permissions(Permissions.MANAGE_USERS): + return + + user_key = f"msg|{context.message.id}" + + if context.custom_id == "modcase|yes": + if user_id := await self.redis.get(user_key): + action_key = f"{user_id}|{context.guild.id}" + if (user := await context.guild.fetch_member(user_id)) and ( + action_data := await self.redis.get(action_key) + ): + name, parent = action_data.split("|")[:2] + action = Action(action_type=name, parent=parent) + note = Note( + admin=context.author.id, content="Moderation case opened via message" + ) + modlog = Modlog( + user=user.id, admin=context.author.id, actions=[action], notes=[note] + ) + await modlog.commit() + + fields = ( + EmbedField(name="Admin", value=context.author.mention), + EmbedField(name="Opening Action", value=f"{name} {parent}"), + ) + embed = build_embed( + title="Moderation Case Opened", + description="Moderation case opened against {user.mention}", + fields=fields, + ) + embed.set_author( + name=user.username + "#" + user.discriminator, + icon_url=user.display_avatar.url, + ) + + await context.message.edit(embed=embed) + elif not user: + self.logger.debug("User no longer in guild") + await context.send("User no longer in guild", ephemeral=True) + else: + self.logger.warn("Unable to get action data ( %s )", action_key) + await context.send("Unable to get action data", ephemeral=True) + + await self.bot.redis.delete(user_key) + await self.bot.redis.delete(action_key) + + for row in context.message.components: + for component in row.components: + component.disabled = True + await context.message.edit(components=context.message.components) diff --git a/jarvis/config.py b/jarvis/config.py index 98399e5..5159b7c 100644 --- a/jarvis/config.py +++ b/jarvis/config.py @@ -1,18 +1,9 @@ """Load the config for JARVIS""" -import os - from jarvis_core.config import Config as CConfig -from pymongo import MongoClient -from yaml import load - -try: - from yaml import CLoader as Loader -except ImportError: - from yaml import Loader class JarvisConfig(CConfig): - REQUIRED = ("token", "mongo", "urls") + REQUIRED = ("token", "mongo", "urls", "redis") OPTIONAL = { "sync": False, "log_level": "WARNING", @@ -24,79 +15,3 @@ class JarvisConfig(CConfig): "reddit": None, "rook_token": None, } - - -class Config(object): - """Config singleton object for JARVIS""" - - def __new__(cls, *args: list, **kwargs: dict): - """Get the singleton config, or creates a new one.""" - it = cls.__dict__.get("it") - if it is not None: - return it - cls.__it__ = it = object.__new__(cls) - it.init(*args, **kwargs) - return it - - def init( - self, - token: str, - mongo: dict, - urls: dict, - sync: bool = False, - log_level: str = "WARNING", - cogs: list = None, - events: bool = True, - gitlab_token: str = None, - max_messages: int = 1000, - twitter: dict = None, - reddit: dict = None, - rook_token: str = None, - ) -> None: - """Initialize the config object.""" - self.token = token - self.mongo = mongo - self.urls = urls - self.log_level = log_level - self.cogs = cogs - self.events = events - self.max_messages = max_messages - self.gitlab_token = gitlab_token - self.twitter = twitter - self.reddit = reddit - self.sync = sync or os.environ.get("SYNC_COMMANDS", False) - self.rook_token = rook_token - self.__db_loaded = False - self.__mongo = MongoClient(**self.mongo["connect"]) - - def get_db_config(self) -> None: - """Load the database config objects.""" - if not self.__db_loaded: - db = self.__mongo[self.mongo["database"]] - items = db.config.find() - for item in items: - setattr(self, item["key"], item["value"]) - self.__db_loaded = True - - @classmethod - def from_yaml(cls, y: dict) -> "Config": - """Load the yaml config file.""" - return cls(**y) - - -def get_config(path: str = "config.yaml") -> Config: - """Get the config from the specified yaml file.""" - if Config.__dict__.get("it"): - return Config() - with open(path) as f: - raw = f.read() - y = load(raw, Loader=Loader) - config = Config.from_yaml(y) - config.get_db_config() - return config - - -def reload_config() -> None: - """Force reload of the config singleton on next call.""" - if "it" in Config.__dict__: - Config.__dict__.pop("it") diff --git a/jarvis/utils/__init__.py b/jarvis/utils/__init__.py index 0c56a59..2bece3a 100644 --- a/jarvis/utils/__init__.py +++ b/jarvis/utils/__init__.py @@ -7,7 +7,7 @@ from naff.models.discord.embed import Embed, EmbedField from naff.models.discord.guild import AuditLogEntry from naff.models.discord.user import Member -from jarvis.config import get_config +from jarvis.config import JarvisConfig def build_embed( @@ -63,7 +63,7 @@ def modlog_embed( def get_extensions(path: str) -> list: """Get JARVIS cogs.""" - config = get_config() + config = JarvisConfig.from_yaml() vals = config.cogs or [x.name for x in iter_modules(path)] return [f"jarvis.cogs.{x}" for x in vals] diff --git a/jarvis/utils/cogs.py b/jarvis/utils/cogs.py index 8fd65f0..1675a58 100644 --- a/jarvis/utils/cogs.py +++ b/jarvis/utils/cogs.py @@ -2,17 +2,9 @@ import logging from jarvis_core.db import q -from jarvis_core.db.models import ( - Action, - Ban, - Kick, - Modlog, - Mute, - Note, - Setting, - Warning, -) +from jarvis_core.db.models import Ban, Kick, Mute, Setting, Warning from naff import Client, Cog, InteractionContext +from naff.models.discord.components import ActionRow, Button, ButtonStyles from naff.models.discord.embed import EmbedField from jarvis.utils import build_embed @@ -55,9 +47,6 @@ class ModcaseCog(Cog): self.logger.warning("Missing action %s, exiting", name) return - action = Action(action_type=name.lower(), parent=action.id) - note = Note(admin=self.bot.user.id, content="Moderation case opened automatically") - await Modlog(user=user.id, admin=ctx.author.id, actions=[action], notes=[note]).commit() notify = await Setting.find_one(q(guild=ctx.guild.id, setting="notify", value=True)) if notify and name not in ("Kick", "Ban"): # Ignore Kick and Ban, as these are unique fields = ( @@ -74,4 +63,45 @@ class ModcaseCog(Cog): guild_url = f"https://discord.com/channels/{ctx.guild.id}" embed.set_author(name=ctx.guild.name, icon_url=ctx.guild.icon.url, url=guild_url) embed.set_thumbnail(url=ctx.guild.icon.url) - await user.send(embed=embed) + try: + await user.send(embed=embed) + except Exception: + self.logger.debug("User not warned of action due to closed DMs") + + lookup_key = f"{user.id}|{ctx.guild.id}" + + async with self.bot.redis.lock(lookup_key): + if await self.bot.redis.get(lookup_key): + self.logger.debug(f"User {user.id} in {ctx.guild.id} already has pending case") + return + + modlog = await Setting.find_one(q(guild=ctx.guild.id, setting="modlog")) + if not modlog: + return + + channel = await ctx.guild.fetch_channel(modlog.value) + if not channel: + self.logger.warn( + f"Guild {ctx.guild.id} modlog channel no longer exists, deleting" + ) + await modlog.delete() + return + + embed = build_embed( + title="Recent Action Taken", + description="Would you like to open a moderation case for {user.mention}?", + fields=[], + ) + embed.set_author( + name=user.username + "#" + user.discriminator, icon_url=user.display_avatar.url + ) + components = [ + ActionRow( + Button(style=ButtonStyles.RED, emoji="✖️", custom_id="modcase|no"), + Button(style=ButtonStyles.GREEN, emoji="✔️", custom_id="modcase|yes"), + ) + ] + message = await ctx.send(embed=embed, components=components) + await self.bot.redis.set(lookup_key, f"{name.lower()}|{action.id}") + + await self.bot.redis.set(f"msg|{message.id}", user.id) diff --git a/jarvis/utils/permissions.py b/jarvis/utils/permissions.py index 3ad2900..cac2ff2 100644 --- a/jarvis/utils/permissions.py +++ b/jarvis/utils/permissions.py @@ -1,7 +1,7 @@ """Permissions wrappers.""" from naff import InteractionContext, Permissions -from jarvis.config import get_config +from jarvis.config import JarvisConfig def user_is_bot_admin() -> bool: @@ -9,8 +9,9 @@ def user_is_bot_admin() -> bool: async def predicate(ctx: InteractionContext) -> bool: """Command check predicate.""" - if getattr(get_config(), "admins", None): - return ctx.author.id in get_config().admins + cfg = JarvisConfig.from_yaml() + if getattr(cfg, "admins", None): + return ctx.author.id in cfg.admins else: return False diff --git a/poetry.lock b/poetry.lock index 9100b39..f14ec08 100644 --- a/poetry.lock +++ b/poetry.lock @@ -40,6 +40,21 @@ yarl = ">=1.0,<2.0" [package.extras] speedups = ["aiodns", "brotli", "cchardet"] +[[package]] +name = "aioredis" +version = "2.0.1" +description = "asyncio (PEP 3156) Redis support" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +async-timeout = "*" +typing-extensions = "*" + +[package.extras] +hiredis = ["hiredis (>=1.0)"] + [[package]] name = "aiosignal" version = "1.2.0" @@ -497,11 +512,11 @@ python-versions = ">=3.10" aiohttp = {version = "3.8.1", markers = "python_version >= \"3.6\""} aiosignal = {version = "1.2.0", markers = "python_version >= \"3.6\""} async-timeout = {version = "4.0.2", markers = "python_version >= \"3.6\""} -attrs = {version = "21.4.0", markers = "python_version >= \"3.6\" and python_full_version < \"3.0.0\" or python_full_version >= \"3.5.0\" and python_version >= \"3.6\""} +attrs = {version = "21.4.0", markers = "python_version >= \"3.6\""} certifi = {version = "2021.10.8", markers = "python_version >= \"2.7\" and python_full_version < \"3.0.0\" or python_full_version >= \"3.6.0\""} -charset-normalizer = {version = "2.0.12", markers = "python_full_version >= \"3.6.0\" and python_version >= \"3.6\""} +charset-normalizer = {version = "2.0.12", markers = "python_full_version >= \"3.6.0\""} frozenlist = {version = "1.3.0", markers = "python_version >= \"3.7\""} -idna = {version = "3.3", markers = "python_version >= \"3.6\" and python_full_version < \"3.0.0\" or python_full_version >= \"3.6.0\" and python_version >= \"3.6\""} +idna = {version = "3.3", markers = "python_full_version >= \"3.6.0\""} multidict = {version = "6.0.2", markers = "python_version >= \"3.7\""} pycryptodome = {version = "3.14.1", markers = "python_version >= \"2.7\" and python_full_version < \"3.0.0\" or python_full_version >= \"3.5.0\""} requests = {version = "2.27.1", markers = "python_version >= \"2.7\" and python_full_version < \"3.0.0\" or python_full_version >= \"3.6.0\""} @@ -901,7 +916,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "3bfe48a36c3bc4bef6e6840eaeb51b01ffd6d038135451e45307eb843df981e0" +content-hash = "3fe4606bc1a4c1e58ee535a4b4126f676c0780c2fd02d15e3df9657586967b1e" [metadata.files] aiofile = [ @@ -986,6 +1001,10 @@ aiohttp = [ {file = "aiohttp-3.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:1c182cb873bc91b411e184dab7a2b664d4fea2743df0e4d57402f7f3fa644bac"}, {file = "aiohttp-3.8.1.tar.gz", hash = "sha256:fc5471e1a54de15ef71c1bc6ebe80d4dc681ea600e68bfd1cbce40427f0b7578"}, ] +aioredis = [ + {file = "aioredis-2.0.1-py3-none-any.whl", hash = "sha256:9ac0d0b3b485d293b8ca1987e6de8658d7dafcca1cddfcd1d506cae8cdebfdd6"}, + {file = "aioredis-2.0.1.tar.gz", hash = "sha256:eaa51aaf993f2d71f54b70527c440437ba65340588afeb786cd87c55c89cd98e"}, +] aiosignal = [ {file = "aiosignal-1.2.0-py3-none-any.whl", hash = "sha256:26e62109036cd181df6e6ad646f91f0dcfd05fe16d0cb924138ff2ab75d64e3a"}, {file = "aiosignal-1.2.0.tar.gz", hash = "sha256:78ed67db6c7b7ced4f98e495e572106d5c432a93e1ddd1bf475e1dc05f5b7df2"}, diff --git a/pyproject.toml b/pyproject.toml index 7e0bdaf..90e6f18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ asyncpraw = "^7.5.0" rook = "^0.1.170" rich = "^12.3.0" jurigged = "^0.5.0" +aioredis = "^2.0.1" [build-system] requires = ["poetry-core>=1.0.0"]