From 5c0c53f7d3e21a29777f19653a7a644498a4f1c3 Mon Sep 17 00:00:00 2001 From: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> Date: Sun, 9 Jul 2023 06:35:01 +0200 Subject: [PATCH 01/12] Remove modmail_guild_id from env examples (#3281) * Update .env.example The removal of `modmail_guild_id` since its causing more confusion to people then it helps them. Features relying on this never were made. Signed-off-by: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> * Update app.json --------- Signed-off-by: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> (cherry picked from commit fa1a9494e8f51542eb55b7ad986239a9f8c36cf0) --- app.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app.json b/app.json index 5d03887632..2b68d01cec 100644 --- a/app.json +++ b/app.json @@ -11,10 +11,6 @@ "description": "The id for the server you are hosting this bot for.", "required": true }, - "MODMAIL_GUILD_ID": { - "description": "The ID of the discord server where the threads channels should be created (receiving server). Default to GUILD_ID.", - "required": false - }, "OWNERS": { "description": "Comma separated user IDs of people that are allowed to use owner only commands. (eval).", "required": true @@ -68,4 +64,4 @@ "required": false } } -} \ No newline at end of file +} From c1c3f303634ded4ba18b15722bf54e8e11c7bb23 Mon Sep 17 00:00:00 2001 From: Stephen <48072084+StephenDaDev@users.noreply.github.com> Date: Sat, 15 Jul 2023 08:12:30 -0400 Subject: [PATCH 02/12] Remove Heroku Reference from Debug Command (#3292) * Very few users are still using Heroku for hosting, so this comment could cause confusion. * Fix mistake --------- Co-authored-by: Taku <45324516+Taaku18@users.noreply.github.com> (cherry picked from commit d891ad18ca0983f344a8c7da083c559e9bd96112) --- cogs/utility.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cogs/utility.py b/cogs/utility.py index bc488290f7..1b60eaefd9 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -383,7 +383,7 @@ async def debug(self, ctx): title="Debug Logs:", description="You don't have any logs at the moment.", ) - embed.set_footer(text="Go to Heroku to see your logs.") + embed.set_footer(text="Go to your console to see your logs.") return await ctx.send(embed=embed) messages = [] @@ -449,7 +449,7 @@ async def debug_hastebin(self, ctx): color=self.bot.main_color, description="Something's wrong. We're unable to upload your logs to hastebin.", ) - embed.set_footer(text="Go to Heroku to see your logs.") + embed.set_footer(text="Go to your console to see your logs.") await ctx.send(embed=embed) @debug.command(name="clear", aliases=["wipe"]) From ea376879494b347e9f4cff50c49f6d5fa77e77d3 Mon Sep 17 00:00:00 2001 From: Taku <45324516+Taaku18@users.noreply.github.com> Date: Mon, 17 Jul 2023 15:55:37 -0700 Subject: [PATCH 03/12] Fix #3291: Resolve code scanning alert for URL sanitization (cherry picked from commit 2b667102e95354fbdf07f88c3c88e95d802be0b7) --- core/utils.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/core/utils.py b/core/utils.py index 21ea50d4cd..4ff6f569a4 100644 --- a/core/utils.py +++ b/core/utils.py @@ -148,13 +148,17 @@ def is_image_url(url: str, **kwargs) -> str: bool Whether the URL is a valid image URL. """ - if url.startswith("https://gyazo.com") or url.startswith("http://gyazo.com"): - # gyazo support - url = re.sub( - r"(http[s]?:\/\/)((?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+)", - r"\1i.\2.png", - url, - ) + try: + result = parse.urlparse(url) + if result.netloc == 'gyazo.com' and result.scheme in ['http', 'https']: + # gyazo support + url = re.sub( + r"(https?://)((?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|%[0-9a-fA-F][0-9a-fA-F])+)", + r"\1i.\2.png", + url, + ) + except ValueError: + pass return parse_image_url(url, **kwargs) From 662f6b76ff5f6e1ce1f76ee864cec3a24153879f Mon Sep 17 00:00:00 2001 From: Taku <45324516+taaku18@users.noreply.github.com> Date: Mon, 17 Jul 2023 16:24:02 -0700 Subject: [PATCH 04/12] Implement #3187: Enhance bot join/leave logs across servers (cherry picked from commit f0c469ed1905ade9492f31dcd80e344de04f023b) Signed-off-by: Khakers <22665282+khakers@users.noreply.github.com> --- bot.py | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/bot.py b/bot.py index 403fce99f3..a7e4d8bbef 100644 --- a/bot.py +++ b/bot.py @@ -49,7 +49,7 @@ ) from core.thread import ThreadManager from core.time import human_timedelta -from core.utils import normalize_alias, parse_alias, truncate, tryint +from core.utils import normalize_alias, parse_alias, truncate, tryint, human_join logger = getLogger(__name__) @@ -1428,28 +1428,44 @@ async def on_guild_channel_delete(self, channel): await thread.close(closer=mod, silent=True, delete_channel=False) async def on_member_remove(self, member): - if member.guild != self.guild: - return thread = await self.threads.find(recipient=member) if thread: - if self.config["close_on_leave"]: + if member.guild == self.guild and self.config["close_on_leave"]: await thread.close( closer=member.guild.me, message=self.config["close_on_leave_reason"], silent=True, ) else: - embed = discord.Embed( - description=self.config["close_on_leave_reason"], color=self.error_color - ) + if len(self.guilds) > 1: + guild_left = member.guild + remaining_guilds = member.mutual_guilds + + if remaining_guilds: + remaining_guild_names = [guild.name for guild in remaining_guilds] + leave_message = ( + f"The recipient has left {guild_left}. " + f"They are still in {human_join(remaining_guild_names, final='and')}." + ) + else: + leave_message = ( + f"The recipient has left {guild_left}. We no longer share any mutual servers." + ) + else: + leave_message = "The recipient has left the server." + + embed = discord.Embed(description=leave_message, color=self.error_color) await thread.channel.send(embed=embed) async def on_member_join(self, member): - if member.guild != self.guild: - return thread = await self.threads.find(recipient=member) if thread: - embed = discord.Embed(description="The recipient has joined the server.", color=self.mod_color) + if len(self.guilds) > 1: + guild_joined = member.guild + join_message = f"The recipient has joined {guild_joined}." + else: + join_message = "The recipient has joined the server." + embed = discord.Embed(description=join_message, color=self.mod_color) await thread.channel.send(embed=embed) async def on_message_delete(self, message): From 6a203c721aac1259ff8ade3df46cfd0010492405 Mon Sep 17 00:00:00 2001 From: Jerrie <70805800+jerrie-aries@users.noreply.github.com> Date: Sat, 15 Jul 2023 04:45:36 -0700 Subject: [PATCH 05/12] Enable discord.py logger by default. (#3216) * Enable `discord.py` logger by default. * Revert: - Restore import orders - Logging stuff is now completely handled in `core.models.configure_logging` * Update logging configurations * Updated changelog * Fix overflow characters in logs when using `?debug` command. * Update changelog --------- Co-authored-by: Taku <45324516+Taaku18@users.noreply.github.com> (cherry picked from commit 43fbc312cfe9f1f8d8222f3e80a39ea5394038ea) Signed-off-by: Khakers <22665282+khakers@users.noreply.github.com> --- CHANGELOG.md | 4 + bot.py | 43 ++------- cogs/utility.py | 23 +---- core/config.py | 1 + core/config_help.json | 10 +- core/models.py | 209 +++++++++++++++++++++++++++++++----------- 6 files changed, 180 insertions(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee4f7f3e33..1fc1c0d0d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ however, insignificant breaking changes do not guarantee a major version bump, s - Support for trailing space in `?prefix` command, example: `?prefix "mm "` for `mm ping`. - Added logviewer as built-in local plugin `?plugin load @local/logviewer`. - `?plugin uninstall` is now an alias for `?plugin remove` ([GH #3260](https://github.com/modmail-dev/modmail/issues/3260)) +- `DISCORD_LOG_LEVEL` environment variable to set the log level of discord.py. ([PR #3216](https://github.com/modmail-dev/Modmail/pull/3216)) ### Changed - Guild icons in embed footers and author urls now have a fixed size of 128. ([PR #3261](https://github.com/modmail-dev/modmail/pull/3261)) @@ -80,6 +81,9 @@ however, insignificant breaking changes do not guarantee a major version bump, s ### Internal - `ConfigManager.get` no longer accepts two positional arguments: the `convert` argument is now keyword-only. +### Internal +- Renamed `Bot.log_file_name` to `Bot.log_file_path`. Log files are now created at `temp/logs/modmail.log`. ([PR #3216](https://github.com/modmail-dev/Modmail/pull/3216)) + # v4.0.2 ### Breaking diff --git a/bot.py b/bot.py index a7e4d8bbef..52a420f1ed 100644 --- a/bot.py +++ b/bot.py @@ -49,11 +49,10 @@ ) from core.thread import ThreadManager from core.time import human_timedelta -from core.utils import normalize_alias, parse_alias, truncate, tryint, human_join +from core.utils import human_join, normalize_alias, parse_alias, truncate, tryint logger = getLogger(__name__) - temp_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "temp") if not os.path.exists(temp_dir): os.mkdir(temp_dir) @@ -85,8 +84,11 @@ def __init__(self): self.threads = ThreadManager(self) - self.log_file_name = os.path.join(temp_dir, f"{self.token.split('.')[0]}.log") - self._configure_logging() + log_dir = os.path.join(temp_dir, "logs") + if not os.path.exists(log_dir): + os.mkdir(log_dir) + self.log_file_path = os.path.join(log_dir, "modmail.log") + configure_logging(self) self.plugin_db = PluginDatabaseClient(self) # Deprecated @@ -186,29 +188,6 @@ async def load_extensions(self): logger.exception("Failed to load %s.", cog) logger.line("debug") - def _configure_logging(self): - level_text = self.config["log_level"].upper() - logging_levels = { - "CRITICAL": logging.CRITICAL, - "ERROR": logging.ERROR, - "WARNING": logging.WARNING, - "INFO": logging.INFO, - "DEBUG": logging.DEBUG, - } - logger.line() - - log_level = logging_levels.get(level_text) - if log_level is None: - log_level = self.config.remove("log_level") - logger.warning("Invalid logging level set: %s.", level_text) - logger.warning("Using default logging level: INFO.") - else: - logger.info("Logging level: %s", level_text) - - logger.info("Log file: %s", self.log_file_name) - configure_logging(self.log_file_name, log_level) - logger.debug("Successfully configured logging.") - @property def version(self): return parse_version(__version__) @@ -1771,16 +1750,6 @@ def main(): except ImportError: pass - # Set up discord.py internal logging - if os.environ.get("LOG_DISCORD"): - logger.debug(f"Discord logging enabled: {os.environ['LOG_DISCORD'].upper()}") - d_logger = logging.getLogger("discord") - - d_logger.setLevel(os.environ["LOG_DISCORD"].upper()) - handler = logging.FileHandler(filename="discord.log", encoding="utf-8", mode="w") - handler.setFormatter(logging.Formatter("%(asctime)s:%(levelname)s:%(name)s: %(message)s")) - d_logger.addHandler(handler) - bot = ModmailBot() bot.run() diff --git a/cogs/utility.py b/cogs/utility.py index 1b60eaefd9..2c722eb2c4 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -368,13 +368,7 @@ async def about(self, ctx: commands.Context): async def debug(self, ctx): """Shows the recent application logs of the bot.""" - log_file_name = self.bot.token.split(".")[0] - - with open( - os.path.join(os.path.dirname(os.path.abspath(__file__)), f"../temp/{log_file_name}.log"), - "r+", - encoding="utf-8", - ) as f: + with open(self.bot.log_file_path, "r+", encoding="utf-8") as f: logs = f.read().strip() if not logs: @@ -400,7 +394,7 @@ async def debug(self, ctx): msg = "```Haskell\n" msg += line if len(msg) + 3 > 2000: - msg = msg[:1993] + "[...]```" + msg = msg[:1992] + "[...]```" messages.append(msg) msg = "```Haskell\n" @@ -422,12 +416,8 @@ async def debug_hastebin(self, ctx): """Posts application-logs to Hastebin.""" haste_url = os.environ.get("HASTE_URL", "https://hastebin.cc") - log_file_name = self.bot.token.split(".")[0] - with open( - os.path.join(os.path.dirname(os.path.abspath(__file__)), f"../temp/{log_file_name}.log"), - "rb+", - ) as f: + with open(self.bot.log_file_path, "rb+") as f: logs = BytesIO(f.read().strip()) try: @@ -458,12 +448,7 @@ async def debug_hastebin(self, ctx): async def debug_clear(self, ctx): """Clears the locally cached logs.""" - log_file_name = self.bot.token.split(".")[0] - - with open( - os.path.join(os.path.dirname(os.path.abspath(__file__)), f"../temp/{log_file_name}.log"), - "w", - ): + with open(self.bot.log_file_path, "w"): pass await ctx.send( embed=discord.Embed(color=self.bot.main_color, description="Cached logs are now cleared.") diff --git a/core/config.py b/core/config.py index b8bb35d40c..b8f8f7a7bd 100644 --- a/core/config.py +++ b/core/config.py @@ -177,6 +177,7 @@ class ConfigManager: "disable_updates": False, # Logging "log_level": "INFO", + "discord_log_level": "INFO", # data collection "data_collection": True, } diff --git a/core/config_help.json b/core/config_help.json index 80ebf1a48a..d5972254f9 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -1121,6 +1121,14 @@ "This configuration can only be set through `.env` file or environment (config) variables." ] }, + "discord_log_level": { + "default": "INFO", + "description": "The `discord.py` library logging level for logging to stdout.", + "examples": [], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, "enable_plugins": { "default": "Yes", "description": "Whether plugins should be enabled and loaded into Modmail.", @@ -1171,4 +1179,4 @@ "If you would like to display the top role of a user regardless of if it's hoisted or not, disable `use_hoisted_top_role`." ] } -} \ No newline at end of file +} diff --git a/core/models.py b/core/models.py index 0bb3690de8..a5f9582515 100644 --- a/core/models.py +++ b/core/models.py @@ -4,8 +4,10 @@ import sys from difflib import get_close_matches from enum import IntEnum +from logging import FileHandler, Handler, StreamHandler from logging.handlers import RotatingFileHandler from string import Formatter +from typing import Optional import _string import discord @@ -22,29 +24,6 @@ Fore = Style = type("Dummy", (object,), {"__getattr__": lambda self, item: ""})() -class PermissionLevel(IntEnum): - OWNER = 5 - ADMINISTRATOR = 4 - ADMIN = 4 - MODERATOR = 3 - MOD = 3 - SUPPORTER = 2 - RESPONDER = 2 - REGULAR = 1 - INVALID = -1 - - -class InvalidConfigError(commands.BadArgument): - def __init__(self, msg, *args): - super().__init__(msg, *args) - self.msg = msg - - @property - def embed(self): - # Single reference of Color.red() - return discord.Embed(title="Error", description=self.msg, color=discord.Color.red()) - - class ModmailLogger(logging.Logger): @staticmethod def _debug_(*msgs): @@ -93,18 +72,92 @@ def line(self, level="info"): ) -logging.setLoggerClass(ModmailLogger) -log_level = logging.INFO -loggers = set() +class FileFormatter(logging.Formatter): + ansi_escape = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") -ch = logging.StreamHandler(stream=sys.stdout) -ch.setLevel(log_level) -formatter = logging.Formatter( + def format(self, record): + record.msg = self.ansi_escape.sub("", record.msg) + return super().format(record) + + +log_stream_formatter = logging.Formatter( "%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s", datefmt="%m/%d/%y %H:%M:%S" ) -ch.setFormatter(formatter) +log_file_formatter = FileFormatter( + "%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) + + +def create_log_handler( + filename: Optional[str] = None, + *, + rotating: bool = False, + level: int = logging.DEBUG, + mode: str = "a+", + encoding: str = "utf-8", + maxBytes: int = 28000000, + backupCount: int = 1, + **kwargs, +) -> Handler: + """ + Creates a pre-configured log handler. This function is made for consistency's sake with + pre-defined default values for parameters and formatters to pass to handler class. + Additional keyword arguments also can be specified, just in case. + + Plugin developers should not use this and use `models.getLogger` instead. + + Parameters + ---------- + filename : Optional[Path] + Specifies that a `FileHandler` or `RotatingFileHandler` be created, using the specified filename, + rather than a `StreamHandler`. Defaults to `None`. + rotating : bool + Whether the file handler should be the `RotatingFileHandler`. Defaults to `False`. Note, this + argument only compatible if the `filename` is specified, otherwise `ValueError` will be raised. + level : int + The root logger level for the handler. Defaults to `logging.DEBUG`. + mode : str + If filename is specified, open the file in this mode. Defaults to 'a+'. + encoding : str + If this keyword argument is specified along with filename, its value is used when the `FileHandler` is created, + and thus used when opening the output file. Defaults to 'utf-8'. + maxBytes : int + The max file size before the rollover occurs. Defaults to 28000000 (28MB). Rollover occurs whenever the current + log file is nearly `maxBytes` in length; but if either of `maxBytes` or `backupCount` is zero, + rollover never occurs, so you generally want to set `backupCount` to at least 1. + backupCount : int + Max number of backup files. Defaults to 1. If this is set to zero, rollover will never occur. + + Returns + ------- + `StreamHandler` when `filename` is `None`, otherwise `FileHandler` or `RotatingFileHandler` + depending on the `rotating` value. + """ + if filename is None and rotating: + raise ValueError("`filename` must be set to instantiate a `RotatingFileHandler`.") -ch_debug = None + if filename is None: + handler = StreamHandler(stream=sys.stdout, **kwargs) + handler.setFormatter(log_stream_formatter) + elif not rotating: + handler = FileHandler(filename, mode=mode, encoding=encoding, **kwargs) + handler.setFormatter(log_file_formatter) + else: + handler = RotatingFileHandler( + filename, mode=mode, encoding=encoding, maxBytes=maxBytes, backupCount=backupCount, **kwargs + ) + handler.setFormatter(log_file_formatter) + + handler.setLevel(level) + return handler + + +logging.setLoggerClass(ModmailLogger) +log_level = logging.INFO +loggers = set() +ch = create_log_handler(level=log_level) +ch_debug: Optional[RotatingFileHandler] = None def getLogger(name=None) -> ModmailLogger: @@ -117,33 +170,71 @@ def getLogger(name=None) -> ModmailLogger: return logger -class FileFormatter(logging.Formatter): - ansi_escape = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") - - def format(self, record): - record.msg = self.ansi_escape.sub("", record.msg) - return super().format(record) - - -def configure_logging(name, level=None): +def configure_logging(bot) -> None: global ch_debug, log_level - ch_debug = RotatingFileHandler(name, mode="a+", maxBytes=48000, backupCount=1, encoding="utf-8") - formatter_debug = FileFormatter( - "%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - ch_debug.setFormatter(formatter_debug) - ch_debug.setLevel(logging.DEBUG) + logger = getLogger(__name__) + level_text = bot.config["log_level"].upper() + logging_levels = { + "CRITICAL": logging.CRITICAL, + "ERROR": logging.ERROR, + "WARNING": logging.WARNING, + "INFO": logging.INFO, + "DEBUG": logging.DEBUG, + } + logger.line() + + level = logging_levels.get(level_text) + if level is None: + level = bot.config.remove("log_level") + logger.warning("Invalid logging level set: %s.", level_text) + logger.warning("Using default logging level: %s.", level) + level = logging_levels[level] + else: + logger.info("Logging level: %s", level_text) + log_level = level + + logger.info("Log file: %s", bot.log_file_path) + ch_debug = create_log_handler(bot.log_file_path, rotating=True) + ch.setLevel(log_level) - if level is not None: - log_level = level + for log in loggers: + log.setLevel(log_level) + log.addHandler(ch_debug) + + # Set up discord.py logging + d_level_text = bot.config["discord_log_level"].upper() + d_level = logging_levels.get(d_level_text) + if d_level is None: + d_level = bot.config.remove("discord_log_level") + logger.warning("Invalid discord logging level set: %s.", d_level_text) + logger.warning("Using default discord logging level: %s.", d_level) + d_level = logging_levels[d_level] + d_logger = logging.getLogger("discord") + d_logger.setLevel(d_level) + + non_verbose_log_level = max(d_level, logging.INFO) + stream_handler = create_log_handler(level=non_verbose_log_level) + if non_verbose_log_level != d_level: + logger.info("Discord logging level (stdout): %s.", logging.getLevelName(non_verbose_log_level)) + logger.info("Discord logging level (logfile): %s.", logging.getLevelName(d_level)) + else: + logger.info("Discord logging level: %s.", logging.getLevelName(d_level)) + d_logger.addHandler(stream_handler) + d_logger.addHandler(ch_debug) + + logger.debug("Successfully configured logging.") - ch.setLevel(log_level) - for logger in loggers: - logger.setLevel(log_level) - logger.addHandler(ch_debug) +class InvalidConfigError(commands.BadArgument): + def __init__(self, msg, *args): + super().__init__(msg, *args) + self.msg = msg + + @property + def embed(self): + # Single reference of Color.red() + return discord.Embed(title="Error", description=self.msg, color=discord.Color.red()) class _Default: @@ -269,6 +360,18 @@ async def ack(self): return +class PermissionLevel(IntEnum): + OWNER = 5 + ADMINISTRATOR = 4 + ADMIN = 4 + MODERATOR = 3 + MOD = 3 + SUPPORTER = 2 + RESPONDER = 2 + REGULAR = 1 + INVALID = -1 + + class DMDisabled(IntEnum): NONE = 0 NEW_THREADS = 1 From 3b97d12e51f2ce70edfa8561074ff79c3c388e58 Mon Sep 17 00:00:00 2001 From: Amy Date: Sat, 18 Nov 2023 21:45:13 -0800 Subject: [PATCH 06/12] Add JSON logging support (#3305) * Add JSON logging support This adds support for JSON logging, along with the relevant options required. This does not change the default log behaviour, so should be backwards compatible. It is opt in via the LOG_FORMAT option, which can be 'json' to use the new logger, or anything else to fallback to the old behaviour. This is implemented in terms of a custom formatter, which is optionally applied to the stdout stream. The debug stream is unaffected by this. * Allow JSON to be selected when creating handlers * Allow different formats to be selected for streams/files * Remove old / unused code * Add new config opts to helpfile * Formatting, basic typing and reorder for consistency in project. --------- Co-authored-by: Jerrie-Aries Co-authored-by: Taku <45324516+Taaku18@users.noreply.github.com> (cherry picked from commit 6d61cf29ff0d9e8a3b984eb44a6337402246bff0) Signed-off-by: Khakers <22665282+khakers@users.noreply.github.com> --- core/config.py | 2 + core/config_help.json | 16 ++++++ core/models.py | 110 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 123 insertions(+), 5 deletions(-) diff --git a/core/config.py b/core/config.py index b8f8f7a7bd..b68719bc0d 100644 --- a/core/config.py +++ b/core/config.py @@ -177,6 +177,8 @@ class ConfigManager: "disable_updates": False, # Logging "log_level": "INFO", + "stream_log_format": "plain", + "file_log_format": "plain", "discord_log_level": "INFO", # data collection "data_collection": True, diff --git a/core/config_help.json b/core/config_help.json index d5972254f9..d446a7f6e1 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -1121,6 +1121,22 @@ "This configuration can only be set through `.env` file or environment (config) variables." ] }, + "stream_log_format": { + "default": "plain", + "description": "The logging format when through a stream, can be 'plain' or 'json'", + "examples": [], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "file_log_format": { + "default": "plain", + "description": "The logging format when logging to a file, can be 'plain' or 'json'", + "examples": [], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, "discord_log_level": { "default": "INFO", "description": "The `discord.py` library logging level for logging to stdout.", diff --git a/core/models.py b/core/models.py index a5f9582515..b061cc967b 100644 --- a/core/models.py +++ b/core/models.py @@ -1,3 +1,4 @@ +import json import logging import os import re @@ -7,7 +8,7 @@ from logging import FileHandler, Handler, StreamHandler from logging.handlers import RotatingFileHandler from string import Formatter -from typing import Optional +from typing import Dict, Optional import _string import discord @@ -72,6 +73,71 @@ def line(self, level="info"): ) +class JsonFormatter(logging.Formatter): + """ + Formatter that outputs JSON strings after parsing the LogRecord. + + Parameters + ---------- + fmt_dict : Optional[Dict[str, str]] + {key: logging format attribute} pairs. Defaults to {"message": "message"}. + time_format: str + time.strftime() format string. Default: "%Y-%m-%dT%H:%M:%S" + msec_format: str + Microsecond formatting. Appended at the end. Default: "%s.%03dZ" + """ + + def __init__( + self, + fmt_dict: Optional[Dict[str, str]] = None, + time_format: str = "%Y-%m-%dT%H:%M:%S", + msec_format: str = "%s.%03dZ", + ): + self.fmt_dict: Dict[str, str] = fmt_dict if fmt_dict is not None else {"message": "message"} + self.default_time_format: str = time_format + self.default_msec_format: str = msec_format + self.datefmt: Optional[str] = None + + def usesTime(self) -> bool: + """ + Overwritten to look for the attribute in the format dict values instead of the fmt string. + """ + return "asctime" in self.fmt_dict.values() + + def formatMessage(self, record) -> Dict[str, str]: + """ + Overwritten to return a dictionary of the relevant LogRecord attributes instead of a string. + KeyError is raised if an unknown attribute is provided in the fmt_dict. + """ + return {fmt_key: record.__dict__[fmt_val] for fmt_key, fmt_val in self.fmt_dict.items()} + + def format(self, record) -> str: + """ + Mostly the same as the parent's class method, the difference being that a dict is manipulated and dumped as JSON + instead of a string. + """ + record.message = record.getMessage() + + if self.usesTime(): + record.asctime = self.formatTime(record, self.datefmt) + + message_dict = self.formatMessage(record) + + if record.exc_info: + # Cache the traceback text to avoid converting it multiple times + # (it's constant anyway) + if not record.exc_text: + record.exc_text = self.formatException(record.exc_info) + + if record.exc_text: + message_dict["exc_info"] = record.exc_text + + if record.stack_info: + message_dict["stack_info"] = self.formatStack(record.stack_info) + + return json.dumps(message_dict, default=str) + + class FileFormatter(logging.Formatter): ansi_escape = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") @@ -83,11 +149,25 @@ def format(self, record): log_stream_formatter = logging.Formatter( "%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s", datefmt="%m/%d/%y %H:%M:%S" ) + log_file_formatter = FileFormatter( "%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) +json_formatter = JsonFormatter( + { + "level": "levelname", + "message": "message", + "loggerName": "name", + "processName": "processName", + "processID": "process", + "threadName": "threadName", + "threadID": "thread", + "timestamp": "asctime", + } +) + def create_log_handler( filename: Optional[str] = None, @@ -96,6 +176,7 @@ def create_log_handler( level: int = logging.DEBUG, mode: str = "a+", encoding: str = "utf-8", + format: str = "plain", maxBytes: int = 28000000, backupCount: int = 1, **kwargs, @@ -122,6 +203,9 @@ def create_log_handler( encoding : str If this keyword argument is specified along with filename, its value is used when the `FileHandler` is created, and thus used when opening the output file. Defaults to 'utf-8'. + format : str + The format to output with, can either be 'json' or 'plain'. Will apply to whichever handler is created, + based on other conditional logic. maxBytes : int The max file size before the rollover occurs. Defaults to 28000000 (28MB). Rollover occurs whenever the current log file is nearly `maxBytes` in length; but if either of `maxBytes` or `backupCount` is zero, @@ -139,23 +223,28 @@ def create_log_handler( if filename is None: handler = StreamHandler(stream=sys.stdout, **kwargs) - handler.setFormatter(log_stream_formatter) + formatter = log_stream_formatter elif not rotating: handler = FileHandler(filename, mode=mode, encoding=encoding, **kwargs) - handler.setFormatter(log_file_formatter) + formatter = log_file_formatter else: handler = RotatingFileHandler( filename, mode=mode, encoding=encoding, maxBytes=maxBytes, backupCount=backupCount, **kwargs ) - handler.setFormatter(log_file_formatter) + formatter = log_file_formatter + + if format == "json": + formatter = json_formatter handler.setLevel(level) + handler.setFormatter(formatter) return handler logging.setLoggerClass(ModmailLogger) log_level = logging.INFO loggers = set() + ch = create_log_handler(level=log_level) ch_debug: Optional[RotatingFileHandler] = None @@ -171,7 +260,11 @@ def getLogger(name=None) -> ModmailLogger: def configure_logging(bot) -> None: - global ch_debug, log_level + global ch_debug, log_level, ch + + stream_log_format, file_log_format = bot.config["stream_log_format"], bot.config["file_log_format"] + if stream_log_format == "json": + ch.setFormatter(json_formatter) logger = getLogger(__name__) level_text = bot.config["log_level"].upper() @@ -196,8 +289,15 @@ def configure_logging(bot) -> None: logger.info("Log file: %s", bot.log_file_path) ch_debug = create_log_handler(bot.log_file_path, rotating=True) + + if file_log_format == "json": + ch_debug.setFormatter(json_formatter) + ch.setLevel(log_level) + logger.info("Stream log format: %s", stream_log_format) + logger.info("File log format: %s", file_log_format) + for log in loggers: log.setLevel(log_level) log.addHandler(ch_debug) From d777ab218eaa016589c1648ccdf1cea415c712eb Mon Sep 17 00:00:00 2001 From: Cordila <49218334+cordila@users.noreply.github.com> Date: Sat, 15 Jul 2023 04:40:51 -0700 Subject: [PATCH 07/12] `logs id` command (#3196) * Update modmail.py * Update clients.py * Formatting * Change log id to log key, added id as an alias * Print the log even if it is closed and fix bug * Update modmail.py * Added a missing period * Updated changelog --------- Co-authored-by: Taku <45324516+Taaku18@users.noreply.github.com> (cherry picked from commit b1f3645a830dd914158315af1daea47f6479b9f5) Signed-off-by: Khakers <22665282+khakers@users.noreply.github.com> --- CHANGELOG.md | 2 ++ cogs/modmail.py | 22 ++++++++++++++++++++++ core/clients.py | 10 ++++++++++ 3 files changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fc1c0d0d7..ef37841859 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ however, insignificant breaking changes do not guarantee a major version bump, s ### Added - Added `content_type` to attachments stored in the database. +- `?log key ` to retrieve the log link and view a preview using a log key. ([PR #3196](https://github.com/modmail-dev/Modmail/pull/3196)) + ### Changed - Changing a threads title or NSFW status immediately updates the status in the database. diff --git a/cogs/modmail.py b/cogs/modmail.py index ecfb63bc14..0c4e322f33 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -1212,6 +1212,28 @@ async def logs_closed_by(self, ctx, *, user: User = None): session = EmbedPaginatorSession(ctx, *embeds) await session.run() + @logs.command(name="key", aliases=["id"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def logs_key(self, ctx, key: str): + """ + Get the log link for the specified log key. + """ + icon_url = ctx.author.avatar.url + + logs = await self.bot.api.find_log_entry(key) + + if not logs: + embed = discord.Embed( + color=self.bot.error_color, + description=f"Log entry `{key}` not found.", + ) + return await ctx.send(embed=embed) + + embeds = self.format_log_embeds(logs, avatar_url=icon_url) + + session = EmbedPaginatorSession(ctx, *embeds) + await session.run() + @logs.command(name="delete", aliases=["wipe"]) @checks.has_permissions(PermissionLevel.OWNER) async def logs_delete(self, ctx, key_or_link: str): diff --git a/core/clients.py b/core/clients.py index e80225d9e9..db9932786e 100644 --- a/core/clients.py +++ b/core/clients.py @@ -356,6 +356,9 @@ async def validate_database_connection(self): async def get_user_logs(self, user_id: Union[str, int]) -> list: return NotImplemented + async def find_log_entry(self, key: str) -> list: + return NotImplemented + async def get_latest_user_logs(self, user_id: Union[str, int]): return NotImplemented @@ -538,6 +541,13 @@ async def get_user_logs(self, user_id: Union[str, int]) -> list: return await self.logs.find(query, projection).to_list(None) + async def find_log_entry(self, key: str) -> list: + query = {"key": key} + projection = {"messages": {"$slice": 5}} + logger.debug(f"Retrieving log ID {key}.") + + return await self.logs.find(query, projection).to_list(None) + async def get_latest_user_logs(self, user_id: Union[str, int]): query = {"recipient.id": str(user_id), "guild_id": str(self.bot.guild_id), "open": False} projection = {"messages": {"$slice": 5}} From e67f04c83c851628f01f30d7ade614dc481a34cc Mon Sep 17 00:00:00 2001 From: Jerrie <70805800+jerrie-aries@users.noreply.github.com> Date: Sat, 15 Jul 2023 04:48:33 -0700 Subject: [PATCH 08/12] Cleanup after unloading extension. (#3226) * Cleanup after unloading extension, resolve #3223. * Remove leftover modules loaded from `plugins` path when purging. --------- Co-authored-by: Taku <45324516+Taaku18@users.noreply.github.com> (cherry picked from commit a784f8299d023428e9a9d5a6fde0e06aab4d4feb) Signed-off-by: Khakers <22665282+khakers@users.noreply.github.com> --- CHANGELOG.md | 1 + cogs/plugins.py | 37 ++++++++++++++++++++++++++----------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef37841859..34acfcc678 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ however, insignificant breaking changes do not guarantee a major version bump, s - Persistent notes have been fixed after the previous discord.py update. - `is_image` now is true only if the image is actually an image. - Fix contact command reporting user was blocked when they weren't. +- Cleanup imports after removing/unloading a plugin. ([PR #3226](https://github.com/modmail-dev/Modmail/pull/3226)) ### Internal - Add `update_title` and `update_nsfw` methods to `ApiClient` to update thread title and nsfw status in the database. diff --git a/cogs/plugins.py b/cogs/plugins.py index d6bc69e2d1..28ba193e0b 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -263,6 +263,17 @@ async def load_plugin(self, plugin): logger.error("Plugin load failure: %s", plugin.ext_string, exc_info=True) raise InvalidPluginError("Cannot load extension, plugin invalid.") from exc + async def unload_plugin(self, plugin: Plugin) -> None: + try: + await self.bot.unload_extension(plugin.ext_string) + except commands.ExtensionError as exc: + raise exc + + ext_parent = ".".join(plugin.ext_string.split(".")[:-1]) + for module in list(sys.modules.keys()): + if module == ext_parent or module.startswith(ext_parent + "."): + del sys.modules[module] + async def parse_user_input(self, ctx, plugin_name, check_version=False): if not self.bot.config["enable_plugins"]: embed = discord.Embed( @@ -376,7 +387,7 @@ async def plugins_add(self, ctx, *, plugin_name: str): logger.warning("Unable to download plugin %s.", plugin, exc_info=True) embed = discord.Embed( - description=f"Failed to download plugin, check logs for error.\n{type(e)}: {e}", + description=f"Failed to download plugin, check logs for error.\n{type(e).__name__}: {e}", color=self.bot.error_color, ) @@ -394,7 +405,7 @@ async def plugins_add(self, ctx, *, plugin_name: str): logger.warning("Unable to load plugin %s.", plugin, exc_info=True) embed = discord.Embed( - description=f"Failed to download plugin, check logs for error.\n{type(e)}: {e}", + description=f"Failed to load plugin, check logs for error.\n{type(e).__name__}: {e}", color=self.bot.error_color, ) @@ -435,7 +446,7 @@ async def plugins_remove(self, ctx, *, plugin_name: str): if self.bot.config.get("enable_plugins"): try: - await self.bot.unload_extension(plugin.ext_string) + await self.unload_plugin(plugin) self.loaded_plugins.remove(plugin) except (commands.ExtensionNotLoaded, KeyError): logger.warning("Plugin was never loaded.") @@ -477,9 +488,10 @@ async def update_plugin(self, ctx, plugin_name): await self.download_plugin(plugin, force=True) if self.bot.config.get("enable_plugins"): try: - await self.bot.unload_extension(plugin.ext_string) + await self.unload_plugin(plugin) except commands.ExtensionError: logger.warning("Plugin unload fail.", exc_info=True) + try: await self.load_plugin(plugin) except Exception: @@ -526,17 +538,20 @@ async def plugins_reset(self, ctx): for ext in list(self.bot.extensions): if not ext.startswith("plugins."): continue + logger.error("Unloading plugin: %s.", ext) try: - logger.error("Unloading plugin: %s.", ext) - await self.bot.unload_extension(ext) - except Exception: - logger.error("Failed to unload plugin: %s.", ext) - else: - if not self.loaded_plugins: - continue plugin = next((p for p in self.loaded_plugins if p.ext_string == ext), None) if plugin: + await self.unload_plugin(plugin) self.loaded_plugins.remove(plugin) + else: + await self.bot.unload_extension(ext) + except Exception: + logger.error("Failed to unload plugin: %s.", ext) + + for module in list(sys.modules.keys()): + if module.startswith("plugins."): + del sys.modules[module] self.bot.config["plugins"].clear() await self.bot.config.update() From d9043848064f2c07854dfdb8ca387c6a3e8b9431 Mon Sep 17 00:00:00 2001 From: Jerrie <70805800+jerrie-aries@users.noreply.github.com> Date: Sat, 15 Jul 2023 05:01:48 -0700 Subject: [PATCH 09/12] Fix rate limit issue on raw reaction add/remove events. (#3306) * Fix rate limit issue on raw reaction add/remove events. * Pasd message object to `find_linked_messages` since it is already fetched. --------- Co-authored-by: Taku <45324516+Taaku18@users.noreply.github.com> (cherry picked from commit ae99060a3fe4a0afcd2d2fa35af85e8fce5b8aaa) Signed-off-by: Khakers <22665282+khakers@users.noreply.github.com> --- CHANGELOG.md | 1 + bot.py | 40 +++++++++++++++++++++++++--------------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34acfcc678..5be206f927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ however, insignificant breaking changes do not guarantee a major version bump, s - Fixed uncached member issue in large guild for react_to_contact and ticket creation. - Fixed blocked roles improperly saving in `blocked_users` config. - Fixed `?block` command improperly parsing reason as timestamp. +- Rate limit issue when fetch the messages due to reaction linking. ([PR #3306](https://github.com/modmail-dev/Modmail/pull/3306)) ### Internal - `ConfigManager.get` no longer accepts two positional arguments: the `convert` argument is now keyword-only. diff --git a/bot.py b/bot.py index 52a420f1ed..9c941a179a 100644 --- a/bot.py +++ b/bot.py @@ -1247,25 +1247,36 @@ async def handle_reaction_events(self, payload): return channel = self.get_channel(payload.channel_id) - if not channel: # dm channel not in internal cache - _thread = await self.threads.find(recipient=user) - if not _thread: + thread = None + # dm channel not in internal cache + if not channel: + thread = await self.threads.find(recipient=user) + if not thread: + return + channel = await thread.recipient.create_dm() + if channel.id != payload.channel_id: + return + + from_dm = isinstance(channel, discord.DMChannel) + from_txt = isinstance(channel, discord.TextChannel) + if not from_dm and not from_txt: + return + + if not thread: + params = {"recipient": user} if from_dm else {"channel": channel} + thread = await self.threads.find(**params) + if not thread: return - channel = await _thread.recipient.create_dm() + # thread must exist before doing this API call try: message = await channel.fetch_message(payload.message_id) except (discord.NotFound, discord.Forbidden): return reaction = payload.emoji - close_emoji = await self.convert_emoji(self.config["close_emoji"]) - - if isinstance(channel, discord.DMChannel): - thread = await self.threads.find(recipient=user) - if not thread: - return + if from_dm: if ( payload.event_type == "REACTION_ADD" and message.embeds @@ -1273,7 +1284,7 @@ async def handle_reaction_events(self, payload): and self.config.get("recipient_thread_close") ): ts = message.embeds[0].timestamp - if thread and ts == thread.channel.created_at: + if ts == thread.channel.created_at: # the reacted message is the corresponding thread creation embed # closing thread return await thread.close(closer=user) @@ -1293,11 +1304,10 @@ async def handle_reaction_events(self, payload): logger.warning("Failed to find linked message for reactions: %s", e) return else: - thread = await self.threads.find(channel=channel) - if not thread: - return try: - _, *linked_messages = await thread.find_linked_messages(message.id, either_direction=True) + _, *linked_messages = await thread.find_linked_messages( + message1=message, either_direction=True + ) except ValueError as e: logger.warning("Failed to find linked message for reactions: %s", e) return From 70e1a37159068a2d592ea98c13db92cb343aadc3 Mon Sep 17 00:00:00 2001 From: Jerrie <70805800+jerrie-aries@users.noreply.github.com> Date: Mon, 20 Nov 2023 05:26:12 +0800 Subject: [PATCH 10/12] Fix bug with `?plugin update`. (#3295) (cherry picked from commit a94e7a984172801c03e9a316e93ea1a5b47df9fd) Signed-off-by: Khakers <22665282+khakers@users.noreply.github.com> --- CHANGELOG.md | 1 + cogs/plugins.py | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5be206f927..993045acec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ however, insignificant breaking changes do not guarantee a major version bump, s - Fixed blocked roles improperly saving in `blocked_users` config. - Fixed `?block` command improperly parsing reason as timestamp. - Rate limit issue when fetch the messages due to reaction linking. ([PR #3306](https://github.com/modmail-dev/Modmail/pull/3306)) +- Update command fails when the plugin is invalid. ([PR #3295](https://github.com/modmail-dev/Modmail/pull/3295)) ### Internal - `ConfigManager.get` no longer accepts two positional arguments: the `convert` argument is now keyword-only. diff --git a/cogs/plugins.py b/cogs/plugins.py index 28ba193e0b..b1f103cfa5 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -499,12 +499,12 @@ async def update_plugin(self, ctx, plugin_name): description=f"Failed to update {plugin.name}. This plugin will now be removed from your bot.", color=self.bot.error_color, ) - self.bot.config["plugins"].remove(plugin_name) - logger.debug("Failed to update %s. Removed plugin from config.", plugin_name) + self.bot.config["plugins"].remove(str(plugin)) + logger.debug("Failed to update %s. Removed plugin from config.", plugin) else: - logger.debug("Updated %s.", plugin_name) + logger.debug("Updated %s.", plugin) else: - logger.debug("Updated %s.", plugin_name) + logger.debug("Updated %s.", plugin) return await ctx.send(embed=embed) @plugins.command(name="update") From cc436b7dac5162267250a91f079fb2d8b905ebe2 Mon Sep 17 00:00:00 2001 From: Taku <45324516+taaku18@users.noreply.github.com> Date: Mon, 20 Nov 2023 14:02:21 -0800 Subject: [PATCH 11/12] Fixed compat with MissingRequiredArgument requiring additional param (cherry picked from commit 9464c5db97c5ce134a86333879d31ac06fe255c6) Signed-off-by: Khakers <22665282+khakers@users.noreply.github.com> --- cogs/modmail.py | 7 +++---- cogs/utility.py | 9 ++++----- core/thread.py | 5 +++-- core/utils.py | 10 ++++++++++ 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/cogs/modmail.py b/cogs/modmail.py index 0c4e322f33..f09da9fbe6 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -2,7 +2,6 @@ import re from datetime import timezone from itertools import zip_longest -from types import SimpleNamespace from typing import List, Literal, Optional, Tuple, Union import discord @@ -1166,7 +1165,7 @@ async def logs(self, ctx, *, user: User = None): if not user: thread = ctx.thread if not thread: - raise commands.MissingRequiredArgument(SimpleNamespace(name="member")) + raise commands.MissingRequiredArgument(DummyParam("user")) user = thread.recipient or await self.bot.get_or_fetch_user(thread.id) default_avatar = "https://cdn.discordapp.com/embed/avatars/0.png" @@ -1824,7 +1823,7 @@ async def block( user_or_role = ctx.thread.recipient if (ctx.thread and not user_or_role) else user_or_role if not user_or_role: - raise commands.MissingRequiredArgument(SimpleNamespace(name="user")) + raise commands.MissingRequiredArgument(DummyParam("user")) mention = getattr(user_or_role, "mention", f"`{user_or_role.id}`") @@ -1884,7 +1883,7 @@ async def unblock(self, ctx, *, user_or_role: Union[discord.User, discord.Role, user_or_role = ctx.thread.recipient if (ctx.thread and not user_or_role) else user_or_role if not user_or_role: - raise commands.MissingRequiredArgument(SimpleNamespace(name="user")) + raise commands.MissingRequiredArgument(DummyParam("user or role")) mention = getattr(user_or_role, "mention", f"`{user_or_role.id}`") diff --git a/cogs/utility.py b/cogs/utility.py index 2c722eb2c4..e1c609b65b 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -11,7 +11,6 @@ from json import JSONDecodeError from subprocess import PIPE from textwrap import indent -from types import SimpleNamespace from typing import Union import discord @@ -25,7 +24,7 @@ from core.changelog import Changelog from core.models import HostingMethod, InvalidConfigError, PermissionLevel, UnseenFormatter, getLogger from core.paginator import EmbedPaginatorSession, MessagePaginatorSession -from core.utils import trigger_typing, truncate +from core.utils import trigger_typing, truncate, DummyParam logger = getLogger(__name__) @@ -489,12 +488,12 @@ async def activity(self, ctx, activity_type: str.lower, *, message: str = ""): return await ctx.send(embed=embed) if not message: - raise commands.MissingRequiredArgument(SimpleNamespace(name="message")) + raise commands.MissingRequiredArgument(DummyParam("message")) try: activity_type = ActivityType[activity_type] except KeyError: - raise commands.MissingRequiredArgument(SimpleNamespace(name="activity")) + raise commands.MissingRequiredArgument(DummyParam("activity")) activity, _ = await self.set_presence(activity_type=activity_type, activity_message=message) @@ -539,7 +538,7 @@ async def status(self, ctx, *, status_type: str.lower): try: status = Status[status_type] except KeyError: - raise commands.MissingRequiredArgument(SimpleNamespace(name="status")) + raise commands.MissingRequiredArgument(DummyParam("status")) _, status = await self.set_presence(status=status) diff --git a/core/thread.py b/core/thread.py index 2518acd6d5..b8610db043 100644 --- a/core/thread.py +++ b/core/thread.py @@ -17,6 +17,7 @@ AcceptButton, ConfirmThreadCreationView, DenyButton, + DummyParam, create_thread_channel, get_joint_id, get_top_role, @@ -812,7 +813,7 @@ async def note( self, message: discord.Message, persistent=False, thread_creation=False ) -> discord.Message: if not message.content and not message.attachments: - raise MissingRequiredArgument(SimpleNamespace(name="msg")) + raise MissingRequiredArgument(DummyParam("msg")) msg = await self.send( message, @@ -833,7 +834,7 @@ async def reply( ) -> typing.Tuple[typing.List[discord.Message], discord.Message]: """Returns List[user_dm_msg] and thread_channel_msg""" if not message.content and not message.attachments: - raise MissingRequiredArgument(SimpleNamespace(name="msg")) + raise MissingRequiredArgument(DummyParam("msg")) if not any(g.get_member(self.id) for g in self.bot.guilds): return await message.channel.send( embed=discord.Embed( diff --git a/core/utils.py b/core/utils.py index 4ff6f569a4..0e4502efa8 100644 --- a/core/utils.py +++ b/core/utils.py @@ -41,6 +41,7 @@ "AcceptButton", "DenyButton", "ConfirmThreadCreationView", + "DummyParam", ] @@ -593,3 +594,12 @@ class ConfirmThreadCreationView(discord.ui.View): def __init__(self): super().__init__(timeout=20) self.value = None + + +class DummyParam: + """ + A dummy parameter that can be used for MissingRequiredArgument. + """ + def __init__(self, name): + self.name = name + self.displayed_name = name From 3ee60412423048208a22ad4ed7ef84c19afbe4e3 Mon Sep 17 00:00:00 2001 From: Taku <45324516+taaku18@users.noreply.github.com> Date: Tue, 21 Nov 2023 07:16:16 -0800 Subject: [PATCH 12/12] Remove pkg_resources and replaced with packaging, updated requirements.txt (cherry picked from commit 5482e94374da73161b3ba33df25de089f2950cc3) Signed-off-by: Khakers <22665282+khakers@users.noreply.github.com> --- bot.py | 6 +++--- cogs/plugins.py | 6 +++--- cogs/utility.py | 11 ++++++----- pdm.lock | 10 +++++----- pyproject.toml | 1 + requirements.txt | 2 +- 6 files changed, 19 insertions(+), 17 deletions(-) diff --git a/bot.py b/bot.py index 9c941a179a..5847bfff57 100644 --- a/bot.py +++ b/bot.py @@ -22,7 +22,7 @@ from discord.ext.commands import MemberConverter from discord.ext.commands.view import StringView from emoji import UNICODE_EMOJI -from pkg_resources import parse_version +from packaging.version import Version from core.blocklist import Blocklist, BlockReason @@ -190,7 +190,7 @@ async def load_extensions(self): @property def version(self): - return parse_version(__version__) + return Version(__version__) @property def api(self) -> ApiClient: @@ -1588,7 +1588,7 @@ async def autoupdate(self): changelog = await Changelog.from_url(self) latest = changelog.latest_version - if self.version < parse_version(latest.version): + if self.version < Version(latest.version): error = None data = {} try: diff --git a/cogs/plugins.py b/cogs/plugins.py index b1f103cfa5..d710ea631c 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -15,7 +15,7 @@ import discord from discord.ext import commands -from pkg_resources import parse_version +from packaging.version import Version from core import checks from core.models import PermissionLevel, getLogger @@ -299,7 +299,7 @@ async def parse_user_input(self, ctx, plugin_name, check_version=False): if check_version: required_version = details.get("bot_version", False) - if required_version and self.bot.version < parse_version(required_version): + if required_version and self.bot.version < Version(required_version): embed = discord.Embed( description="Your bot's version is too low. " f"This plugin requires version `{required_version}`.", @@ -687,7 +687,7 @@ async def plugins_registry(self, ctx, *, plugin_name: typing.Union[int, str] = N embed.set_footer(text="This plugin is currently loaded.") else: required_version = details.get("bot_version", False) - if required_version and self.bot.version < parse_version(required_version): + if required_version and self.bot.version < Version(required_version): embed.set_footer( text="Your bot is unable to install this plugin, " f"minimum required version is v{required_version}." diff --git a/cogs/utility.py b/cogs/utility.py index e1c609b65b..736783a6d8 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -14,11 +14,12 @@ from typing import Union import discord -from aiohttp import ClientResponseError from discord.enums import ActivityType, Status from discord.ext import commands, tasks from discord.ext.commands.view import StringView -from pkg_resources import parse_version + +from aiohttp import ClientResponseError +from packaging.version import Version from core import checks, migrations, utils from core.changelog import Changelog @@ -351,9 +352,9 @@ async def about(self, ctx: commands.Context): latest = changelog.latest_version if self.bot.version.is_prerelease: - stable = next(filter(lambda v: not parse_version(v.version).is_prerelease, changelog.versions)) + stable = next(filter(lambda v: not Version(v.version).is_prerelease, changelog.versions)) footer = f"You are on the prerelease version • the latest version is v{stable.version}." - elif self.bot.version < parse_version(latest.version): + elif self.bot.version < Version(latest.version): footer = f"A newer version is available v{latest.version}." else: footer = "You are up to date with the latest version." @@ -1953,7 +1954,7 @@ async def update(self, ctx, *, flag: str = ""): "(https://github.com/raidensakura/modmail/blob/stable/bot.py#L1)" ) - if self.bot.version >= parse_version(latest.version) and flag.lower() != "force": + if self.bot.version >= Version(latest.version) and flag.lower() != "force": embed = discord.Embed(title="Already up to date", description=desc, color=self.bot.main_color) data = await self.bot.api.get_user_info() diff --git a/pdm.lock b/pdm.lock index c6ee866149..dfb32dab74 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev"] strategy = ["cross_platform"] lock_version = "4.4.1" -content_hash = "sha256:4c67644ed00e2a2cf4a09ff3714ef9f8460fbafcfa70dd6e56beaefba85a63c5" +content_hash = "sha256:ceed3f763518a48f4c4ad9e436233d98ccabcf2385422ff2f84683a62d146854" [[package]] name = "aiohttp" @@ -470,12 +470,12 @@ files = [ [[package]] name = "packaging" -version = "23.2" -requires_python = ">=3.7" +version = "24.1" +requires_python = ">=3.8" summary = "Core utilities for Python packages" files = [ - {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, - {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 6fffa0af3d..6a4b165eb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,7 @@ dependencies = [ "strenum", "discord-py~=2.3.0", "setuptools>=69.0.3", + "packaging>=24.1", ] requires-python = ">=3.10" readme = "README.md" diff --git a/requirements.txt b/requirements.txt index 590b7fee11..1122e8164a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ multidict==6.0.4 mypy-extensions==1.0.0 natural==0.2.0 nodeenv==1.8.0 -packaging==23.2 +packaging==24.1 parsedatetime==2.6 pathspec==0.11.2 platformdirs==3.11.0