From 288d52924091a885d0db95c1dff9026fb578a064 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Sun, 30 Jun 2019 15:00:13 -0700 Subject: [PATCH 01/50] Initial commit --- bot.py | 24 +++++++++++++----------- core/config.py | 46 ++++++++++++++++++++++++++++++++++++++++++++-- core/thread.py | 4 ++-- 3 files changed, 59 insertions(+), 15 deletions(-) diff --git a/bot.py b/bot.py index d2e1ff82cc..06d72575b2 100644 --- a/bot.py +++ b/bot.py @@ -20,9 +20,7 @@ from colorama import init, Fore, Style from emoji import UNICODE_EMOJI from motor.motor_asyncio import AsyncIOMotorClient -from pkg_resources import parse_version -from core.changelog import Changelog from core.clients import ApiClient, PluginDatabaseClient from core.config import ConfigManager from core.utils import info, error, human_join @@ -68,9 +66,15 @@ def __init__(self): self._connected = asyncio.Event() self._configure_logging() - # TODO: Raise fatal error if mongo_uri or other essentials are not found - self._db = AsyncIOMotorClient(self.config.mongo_uri).modmail_bot - self._api = ApiClient(self) + + self.standalone = 'mongo_uri' not in self.config.cache + + if not self.standalone: + self._db = AsyncIOMotorClient(self.config.mongo_uri).modmail_bot + else: + self._db = None + + self.api = ApiClient(self) self.plugin_db = PluginDatabaseClient(self) self.metadata_task = self.loop.create_task(self.metadata_loop()) @@ -128,13 +132,11 @@ def version(self) -> str: return __version__ @property - def db(self) -> typing.Optional[AsyncIOMotorClient]: + def db(self) -> AsyncIOMotorClient: + if self._db is None: + raise ValueError('No database instance found.') return self._db - @property - def api(self) -> ApiClient: - return self._api - @property def config(self) -> ConfigManager: if self._config is None: @@ -743,7 +745,7 @@ async def on_raw_reaction_add(self, payload): ts = message.embeds[0].timestamp if message.embeds else None if thread and ts == thread.channel.created_at: # the reacted message is the corresponding thread creation embed - if not self.config.get("disable_recipient_thread_close"): + if self.config.get("recipient_thread_close"): await thread.close(closer=user) elif not isinstance(channel, discord.DMChannel): if not message.embeds: diff --git a/core/config.py b/core/config.py index 32f9dacf79..6b083d54f7 100644 --- a/core/config.py +++ b/core/config.py @@ -5,6 +5,7 @@ import isodate +import discord from discord.ext.commands import BadArgument from core._color_data import ALL_COLORS @@ -122,7 +123,7 @@ def cache(self, val: dict): self._cache = val def populate_cache(self) -> dict: - data = { + defaults = { "snippets": {}, "plugins": [], "aliases": {}, @@ -134,10 +135,51 @@ def populate_cache(self) -> dict: "notification_squad": {}, "subscriptions": {}, "closures": {}, + "twitch_url": 'https://www.twitch.tv/discord-modmail/', + "main_category_id": None, + "disable_autoupdates": False, + "prefix": '?', + "mention": None, + "main_color": discord.Color.blurple(), + "user_typing": False, + "mod_typing": False, + "account_age": None, + "guild_age": None, + "reply_without_command": False, + "log_channel_id": None, + "sent_emoji": '✅', + "blocked_emoji": '🚫', + "close_emoji": '🔒', + "recipient_thread_close": False, + "thread_auto_close": 0, + "thread_auto_close_response": None, + "thread_creation_response": "The staff team will get back to you as soon as possible.", + "thread_creation_footer": None, + "thread_creation_title": 'Thread Created', + "thread_close_footer": 'Replying will create a new thread', + "thread_close_title": 'Thread Closed', + "thread_close_response": '{closer.mention} has closed this Modmail thread.', + "thread_self_close_response": 'You have closed this Modmail thread.', + "recipient_color": discord.Color.gold(), + "mod_tag": None, + "mod_color": discord.Color.green(), + "anon_username": None, + "anon_avatar_url": None, + "anon_tag": 'Response', + "activity_message": None, + "activity_type": None, + "status": None, + "modmail_guild_id": None, + "guild_id": None, + "mongo_uri": None, + "owners": None, + "token": None, + "github_access_token": None, + "log_url": 'https://example.com/', "log_level": "INFO", } - data.update(os.environ) + defaults.update(os.environ) if os.path.exists("config.json"): with open("config.json") as f: diff --git a/core/thread.py b/core/thread.py index ad37f4d05d..107ec22773 100644 --- a/core/thread.py +++ b/core/thread.py @@ -163,7 +163,7 @@ async def send_genesis_message(): ) footer = "Your message has been sent" - if not self.bot.config.get("disable_recipient_thread_close"): + if self.bot.config.get("recipient_thread_close"): footer = "Click the lock to close the thread" footer = self.bot.config.get("thread_creation_footer", footer) @@ -172,7 +172,7 @@ async def send_genesis_message(): if creator is None: msg = await recipient.send(embed=embed) - if not self.bot.config.get("disable_recipient_thread_close"): + if self.bot.config.get("recipient_thread_close"): close_emoji = self.bot.config.get("close_emoji", "🔒") close_emoji = await self.bot.convert_emoji(close_emoji) await msg.add_reaction(close_emoji) From aa29158914f1f820aa728af8282da68cbf873582 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Mon, 1 Jul 2019 13:35:47 -0700 Subject: [PATCH 02/50] Major commit --- CHANGELOG.md | 16 ++ bot.py | 358 ++++++++++++++++++++++++--------------------- cogs/modmail.py | 40 ++--- cogs/plugins.py | 28 ++-- cogs/utility.py | 85 +++++------ core/changelog.py | 9 +- core/checks.py | 4 +- core/clients.py | 214 +-------------------------- core/config.py | 281 ++++++++++++++--------------------- core/decorators.py | 1 - core/thread.py | 140 +++++++++--------- core/time.py | 2 +- core/utils.py | 7 + 13 files changed, 478 insertions(+), 707 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c764130b53..adcaf554e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +# v3.1.0 + +### Breaking + +- `disable_recipient_thread_close` is removed, a new configuration variable `recipient_thread_close` replaces it which defaults to False. +- Truthy and falsy values for binary configuration variables are now interpreted respectfully. + + +### Changes + +- `thread_auto_close_response` has a configurable variable `{timeout}`. + +### Internal + +- Removed supporting code for GitHub interaction. + # v3.0.3 ### Added diff --git a/bot.py b/bot.py index 06d72575b2..406521dac4 100644 --- a/bot.py +++ b/bot.py @@ -23,7 +23,7 @@ from core.clients import ApiClient, PluginDatabaseClient from core.config import ConfigManager -from core.utils import info, error, human_join +from core.utils import info, error, human_join, strtobool from core.models import PermissionLevel from core.thread import ThreadManager from core.time import human_timedelta @@ -58,22 +58,20 @@ def format(self, record): class ModmailBot(commands.Bot): def __init__(self): super().__init__(command_prefix=None) # implemented in `get_prefix` - self._threads = None - self._session = None - self._config = None - self._db = None + self._session = ClientSession(loop=self.loop) + self._config = ConfigManager(self) + self.start_time = datetime.utcnow() self._connected = asyncio.Event() self._configure_logging() - self.standalone = 'mongo_uri' not in self.config.cache - - if not self.standalone: - self._db = AsyncIOMotorClient(self.config.mongo_uri).modmail_bot - else: - self._db = None + mongo_uri = self.config['mongo_uri'] + if mongo_uri is None: + raise ValueError('A Mongo URI is necessary for the bot to function.') + self._db = AsyncIOMotorClient(mongo_uri).modmail_bot + self._threads = ThreadManager(self) self.api = ApiClient(self) self.plugin_db = PluginDatabaseClient(self) @@ -95,7 +93,7 @@ def uptime(self) -> str: return fmt.format(d=days, h=hours, m=minutes, s=seconds) def _configure_logging(self): - level_text = self.config.log_level.upper() + level_text = self.config['log_level'].upper() logging_levels = { "CRITICAL": logging.CRITICAL, "ERROR": logging.ERROR, @@ -104,7 +102,7 @@ def _configure_logging(self): "DEBUG": logging.DEBUG, } - log_file_name = self.config.token.split(".")[0] + log_file_name = self.token.split(".")[0] ch_debug = logging.FileHandler( os.path.join(temp_dir, f"{log_file_name}.log"), mode="a+" ) @@ -139,20 +137,14 @@ def db(self) -> AsyncIOMotorClient: @property def config(self) -> ConfigManager: - if self._config is None: - self._config = ConfigManager(self) return self._config @property def session(self) -> ClientSession: - if self._session is None: - self._session = ClientSession(loop=self.loop) return self._session @property def threads(self) -> ThreadManager: - if self._threads is None: - self._threads = ThreadManager(self) return self._threads async def get_prefix(self, message=None): @@ -183,8 +175,6 @@ def run(self, *args, **kwargs): self.loop.run_until_complete(self.start(self.token)) except discord.LoginFailure: logger.critical(error("Invalid token")) - except KeyboardInterrupt: - pass except Exception: logger.critical(error("Fatal exception"), exc_info=True) finally: @@ -192,7 +182,7 @@ def run(self, *args, **kwargs): self.metadata_task.cancel() self.loop.run_until_complete(self.metadata_task) except asyncio.CancelledError: - logger.debug(info("data_task has been cancelled.")) + logger.debug(info("metadata_task has been cancelled.")) self.loop.run_until_complete(self.logout()) for task in asyncio.Task.all_tasks(): @@ -207,37 +197,63 @@ def run(self, *args, **kwargs): logger.info(error(" - Shutting down bot - ")) async def is_owner(self, user: discord.User) -> bool: - raw = str(self.config.get("owners", "0")).split(",") - allowed = {int(x) for x in raw} - return (user.id in allowed) or await super().is_owner(user) + owners = self.config['owners'] + if owners is not None: + if user.id in set(map(int, str(owners).split(','))): + return True + return await super().is_owner(user) @property def log_channel(self) -> typing.Optional[discord.TextChannel]: - channel_id = self.config.get("log_channel_id") + channel_id = self.config["log_channel_id"] if channel_id is not None: - return self.get_channel(int(channel_id)) + channel = self.get_channel(int(channel_id)) + if channel is not None: + return channel + self.config.remove('log_channel_id') if self.main_category is not None: - return self.main_category.channels[0] - return None + try: + return self.main_category.channels[0] + except IndexError: + pass + logger.info(info(f'No log channel set, set one with `%ssetup` or ' + f'`%sconfig set log_channel_id `.'), self.prefix, self.prefix) + + @property + def is_connected(self) -> bool: + return self._connected.is_set() + + async def wait_for_connected(self) -> None: + await self.wait_until_ready() + await self._connected.wait() + await self.config.wait_until_ready() @property def snippets(self) -> typing.Dict[str, str]: - return {k: v for k, v in self.config.get("snippets", {}).items() if v} + return self.config["snippets"] @property def aliases(self) -> typing.Dict[str, str]: - return {k: v for k, v in self.config.get("aliases", {}).items() if v} + return self.config["aliases"] @property def token(self) -> str: - return self.config.token + token = self.config['token'] + if token is None: + raise ValueError('TOKEN must be set, this is your bot token.') + return token @property - def guild_id(self) -> int: - return int(self.config.guild_id) + def guild_id(self) -> typing.Optional[int]: + guild_id = self.config['guild_id'] + if guild_id is not None: + try: + return int(str(guild_id)) + except ValueError: + raise ValueError('Invalid guild_id set.') @property - def guild(self) -> discord.Guild: + def guild(self) -> typing.Optional[discord.Guild]: """ The guild that the bot is serving (the server where users message it from) @@ -245,90 +261,92 @@ def guild(self) -> discord.Guild: return discord.utils.get(self.guilds, id=self.guild_id) @property - def modmail_guild(self) -> discord.Guild: + def modmail_guild(self) -> typing.Optional[discord.Guild]: """ The guild that the bot is operating in (where the bot is creating threads) """ - modmail_guild_id = self.config.get("modmail_guild_id") - if not modmail_guild_id: + modmail_guild_id = self.config["modmail_guild_id"] + if modmail_guild_id is None: return self.guild - return discord.utils.get(self.guilds, id=int(modmail_guild_id)) + guild = discord.utils.get(self.guilds, id=int(modmail_guild_id)) + if guild is not None: + return guild + self.config.remove('modmail_guild_id') + logger.error(error('Invalid modmail_guild_id set.')) + return self.guild @property def using_multiple_server_setup(self) -> bool: return self.modmail_guild != self.guild @property - def main_category(self) -> typing.Optional[discord.TextChannel]: - category_id = self.config.get("main_category_id") - if category_id is not None: - return discord.utils.get(self.modmail_guild.categories, id=int(category_id)) - - if self.modmail_guild: + def main_category(self) -> typing.Optional[discord.CategoryChannel]: + if self.modmail_guild is not None: + category_id = self.config["main_category_id"] + if category_id is not None: + cat = discord.utils.get(self.modmail_guild.categories, id=int(category_id)) + if cat is not None: + return cat + self.config.remove("main_category_id") return discord.utils.get(self.modmail_guild.categories, name="Modmail") - return None @property def blocked_users(self) -> typing.Dict[str, str]: - return self.config.get("blocked", {}) + return self.config["blocked"] @property def blocked_whitelisted_users(self) -> typing.List[str]: - return self.config.get("blocked_whitelist", []) + return self.config["blocked_whitelist"] @property def prefix(self) -> str: - return self.config.get("prefix", "?") + return str(self.config["prefix"]) @property def mod_color(self) -> typing.Union[discord.Color, int]: color = self.config.get("mod_color") - if not color: - return discord.Color.green() + if isinstance(color, discord.Color): + return color try: - color = int(color.lstrip("#"), base=16) + return int(color.lstrip("#"), base=16) except ValueError: - logger.error(error("Invalid mod_color provided")) - return discord.Color.green() - else: - return color + logger.error(error("Invalid mod_color provided.")) + return self.config.remove('mod_color') @property def recipient_color(self) -> typing.Union[discord.Color, int]: color = self.config.get("recipient_color") - if not color: - return discord.Color.gold() + if isinstance(color, discord.Color): + return color try: - color = int(color.lstrip("#"), base=16) + return int(color.lstrip("#"), base=16) except ValueError: - logger.error(error("Invalid recipient_color provided")) - return discord.Color.gold() - else: - return color + logger.error(error("Invalid recipient_color provided.")) + return self.config.remove('recipient_color') @property def main_color(self) -> typing.Union[discord.Color, int]: color = self.config.get("main_color") - if not color: - return discord.Color.blurple() + if isinstance(color, discord.Color): + return color try: - color = int(color.lstrip("#"), base=16) + return int(color.lstrip("#"), base=16) except ValueError: - logger.error(error("Invalid main_color provided")) - return discord.Color.blurple() - else: - return color + logger.error(error("Invalid main_color provided.")) + return self.config.remove('main_color') async def on_connect(self): logger.info(LINE) - await self.validate_database_connection() + try: + await self.validate_database_connection() + except Exception: + return await self.logout() + logger.info(LINE) logger.info(info("Connected to gateway.")) - await self.config.refresh() - if self.db: - await self.setup_indexes() + await self.setup_indexes() self._connected.set() async def setup_indexes(self): @@ -357,30 +375,26 @@ async def setup_indexes(self): async def on_ready(self): """Bot startup, sets uptime.""" - await self._connected.wait() + + # Wait until config cache is populated with stuff from db and on_connect ran + await self.wait_for_connected() + logger.info(LINE) logger.info(info("Client ready.")) logger.info(LINE) logger.info(info(f"Logged in as: {self.user}")) logger.info(info(f"User ID: {self.user.id}")) logger.info(info(f"Prefix: {self.prefix}")) - logger.info(info(f"Guild Name: {self.guild.name if self.guild else 'None'}")) - logger.info(info(f"Guild ID: {self.guild.id if self.guild else 0}")) - + logger.info(info(f"Guild Name: {self.guild.name if self.guild else 'Invalid'}")) + logger.info(info(f"Guild ID: {self.guild.id if self.guild else 'Invalid'}")) logger.info(LINE) - if not self.guild: - logger.error(error("WARNING - The GUILD_ID " "provided does not exist!")) - else: - await self.threads.populate_cache() - - # Wait until config cache is populated with stuff from db - await self.config.wait_until_ready() + await self.threads.populate_cache() # closures - closures = self.config.closures.copy() + closures = self.config['closures'] logger.info( - info(f"There are {len(closures)} thread(s) " "pending to be closed.") + info(f"There are {len(closures)} thread(s) pending to be closed.") ) for recipient_id, items in closures.items(): @@ -394,7 +408,7 @@ async def on_ready(self): if not thread: # If the channel is deleted - self.config.closures.pop(str(recipient_id)) + self.config['closures'].pop(recipient_id) await self.config.update() continue @@ -423,27 +437,24 @@ async def convert_emoji(self, name: str) -> str: async def retrieve_emoji(self) -> typing.Tuple[str, str]: - sent_emoji = self.config.get("sent_emoji", "✅") - blocked_emoji = self.config.get("blocked_emoji", "🚫") + sent_emoji = self.config["sent_emoji"] + blocked_emoji = self.config["blocked_emoji"] if sent_emoji != "disable": try: sent_emoji = await self.convert_emoji(sent_emoji) except commands.BadArgument: - logger.warning(info("Removed sent emoji (%s)."), sent_emoji) - del self.config.cache["sent_emoji"] - await self.config.update() - sent_emoji = "✅" + logger.warning(error("Removed sent emoji (%s)."), sent_emoji) + sent_emoji = self.config.remove("sent_emoji") if blocked_emoji != "disable": try: blocked_emoji = await self.convert_emoji(blocked_emoji) except commands.BadArgument: - logger.warning(info("Removed blocked emoji (%s)."), blocked_emoji) - del self.config.cache["blocked_emoji"] - await self.config.update() - blocked_emoji = "🚫" + logger.warning(error("Removed blocked emoji (%s)."), blocked_emoji) + blocked_emoji = self.config.remove("blocked_emoji") + await self.config.update() return sent_emoji, blocked_emoji async def _process_blocked(self, message: discord.Message) -> bool: @@ -451,7 +462,7 @@ async def _process_blocked(self, message: discord.Message) -> bool: if str(message.author.id) in self.blocked_whitelisted_users: if str(message.author.id) in self.blocked_users: - del self.config.blocked[str(message.author.id)] + self.blocked_users.pop(str(message.author.id)) await self.config.update() if sent_emoji != "disable": @@ -464,11 +475,10 @@ async def _process_blocked(self, message: discord.Message) -> bool: now = datetime.utcnow() - account_age = self.config.get("account_age") - guild_age = self.config.get("guild_age") - if account_age is None: - account_age = isodate.duration.Duration() - else: + account_age = self.config["account_age"] + guild_age = self.config["guild_age"] + + if not isinstance(account_age, isodate.Duration): try: account_age = isodate.parse_duration(account_age) except isodate.ISO8601Error: @@ -478,13 +488,9 @@ async def _process_blocked(self, message: discord.Message) -> bool: 'greater than 0 days, not "%s".', str(account_age), ) - del self.config.cache["account_age"] - await self.config.update() - account_age = isodate.duration.Duration() + account_age = self.config.remove("account_age") - if guild_age is None: - guild_age = isodate.duration.Duration() - else: + if not isinstance(guild_age, isodate.Duration): try: guild_age = isodate.parse_duration(guild_age) except isodate.ISO8601Error: @@ -494,33 +500,24 @@ async def _process_blocked(self, message: discord.Message) -> bool: 'greater than 0 days, not "%s".', str(guild_age), ) - del self.config.cache["guild_age"] - await self.config.update() - guild_age = isodate.duration.Duration() + guild_age = self.config.remove("guild_age") - reason = self.blocked_users.get(str(message.author.id)) - if reason is None: - reason = "" + reason = self.blocked_users.get(str(message.author.id)) or '' + min_guild_age = min_account_age = now try: min_account_age = message.author.created_at + account_age - except ValueError as exc: - logger.warning(exc.args[0]) - del self.config.cache["account_age"] - await self.config.update() - min_account_age = now + except ValueError: + logger.warning("Error with 'account_age'.", exc_info=True) + self.config.remove("account_age") try: - member = self.guild.get_member(message.author.id) - if member: - min_guild_age = member.joined_at + guild_age - else: - min_guild_age = now - except ValueError as exc: - logger.warning(exc.args[0]) - del self.config.cache["guild_age"] - await self.config.update() - min_guild_age = now + joined_at = getattr(message.author, 'joined_at', None) + if joined_at is not None: + min_guild_age = joined_at + guild_age + except ValueError: + logger.warning("Error with 'guild_age'.", exc_info=True) + self.config.remove("guild_age") if min_account_age > now: # User account has not reached the required time @@ -532,8 +529,7 @@ async def _process_blocked(self, message: discord.Message) -> bool: new_reason = ( f"System Message: New Account. Required to wait for {delta}." ) - self.config.blocked[str(message.author.id)] = new_reason - await self.config.update() + self.blocked_users[str(message.author.id)] = new_reason changed = True if reason.startswith("System Message: New Account.") or changed: @@ -556,8 +552,7 @@ async def _process_blocked(self, message: discord.Message) -> bool: new_reason = ( f"System Message: Recently Joined. Required to wait for {delta}." ) - self.config.blocked[str(message.author.id)] = new_reason - await self.config.update() + self.blocked_users[str(message.author.id)] = new_reason changed = True if reason.startswith("System Message: Recently Joined.") or changed: @@ -575,10 +570,9 @@ async def _process_blocked(self, message: discord.Message) -> bool: if reason.startswith("System Message: New Account.") or reason.startswith( "System Message: Recently Joined." ): - # Met the age limit already + # Met the age limit already, otherwise it would've been caught by the previous if's reaction = sent_emoji - del self.config.blocked[str(message.author.id)] - await self.config.update() + self.blocked_users.pop(str(message.author.id)) else: end_time = re.search(r"%(.+?)%$", reason) if end_time is not None: @@ -588,11 +582,11 @@ async def _process_blocked(self, message: discord.Message) -> bool: if after <= 0: # No longer blocked reaction = sent_emoji - del self.config.blocked[str(message.author.id)] - await self.config.update() + self.blocked_users.pop(str(message.author.id)) else: reaction = sent_emoji + await self.config.update() if reaction != "disable": try: await message.add_reaction(reaction) @@ -602,6 +596,8 @@ async def _process_blocked(self, message: discord.Message) -> bool: async def process_modmail(self, message: discord.Message) -> None: """Processes messages sent to the bot.""" + await self.wait_for_connected() + blocked = await self._process_blocked(message) if not blocked: thread = await self.threads.find_or_create(message.author) @@ -612,6 +608,7 @@ async def get_context(self, message, *, cls=commands.Context): Returns the invocation context from the message. Supports getting the prefix from database as well as command aliases. """ + await self.wait_for_connected() view = StringView(message.content) ctx = cls(prefix=None, view=view, bot=self, message=message) @@ -630,7 +627,7 @@ async def get_context(self, message, *, cls=commands.Context): invoker = view.get_word().lower() # Check if there is any aliases being called. - alias = self.config.get("aliases", {}).get(invoker) + alias = self.aliases.get(invoker) if alias is not None: ctx._alias_invoked = True len_ = len(f"{invoked_prefix}{invoker}") @@ -648,10 +645,10 @@ async def update_perms( self, name: typing.Union[PermissionLevel, str], value: int, add: bool = True ) -> None: if isinstance(name, PermissionLevel): - permissions = self.config.level_permissions + permissions = self.config['level_permissions'] name = name.name else: - permissions = self.config.command_permissions + permissions = self.config['command_permissions'] if name not in permissions: if add: permissions[name] = [value] @@ -666,6 +663,8 @@ async def update_perms( await self.config.update() async def on_message(self, message): + await self.wait_for_connected() + if message.type == discord.MessageType.pins_add and message.author == self.user: await message.delete() @@ -678,7 +677,7 @@ async def on_message(self, message): prefix = self.prefix if message.content.startswith(prefix): - cmd = message.content[len(prefix) :].strip() + cmd = message.content[len(prefix):].strip() if cmd in self.snippets: thread = await self.threads.find(channel=message.channel) snippet = self.snippets[cmd] @@ -692,7 +691,12 @@ async def on_message(self, message): thread = await self.threads.find(channel=ctx.channel) if thread is not None: - if self.config.get("reply_without_command"): + try: + reply_without_command = strtobool(self.config["reply_without_command"]) + except ValueError: + reply_without_command = self.config.remove('reply_without_command') + + if reply_without_command: await thread.reply(message) else: await self.api.append_log(message, type_="internal") @@ -703,30 +707,39 @@ async def on_message(self, message): self.dispatch("command_error", ctx, exc) async def on_typing(self, channel, user, _): + await self.wait_for_connected() + if user.bot: return if isinstance(channel, discord.DMChannel): - if not self.config.get("user_typing"): + try: + user_typing = strtobool(self.config["user_typing"]) + except ValueError: + user_typing = self.config.remove('user_typing') + if not user_typing: return + thread = await self.threads.find(recipient=user) if thread: await thread.channel.trigger_typing() else: - if not self.config.get("mod_typing"): + try: + mod_typing = strtobool(self.config["mod_typing"]) + except ValueError: + mod_typing = self.config.remove('mod_typing') + if not mod_typing: return + thread = await self.threads.find(channel=channel) - if thread and thread.recipient: + if thread is not None and thread.recipient: await thread.recipient.trigger_typing() async def on_raw_reaction_add(self, payload): - user = self.get_user(payload.user_id) - if user.bot: 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: @@ -736,18 +749,21 @@ async def on_raw_reaction_add(self, payload): message = await channel.fetch_message(payload.message_id) reaction = payload.emoji - close_emoji = await self.convert_emoji(self.config.get("close_emoji", "🔒")) + close_emoji = await self.convert_emoji(self.config["close_emoji"]) - if isinstance(channel, discord.DMChannel) and str(reaction) == str( - close_emoji - ): # closing thread - thread = await self.threads.find(recipient=user) - ts = message.embeds[0].timestamp if message.embeds else None - if thread and ts == thread.channel.created_at: - # the reacted message is the corresponding thread creation embed - if self.config.get("recipient_thread_close"): - await thread.close(closer=user) - elif not isinstance(channel, discord.DMChannel): + if isinstance(channel, discord.DMChannel): + if str(reaction) == str(close_emoji): # closing thread + thread = await self.threads.find(recipient=user) + ts = message.embeds[0].timestamp if message.embeds else None + if thread and ts == thread.channel.created_at: + # the reacted message is the corresponding thread creation embed + try: + recipient_thread_close = strtobool(self.config["recipient_thread_close"]) + except ValueError: + recipient_thread_close = self.config.remove('recipient_thread_close') + if recipient_thread_close: + await thread.close(closer=user) + else: if not message.embeds: return message_id = str(message.embeds[0].author.url).split("/")[-1] @@ -771,13 +787,18 @@ async def on_guild_channel_delete(self, channel): if mod == self.user: return + if isinstance(channel, discord.CategoryChannel): + if self.main_category.id == channel.id: + self.config.remove('main_category_id') + await self.config.update() + return + if not isinstance(channel, discord.TextChannel): - if int(self.config.get("main_category_id")) == channel.id: - await self.config.update({"main_category_id": None}) return - if int(self.config.get("log_channel_id")) == channel.id: - await self.config.update({"log_channel_id": None}) + if self.log_channel is None or self.log_channel.id == channel.id: + self.config.remove('log_channel_id') + await self.config.update() return thread = await self.threads.find(channel=channel) @@ -918,12 +939,12 @@ async def validate_database_connection(self): ) ) - return await self.logout() + raise else: logger.info(info("Successfully connected to the database.")) async def metadata_loop(self): - await self.wait_until_ready() + await self.wait_for_connected() if not self.guild: return @@ -955,7 +976,6 @@ async def metadata_loop(self): if __name__ == "__main__": if os.name != "nt": import uvloop - uvloop.install() bot = ModmailBot() bot.run() diff --git a/cogs/modmail.py b/cogs/modmail.py index 2c6d9e6068..96d95ac096 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -35,9 +35,15 @@ async def setup(self, ctx): once after configuring Modmail. """ - if self.bot.main_category: + if self.bot.main_category is not None: return await ctx.send(f"{self.bot.modmail_guild} is already set up.") + if self.bot.modmail_guild is None: + embed = discord.Embed(title='Error', + description='Modmail functioning guild not found.', + color=discord.Color.red()) + return await ctx.send(embed=embed) + category = await self.bot.modmail_guild.create_category( name="Modmail", overwrites=self.bot.overwrites(ctx) ) @@ -65,7 +71,7 @@ async def setup(self, ctx): ) embed.set_footer( - text=f'Type "{self.bot.prefix}help" ' "for a complete list of commands." + text=f'Type "{self.bot.prefix}help" for a complete list of commands.' ) await log_channel.send(embed=embed) @@ -80,7 +86,7 @@ async def setup(self, ctx): f"Type `{self.bot.prefix}permissions` for more info." ) - if not self.bot.config.get("permissions"): + if not self.bot.config["permissions"]: await self.bot.update_perms(PermissionLevel.REGULAR, -1) await self.bot.update_perms(PermissionLevel.OWNER, ctx.author.id) @@ -147,14 +153,14 @@ async def snippets_add(self, ctx, name: str.lower, *, value): {prefix}snippets add "two word" this is a two word snippet. ``` """ - if name in self.bot.config.snippets: + if name in self.bot.snippets: embed = discord.Embed( title="Error", color=discord.Color.red(), description=f"Snippet `{name}` already exists.", ) else: - self.bot.config.snippets[name] = value + self.bot.snippets[name] = value await self.bot.config.update() embed = discord.Embed( @@ -170,13 +176,13 @@ async def snippets_add(self, ctx, name: str.lower, *, value): async def snippets_remove(self, ctx, *, name: str.lower): """Remove a snippet.""" - if name in self.bot.config.snippets: + if name in self.bot.snippets: embed = discord.Embed( title="Removed snippet", color=self.bot.main_color, description=f"`{name}` no longer exists.", ) - del self.bot.config["snippets"][name] + self.bot.snippets.pop(name) await self.bot.config.update() else: @@ -191,8 +197,8 @@ async def snippets_remove(self, ctx, *, name: str.lower): @snippets.command(name="edit") @checks.has_permissions(PermissionLevel.SUPPORTER) async def snippets_edit(self, ctx, name: str.lower, *, value): - if name in self.bot.config.snippets: - self.bot.config.snippets[name] = value + if name in self.bot.snippets: + self.bot.snippets[name] = value await self.bot.config.update() embed = discord.Embed( @@ -337,7 +343,7 @@ async def notify( if mention in mentions: embed = discord.Embed( color=discord.Color.red(), - description=f"{mention} is already " "going to be mentioned.", + description=f"{mention} is already going to be mentioned.", ) else: mentions.append(mention) @@ -381,14 +387,14 @@ async def unnotify( if mention not in mentions: embed = discord.Embed( color=discord.Color.red(), - description=f"{mention} does not have a " "pending notification.", + description=f"{mention} does not have a pending notification.", ) else: mentions.remove(mention) await self.bot.config.update() embed = discord.Embed( color=self.bot.main_color, - description=f"{mention} will no longer " "be notified.", + description=f"{mention} will no longer be notified.", ) return await ctx.send(embed=embed) @@ -510,11 +516,11 @@ def format_log_embeds(self, logs, avatar_url): created_at = parser.parse(entry["created_at"]) - prefix = os.getenv("LOG_URL_PREFIX", "/logs") + prefix = self.bot.config["log_url_prefix"] if prefix == "NONE": prefix = "" - log_url = self.bot.config.log_url.strip("/") + f"{prefix}/{key}" + log_url = self.bot.config['log_url'].strip("/") + f"{prefix}/{key}" username = entry["recipient"]["name"] + "#" username += entry["recipient"]["discriminator"] @@ -869,7 +875,7 @@ async def blocked_whitelist(self, ctx, *, user: User = None): msg = self.bot.blocked_users.get(str(user.id)) if msg is None: msg = "" - del self.bot.config.blocked[str(user.id)] + self.bot.blocked_users.pop(str(user.id)) await self.bot.config.update() @@ -967,7 +973,7 @@ async def block( color=self.bot.main_color, description=f"{mention} is now blocked{extend}.", ) - self.bot.config.blocked[str(user.id)] = reason + self.bot.blocked_users[str(user.id)] = reason await self.bot.config.update() else: embed = discord.Embed( @@ -1003,7 +1009,7 @@ async def unblock(self, ctx, *, user: User = None): msg = self.bot.blocked_users.get(str(user.id)) if msg is None: msg = "" - del self.bot.config.blocked[str(user.id)] + self.bot.blocked_users.pop(str(user.id)) await self.bot.config.update() if msg.startswith("System Message: "): diff --git a/cogs/plugins.py b/cogs/plugins.py index c138af960e..bcfd2a7dd5 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -13,7 +13,6 @@ import discord from discord.ext import commands -from discord.utils import async_all from pkg_resources import parse_version from core import checks @@ -58,7 +57,8 @@ def parse_plugin(name): # returns: (username, repo, plugin_name, branch) # default branch = master try: - result = name.split("/") + # when names are formatted with inline code + result = name.strip('`').split("/") result[2] = "/".join(result[2:]) if "@" in result[2]: # branch is specified @@ -75,9 +75,9 @@ def parse_plugin(name): return tuple(result) async def download_initial_plugins(self): - await self.bot._connected.wait() + await self.bot.wait_for_connected() - for i in self.bot.config.plugins: + for i in self.bot.config['plugins']: username, repo, name, branch = self.parse_plugin(i) try: @@ -187,7 +187,7 @@ async def plugin_add(self, ctx, *, plugin_name: str): ) return await ctx.send(embed=embed) - if plugin_name in self.bot.config.plugins: + if plugin_name in self.bot.config['plugins']: embed = discord.Embed( description="This plugin is already installed.", color=self.bot.main_color, @@ -234,7 +234,7 @@ async def plugin_add(self, ctx, *, plugin_name: str): # if it makes it here, it has passed all checks and should # be entered into the config - self.bot.config.plugins.append(plugin_name) + self.bot.config['plugins'].append(plugin_name) await self.bot.config.update() embed = discord.Embed( @@ -261,7 +261,7 @@ async def plugin_remove(self, ctx, *, plugin_name: str): details["repository"] + "/" + plugin_name + "@" + details["branch"] ) - if plugin_name in self.bot.config.plugins: + if plugin_name in self.bot.config['plugins']: try: username, repo, name, branch = self.parse_plugin(plugin_name) @@ -271,12 +271,12 @@ async def plugin_remove(self, ctx, *, plugin_name: str): except Exception: pass - self.bot.config.plugins.remove(plugin_name) + self.bot.config['plugins'].remove(plugin_name) try: - # BUG: Local variables 'username' and 'repo' might be referenced before assignment + # BUG: Local variables 'username', 'branch' and 'repo' might be referenced before assignment if not any( - i.startswith(f"{username}/{repo}") for i in self.bot.config.plugins + i.startswith(f"{username}/{repo}") for i in self.bot.config['plugins'] ): # if there are no more of such repos, delete the folder def onerror(func, path, exc_info): # pylint: disable=W0613 @@ -290,7 +290,7 @@ def onerror(func, path, exc_info): # pylint: disable=W0613 ) except Exception as exc: logger.error(str(exc)) - self.bot.config.plugins.append(plugin_name) + self.bot.config['plugins'].append(plugin_name) logger.error(error(exc)) raise exc @@ -318,7 +318,7 @@ async def plugin_update(self, ctx, *, plugin_name: str): details["repository"] + "/" + plugin_name + "@" + details["branch"] ) - if plugin_name not in self.bot.config.plugins: + if plugin_name not in self.bot.config['plugins']: embed = discord.Embed( description="That plugin is not installed.", color=self.bot.main_color ) @@ -368,8 +368,8 @@ async def plugin_update(self, ctx, *, plugin_name: str): async def plugin_enabled(self, ctx): """Shows a list of currently enabled plugins.""" - if self.bot.config.plugins: - msg = "```\n" + "\n".join(self.bot.config.plugins) + "\n```" + if self.bot.config['plugins']: + msg = "```\n" + "\n".join(self.bot.config['plugins']) + "\n```" embed = discord.Embed(description=msg, color=self.bot.main_color) await ctx.send(embed=embed) else: diff --git a/cogs/utility.py b/cogs/utility.py index b1743f5eb3..d1909df4da 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -296,7 +296,7 @@ async def sponsors(self, ctx): async def debug(self, ctx): """Shows the recent application-logs of the bot.""" - log_file_name = self.bot.config.token.split(".")[0] + log_file_name = self.bot.token.split(".")[0] with open( os.path.join( @@ -352,7 +352,7 @@ async def debug_hastebin(self, ctx): """Posts application-logs to Hastebin.""" haste_url = os.environ.get("HASTE_URL", "https://hasteb.in") - log_file_name = self.bot.config.token.split(".")[0] + log_file_name = self.bot.token.split(".")[0] with open( os.path.join( @@ -389,7 +389,7 @@ async def debug_hastebin(self, ctx): async def debug_clear(self, ctx): """Clears the locally cached logs.""" - log_file_name = self.bot.config.token.split(".")[0] + log_file_name = self.bot.token.split(".")[0] with open( os.path.join( @@ -428,8 +428,8 @@ async def activity(self, ctx, activity_type: str.lower, *, message: str = ""): - `{prefix}activity clear` """ if activity_type == "clear": - self.bot.config["activity_type"] = None - self.bot.config["activity_message"] = None + self.bot.config.remove("activity_type") + self.bot.config.remove("activity_message") await self.bot.config.update() await self.set_presence() embed = Embed(title="Activity Removed", color=self.bot.main_color) @@ -474,7 +474,7 @@ async def status(self, ctx, *, status_type: str.lower): - `{prefix}status clear` """ if status_type == "clear": - self.bot.config["status"] = None + self.bot.config.remove("status") await self.bot.config.update() await self.set_presence() embed = Embed(title="Status Removed", color=self.bot.main_color) @@ -507,7 +507,7 @@ async def set_presence( activity = status = None if status_identifier is None: - status_identifier = self.bot.config.get("status", None) + status_identifier = self.bot.config["status"] status_by_key = False try: @@ -525,7 +525,7 @@ async def set_presence( raise ValueError( "activity_message must be None " "if activity_identifier is None." ) - activity_identifier = self.bot.config.get("activity_type", None) + activity_identifier = self.bot.config["activity_type"] activity_by_key = False try: @@ -540,7 +540,7 @@ async def set_presence( else: url = None activity_message = ( - activity_message or self.bot.config.get("activity_message", "") + activity_message or self.bot.config["activity_message"] ).strip() if activity_type == ActivityType.listening: @@ -549,9 +549,7 @@ async def set_presence( # discord automatically add the "to" activity_message = activity_message[3:].strip() elif activity_type == ActivityType.streaming: - url = self.bot.config.get( - "twitch_url", "https://www.twitch.tv/discord-modmail/" - ) + url = self.bot.config["twitch_url"] if activity_message: activity = Activity(type=activity_type, name=activity_message, url=url) @@ -578,14 +576,13 @@ async def set_presence( @commands.Cog.listener() async def on_ready(self): # Wait until config cache is populated with stuff from db - await self.bot.config.wait_until_ready() + await self.bot.wait_for_connected() logger.info(info(self.presence["activity"][1])) logger.info(info(self.presence["status"][1])) async def loop_presence(self): """Set presence to the configured value every hour.""" - await self.bot.config.wait_until_ready() - await self.bot.wait_until_ready() + await self.bot.wait_for_connected() while not self.bot.is_closed(): self.presence = await self.set_presence() await asyncio.sleep(600) @@ -610,7 +607,7 @@ async def mention(self, ctx, *, mention: str = None): Type only `{prefix}mention` to retrieve your current "mention" message. """ - current = self.bot.config.get("mention", "@here") + current = self.bot.config["mention"] if mention is None: embed = Embed( @@ -673,7 +670,7 @@ async def config(self, ctx): @checks.has_permissions(PermissionLevel.OWNER) async def config_options(self, ctx): """Return a list of valid configuration names you can change.""" - allowed = self.bot.config.allowed_to_change_in_command + allowed = self.bot.config.public_keys valid = ", ".join(f"`{k}`" for k in allowed) embed = Embed(title="Valid Keys", description=valid, color=self.bot.main_color) return await ctx.send(embed=embed) @@ -683,7 +680,7 @@ async def config_options(self, ctx): async def config_set(self, ctx, key: str.lower, *, value: str): """Set a configuration variable and its value.""" - keys = self.bot.config.allowed_to_change_in_command + keys = self.bot.config.public_keys if key in keys: try: @@ -691,7 +688,8 @@ async def config_set(self, ctx, key: str.lower, *, value: str): except InvalidConfigError as exc: embed = exc.embed else: - await self.bot.config.update({key: value}) + self.bot.config[key] = value + await self.bot.config.update() embed = Embed( title="Success", color=self.bot.main_color, @@ -714,16 +712,12 @@ async def config_remove(self, ctx, key: str.lower): """Delete a set configuration variable.""" keys = self.bot.config.allowed_to_change_in_command if key in keys: - try: - del self.bot.config.cache[key] - await self.bot.config.update() - except KeyError: - # when no values were set - pass + self.bot.config.remove(key) + await self.bot.config.update() embed = Embed( title="Success", color=self.bot.main_color, - description=f"`{key}` had been deleted from the config.", + description=f"`{key}` had been reset to default.", ) else: embed = Embed( @@ -744,11 +738,11 @@ async def config_get(self, ctx, key: str.lower = None): Leave `key` empty to show all currently set configuration variables. """ - keys = self.bot.config.allowed_to_change_in_command + keys = self.bot.config.public_keys if key: if key in keys: - desc = f"`{key}` is set to `{self.bot.config.get(key)}`" + desc = f"`{key}` is set to `{self.bot.config[key]}`" embed = Embed(color=self.bot.main_color, description=desc) embed.set_author( name="Config variable", icon_url=self.bot.user.avatar_url @@ -773,8 +767,8 @@ async def config_get(self, ctx, key: str.lower = None): config = { key: val - for key, val in self.bot.config.cache.items() - if val and key in keys + for key, val in self.bot.config.items() + if val != self.bot.config.defaults['key'] and key in keys } for name, value in reversed(list(config.items())): @@ -835,10 +829,7 @@ async def alias(self, ctx): @checks.has_permissions(PermissionLevel.MODERATOR) async def alias_add(self, ctx, name: str.lower, *, value): """Add an alias.""" - if "aliases" not in self.bot.config.cache: - self.bot.config["aliases"] = {} - - if self.bot.get_command(name) or name in self.bot.config.aliases: + if self.bot.get_command(name) or name in self.bot.aliases: embed = Embed( title="Error", color=Color.red(), @@ -855,8 +846,9 @@ async def alias_add(self, ctx, name: str.lower, *, value): description="The command you are attempting to point " f"to does not exist: `{linked_command}`.", ) + return await ctx.send(embed=embed) - self.bot.config.aliases[name] = value + self.bot.aliases[name] = value await self.bot.config.update() embed = Embed( @@ -872,11 +864,8 @@ async def alias_add(self, ctx, name: str.lower, *, value): async def alias_remove(self, ctx, *, name: str.lower): """Remove an alias.""" - if "aliases" not in self.bot.config.cache: - self.bot.config["aliases"] = {} - - if name in self.bot.config.aliases: - del self.bot.config["aliases"][name] + if name in self.bot.aliases: + self.bot.aliases.pop(name) await self.bot.config.update() embed = Embed( @@ -897,10 +886,7 @@ async def alias_remove(self, ctx, *, name: str.lower): @alias.command(name="edit") @checks.has_permissions(PermissionLevel.MODERATOR) async def alias_edit(self, ctx, name: str.lower, *, value): - if "aliases" not in self.bot.config.cache: - self.bot.config["aliases"] = {} - - if name not in self.bot.config.aliases: + if name not in self.bot.aliases: embed = Embed( title="Error", color=Color.red(), @@ -928,7 +914,7 @@ async def alias_edit(self, ctx, name: str.lower, *, value): ) return await ctx.send(embed=embed) - self.bot.config.aliases[name] = value + self.bot.aliases[name] = value await self.bot.config.update() embed = Embed( @@ -1141,11 +1127,11 @@ async def permissions_get(self, ctx, *, user_or_role: Union[User, Role, str]): cmds = [] levels = [] for cmd in self.bot.commands: - permissions = self.bot.config.command_permissions.get(cmd.name, []) + permissions = self.bot.config['command_permissions'].get(cmd.name, []) if value in permissions: cmds.append(cmd.name) for level in PermissionLevel: - permissions = self.bot.config.level_permissions.get(level.name, []) + permissions = self.bot.config['level_permissions'].get(level.name, []) if value in permissions: levels.append(level.name) mention = user_or_role.name if hasattr(user_or_role, "name") else user_or_role @@ -1181,7 +1167,7 @@ async def permissions_get_command(self, ctx, *, command: str = None): """View currently-set permissions for a command.""" def get_command(cmd): - permissions = self.bot.config.command_permissions.get(cmd.name, []) + permissions = self.bot.config['command_permissions'].get(cmd.name, []) if not permissions: embed = Embed( title=f"Permission entries for command `{cmd.name}`:", @@ -1239,7 +1225,7 @@ async def permissions_get_level(self, ctx, *, level: str = None): """View currently-set permissions for commands of a permission level.""" def get_level(perm_level): - permissions = self.bot.config.level_permissions.get(perm_level.name, []) + permissions = self.bot.config['level_permissions'].get(perm_level.name, []) if not permissions: embed = Embed( title="Permission entries for permission " @@ -1311,6 +1297,7 @@ async def oauth_whitelist(self, ctx, target: Union[User, Role]): """ whitelisted = self.bot.config["oauth_whitelist"] + # target.id is not int?? if target.id in whitelisted: whitelisted.remove(target.id) removed = True diff --git a/core/changelog.py b/core/changelog.py index 9a10b4dc35..e3f9424546 100644 --- a/core/changelog.py +++ b/core/changelog.py @@ -1,6 +1,6 @@ import re from collections import defaultdict -from typing import List +from typing import List, Union from discord import Embed, Color @@ -32,10 +32,13 @@ class Version: General description of the version. """ - def __init__(self, bot, version: str, lines: str): + def __init__(self, bot, version: str, lines: Union[List[str], str]): self.bot = bot self.version = version.lstrip("vV") - self.lines = [x for x in lines.splitlines() if x] + if isinstance(lines, list): + self.lines = lines + else: + self.lines = [x for x in lines.splitlines() if x] self.fields = defaultdict(str) self.description = "" self.parse() diff --git a/core/checks.py b/core/checks.py index 0779d3d617..72561b4a57 100644 --- a/core/checks.py +++ b/core/checks.py @@ -58,7 +58,7 @@ async def check_permissions(ctx, command_name, permission_level) -> bool: # Administrators have permission to all non-owner commands return True - command_permissions = ctx.bot.config.command_permissions + command_permissions = ctx.bot.config['command_permissions'] author_roles = ctx.author.roles if command_name in command_permissions: @@ -71,7 +71,7 @@ async def check_permissions(ctx, command_name, permission_level) -> bool: has_perm_id = ctx.author.id in command_permissions[command_name] return has_perm_role or has_perm_id - level_permissions = ctx.bot.config.level_permissions + level_permissions = ctx.bot.config['level_permissions'] for level in PermissionLevel: if level >= permission_level and level.name in level_permissions: diff --git a/core/clients.py b/core/clients.py index 4551af64f8..69b18c1dc9 100644 --- a/core/clients.py +++ b/core/clients.py @@ -1,4 +1,3 @@ -import os import logging import secrets from datetime import datetime @@ -14,10 +13,6 @@ logger = logging.getLogger("Modmail") -prefix = os.getenv("LOG_URL_PREFIX", "/logs") -if prefix == "NONE": - prefix = "" - class RequestClient: """ @@ -34,14 +29,11 @@ class RequestClient: The Modmail bot. session : ClientSession The bot's current running `ClientSession`. - headers : Dict[str, str] - The HTTP headers that will be sent along with the requiest. """ def __init__(self, bot): self.bot = bot self.session = bot.session - self.headers: dict = None async def request( self, @@ -76,10 +68,6 @@ async def request( `str` if the returned data is not a valid json data, the raw response. """ - if headers is not None: - headers.update(self.headers) - else: - headers = self.headers async with self.session.request( method, url, headers=headers, json=payload ) as resp: @@ -90,176 +78,8 @@ async def request( except (JSONDecodeError, ClientResponseError): return await resp.text() - def filter_valid(self, data): - """ - Filters configuration keys that are accepted. - - Parameters - ---------- - data : Dict[str, Any] - The data that needs to be cleaned. - - Returns - ------- - Dict[str, Any] - Filtered `data` to keep only the accepted pairs. - """ - valid_keys = self.bot.config.valid_keys.difference( - self.bot.config.protected_keys - ) - return {k: v for k, v in data.items() if k in valid_keys} - - -class GitHub(RequestClient): - """ - The client for interacting with GitHub API. - - Parameters - ---------- - bot : Bot - The Modmail bot. - access_token : str, optional - GitHub's access token. - username : str, optional - GitHub username. - avatar_url : str, optional - URL to the avatar in GitHub. - url : str, optional - URL to the GitHub profile. - - Attributes - ---------- - bot : Bot - The Modmail bot. - access_token : str - GitHub's access token. - username : str - GitHub username. - avatar_url : str - URL to the avatar in GitHub. - url : str - URL to the GitHub profile. - - Class Attributes - ---------------- - BASE : str - GitHub API base URL. - REPO : str - Modmail repo URL for GitHub API. - HEAD : str - Modmail HEAD URL for GitHub API. - MERGE_URL : str - URL for merging upstream to master. - FORK_URL : str - URL to fork Modmail. - STAR_URL : str - URL to star Modmail. - """ - - BASE = "https://api.github.com" - REPO = BASE + "/repos/kyb3r/modmail" - HEAD = REPO + "/git/refs/heads/master" - MERGE_URL = BASE + "/repos/{username}/modmail/merges" - FORK_URL = REPO + "/forks" - STAR_URL = BASE + "/user/starred/kyb3r/modmail" - - def __init__(self, bot, access_token: str = "", username: str = "", **kwargs): - super().__init__(bot) - self.access_token = access_token - self.username = username - self.avatar_url: str = kwargs.pop("avatar_url", "") - self.url: str = kwargs.pop("url", "") - if self.access_token: - self.headers = {"Authorization": "token " + str(access_token)} - - async def update_repository(self, sha: str = None) -> Optional[dict]: - """ - Update the repository from Modmail main repo. - - Parameters - ---------- - sha : Optional[str], optional - The commit SHA to update the repository. - - Returns - ------- - Optional[dict] - If the response is a dict. - """ - if not self.username: - raise commands.CommandInvokeError("Username not found.") - - if sha is None: - resp: dict = await self.request(self.HEAD) - sha = resp["object"]["sha"] - - payload = {"base": "master", "head": sha, "commit_message": "Updating bot"} - - merge_url = self.MERGE_URL.format(username=self.username) - - resp = await self.request(merge_url, method="POST", payload=payload) - if isinstance(resp, dict): - return resp - - async def fork_repository(self) -> None: - """ - Forks Modmail's repository. - """ - await self.request(self.FORK_URL, method="POST") - - async def has_starred(self) -> bool: - """ - Checks if shared Modmail. - - Returns - ------- - bool - `True`, if Modmail was starred. - Otherwise `False`. - """ - resp = await self.request(self.STAR_URL, return_response=True) - return resp.status == 204 - - async def star_repository(self) -> None: - """ - Stars Modmail's repository. - """ - await self.request(self.STAR_URL, method="PUT", headers={"Content-Length": "0"}) - - @classmethod - async def login(cls, bot) -> "GitHub": - """ - Logs in to GitHub with configuration variable information. - - Parameters - ---------- - bot : Bot - The Modmail bot. - - Returns - ------- - GitHub - The newly created `GitHub` object. - """ - self = cls(bot, bot.config.get("github_access_token")) - resp: dict = await self.request("https://api.github.com/user") - self.username: str = resp["login"] - self.avatar_url: str = resp["avatar_url"] - self.url: str = resp["html_url"] - logger.info(info(f"GitHub logged in to: {self.username}")) - return self - class ApiClient(RequestClient): - def __init__(self, bot): - super().__init__(bot) - if self.token: - self.headers = {"Authorization": "Bearer " + self.token} - - @property - def token(self) -> Optional[str]: - return self.bot.config.get("github_access_token") - @property def db(self): return self.bot.db @@ -279,7 +99,7 @@ async def get_log(self, channel_id: Union[str, int]) -> dict: async def get_log_link(self, channel_id: Union[str, int]) -> str: doc = await self.get_log(channel_id) - return f"{self.bot.config.log_url.strip('/')}{prefix}/{doc['key']}" + return f"{self.bot.config['log_url'].strip('/')}{self.bot.config['log_url_prefix']}/{doc['key']}" async def create_log_entry( self, recipient: Member, channel: TextChannel, creator: Member @@ -315,7 +135,7 @@ async def create_log_entry( } ) - return f"{self.bot.config.log_url.strip('/')}{prefix}/{key}" + return f"{self.bot.config['log_url'].strip('/')}{self.bot.config['log_url_prefix']}/{key}" async def get_config(self) -> dict: conf = await self.db.config.find_one({"bot_id": self.bot.user.id}) @@ -325,12 +145,8 @@ async def get_config(self) -> dict: return conf async def update_config(self, data: dict): - valid_keys = self.bot.config.valid_keys.difference( - self.bot.config.protected_keys - ) - - toset = {k: v for k, v in data.items() if k in valid_keys} - unset = {k: 1 for k in valid_keys if k not in data} + toset = self.bot.config.filter_valid(data) + unset = self.bot.config.filter_valid({k: 1 for k in self.bot.config.all_keys if k not in data}) return await self.db.config.update_one( {"bot_id": self.bot.user.id}, {"$set": toset, "$unset": unset} @@ -386,28 +202,6 @@ async def post_log(self, channel_id: Union[int, str], data: dict) -> dict: return_document=True, ) - async def update_repository(self) -> dict: - user = await GitHub.login(self.bot) - data = await user.update_repository() - return { - "data": data, - "user": { - "username": user.username, - "avatar_url": user.avatar_url, - "url": user.url, - }, - } - - async def get_user_info(self) -> dict: - user = await GitHub.login(self.bot) - return { - "user": { - "username": user.username, - "avatar_url": user.avatar_url, - "url": user.url, - } - } - class PluginDatabaseClient: def __init__(self, bot): diff --git a/core/config.py b/core/config.py index 6b083d54f7..b3c0645576 100644 --- a/core/config.py +++ b/core/config.py @@ -2,6 +2,7 @@ import json import os import typing +from copy import deepcopy import isodate @@ -15,181 +16,113 @@ class ConfigManager: - allowed_to_change_in_command = { + public_keys = { # activity - "twitch_url", + "twitch_url": 'https://www.twitch.tv/discord-modmail/', # bot settings - "main_category_id", - "disable_autoupdates", - "prefix", - "mention", - "main_color", - "user_typing", - "mod_typing", - "account_age", - "guild_age", - "reply_without_command", + "main_category_id": None, + "prefix": '?', + "mention": '@here', + "main_color": discord.Color.blurple(), + "user_typing": False, + "mod_typing": False, + "account_age": isodate.Duration(), + "guild_age": isodate.Duration(), + "reply_without_command": False, # logging - "log_channel_id", + "log_channel_id": None, # threads - "sent_emoji", - "blocked_emoji", - "close_emoji", - "disable_recipient_thread_close", - "thread_auto_close", - "thread_auto_close_response", - "thread_creation_response", - "thread_creation_footer", - "thread_creation_title", - "thread_close_footer", - "thread_close_title", - "thread_close_response", - "thread_self_close_response", + "sent_emoji": '✅', + "blocked_emoji": '🚫', + "close_emoji": '🔒', + "recipient_thread_close": False, + "thread_auto_close": 0, + "thread_auto_close_response": "This thread has been closed automatically due to inactivity after {timeout}.", + "thread_creation_response": "The staff team will get back to you as soon as possible.", + "thread_creation_footer": None, + "thread_creation_title": 'Thread Created', + "thread_close_footer": 'Replying will create a new thread', + "thread_close_title": 'Thread Closed', + "thread_close_response": '{closer.mention} has closed this Modmail thread.', + "thread_self_close_response": 'You have closed this Modmail thread.', # moderation - "recipient_color", - "mod_tag", - "mod_color", + "recipient_color": discord.Color.gold(), + "mod_tag": None, + "mod_color": discord.Color.green(), # anonymous message - "anon_username", - "anon_avatar_url", - "anon_tag", + "anon_username": None, + "anon_avatar_url": None, + "anon_tag": 'Response', } - internal_keys = { + private_keys = { # bot presence - "activity_message", - "activity_type", - "status", - "oauth_whitelist", + "activity_message": '', + "activity_type": None, + "status": None, + "oauth_whitelist": [], # moderation - "blocked", - "blocked_whitelist", - "command_permissions", - "level_permissions", + "blocked": {}, + "blocked_whitelist": [], + "command_permissions": {}, + "level_permissions": {}, # threads - "snippets", - "notification_squad", - "subscriptions", - "closures", + "snippets": {}, + "notification_squad": {}, + "subscriptions": {}, + "closures": {}, # misc - "aliases", - "plugins", + "plugins": [], + "aliases": {}, } protected_keys = { # Modmail - "modmail_guild_id", - "guild_id", - "log_url", - "mongo_uri", - "owners", + "modmail_guild_id": None, + "guild_id": None, + "log_url": 'https://example.com/', + "log_url_prefix": '/logs', + "mongo_uri": None, + "owners": None, # bot - "token", - # GitHub - "github_access_token", + "token": None, # Logging - "log_level", + "log_level": "INFO", } colors = {"mod_color", "recipient_color", "main_color"} time_deltas = {"account_age", "guild_age", "thread_auto_close"} - valid_keys = allowed_to_change_in_command | internal_keys | protected_keys + defaults = {**public_keys, **private_keys, **protected_keys} + all_keys = set(defaults.keys()) def __init__(self, bot): self.bot = bot self._cache = {} - self._ready_event = asyncio.Event() + self.ready_event = asyncio.Event() self.populate_cache() def __repr__(self): - return repr(self.cache) + return repr(self._cache) @property def api(self): return self.bot.api - @property - def ready_event(self) -> asyncio.Event: - return self._ready_event - - @property - def cache(self) -> dict: - return self._cache - - @cache.setter - def cache(self, val: dict): - self._cache = val - def populate_cache(self) -> dict: - defaults = { - "snippets": {}, - "plugins": [], - "aliases": {}, - "blocked": {}, - "blocked_whitelist": [], - "oauth_whitelist": [], - "command_permissions": {}, - "level_permissions": {}, - "notification_squad": {}, - "subscriptions": {}, - "closures": {}, - "twitch_url": 'https://www.twitch.tv/discord-modmail/', - "main_category_id": None, - "disable_autoupdates": False, - "prefix": '?', - "mention": None, - "main_color": discord.Color.blurple(), - "user_typing": False, - "mod_typing": False, - "account_age": None, - "guild_age": None, - "reply_without_command": False, - "log_channel_id": None, - "sent_emoji": '✅', - "blocked_emoji": '🚫', - "close_emoji": '🔒', - "recipient_thread_close": False, - "thread_auto_close": 0, - "thread_auto_close_response": None, - "thread_creation_response": "The staff team will get back to you as soon as possible.", - "thread_creation_footer": None, - "thread_creation_title": 'Thread Created', - "thread_close_footer": 'Replying will create a new thread', - "thread_close_title": 'Thread Closed', - "thread_close_response": '{closer.mention} has closed this Modmail thread.', - "thread_self_close_response": 'You have closed this Modmail thread.', - "recipient_color": discord.Color.gold(), - "mod_tag": None, - "mod_color": discord.Color.green(), - "anon_username": None, - "anon_avatar_url": None, - "anon_tag": 'Response', - "activity_message": None, - "activity_type": None, - "status": None, - "modmail_guild_id": None, - "guild_id": None, - "mongo_uri": None, - "owners": None, - "token": None, - "github_access_token": None, - "log_url": 'https://example.com/', - "log_level": "INFO", - } - - defaults.update(os.environ) + data = deepcopy(self.defaults) + + # populate from env var and .env file + data.update({k.lower(): v for k, v in os.environ.items() if k.lower() in self.all_keys}) if os.path.exists("config.json"): with open("config.json") as f: # Config json should override env vars - data.update(json.load(f)) + data.update({k.lower(): v for k, v in json.load(f).items() if k.lower() in self.all_keys}) - self.cache = { - k.lower(): v for k, v in data.items() if k.lower() in self.valid_keys - } - return self.cache + self._cache = data + return self._cache async def clean_data(self, key: str, val: typing.Any) -> typing.Tuple[str, str]: value_text = val @@ -200,32 +133,17 @@ async def clean_data(self, key: str, val: typing.Any) -> typing.Tuple[str, str]: hex_ = ALL_COLORS.get(val) if hex_ is None: - if not isinstance(val, str): + hex_ = str(hex_) + if hex_.startswith("#"): + hex_ = hex_[1:] + if len(hex_) == 3: + hex_ = ''.join(s for s in hex_ for _ in range(2)) + if len(hex_) != 6: raise InvalidConfigError("Invalid color name or hex.") - if val.startswith("#"): - val = val[1:] - if len(val) != 6: + try: + int(val, 16) + except ValueError: raise InvalidConfigError("Invalid color name or hex.") - for letter in val: - if letter not in { - "0", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - "a", - "b", - "c", - "d", - "e", - "f", - }: - raise InvalidConfigError("Invalid color name or hex.") clean_value = "#" + val value_text = clean_value else: @@ -253,31 +171,54 @@ async def clean_data(self, key: str, val: typing.Any) -> typing.Tuple[str, str]: return clean_value, value_text - async def update(self, data: typing.Optional[dict] = None) -> dict: + async def update(self): """Updates the config with data from the cache""" - if data is not None: - self.cache.update(data) - await self.api.update_config(self.cache) - return self.cache + await self.api.update_config(self._cache) async def refresh(self) -> dict: """Refreshes internal cache with data from database""" data = await self.api.get_config() - self.cache.update(data) + self._cache.update(data) self.ready_event.set() - return self.cache + return self._cache async def wait_until_ready(self) -> None: await self.ready_event.wait() - def __getattr__(self, value: str) -> typing.Any: - return self.cache[value] - def __setitem__(self, key: str, item: typing.Any) -> None: - self.cache[key] = item + if key not in self.all_keys: + raise InvalidConfigError(f'Configuration "{key}" is invalid.') + self._cache[key] = item def __getitem__(self, key: str) -> typing.Any: - return self.cache[key] + if key not in self.all_keys: + raise InvalidConfigError(f'Configuration "{key}" is invalid.') + if key not in self._cache: + val = deepcopy(self.defaults[key]) + self._cache[key] = val + return self._cache[key] def get(self, key: str, default: typing.Any = None) -> typing.Any: - return self.cache.get(key, default) + if key not in self.all_keys: + raise InvalidConfigError(f'Configuration "{key}" is invalid.') + if key not in self._cache: + self._cache[key] = default + return self._cache[key] + + def set(self, key: str, item: typing.Any) -> None: + if key not in self.all_keys: + raise InvalidConfigError(f'Configuration "{key}" is invalid.') + self._cache[key] = item + + def remove(self, key: str) -> typing.Any: + if key not in self.all_keys: + raise InvalidConfigError(f'Configuration "{key}" is invalid.') + self._cache[key] = deepcopy(self.defaults[key]) + return self._cache[key] + + def items(self) -> typing.Iterable: + return self._cache.items() + + def filter_valid(self, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + return {k.lower(): v for k, v in data.items() + if k.lower() in self.public_keys or k.lower() in self.private_keys} diff --git a/core/decorators.py b/core/decorators.py index 6e27dd2292..b9e6f50067 100644 --- a/core/decorators.py +++ b/core/decorators.py @@ -1,6 +1,5 @@ import functools -from discord import Embed, Color from discord.ext import commands diff --git a/core/thread.py b/core/thread.py index 107ec22773..8c0033238c 100644 --- a/core/thread.py +++ b/core/thread.py @@ -12,8 +12,7 @@ from discord.ext.commands import MissingRequiredArgument, CommandError from core.time import human_timedelta -from core.utils import is_image_url, days, match_user_id -from core.utils import truncate, ignore, error +from core.utils import is_image_url, days, match_user_id, truncate, ignore, error, strtobool logger = logging.getLogger("Modmail") @@ -103,16 +102,16 @@ async def setup(self, *, creator=None, category=None): reason="Creating a thread channel", ) except discord.HTTPException as e: # Failed to create due to 50 channel limit. - del self.manager.cache[self.id] - log_channel = self.bot.log_channel + self.manager.cache.pop(self.id) - em = discord.Embed(color=discord.Color.red()) - em.title = "Error while trying to create a thread" - em.description = e.message - em.add_field(name="Recipient", value=recipient.mention) + embed = discord.Embed(color=discord.Color.red()) + embed.title = "Error while trying to create a thread" + embed.description = e.message + embed.add_field(name="Recipient", value=recipient.mention) - if log_channel is not None: - return await log_channel.send(embed=em) + if self.bot.log_channel is not None: + await self.bot.log_channel.send(embed=embed) + return self._channel = channel @@ -123,7 +122,7 @@ async def setup(self, *, creator=None, category=None): ) log_count = sum(1 for log in log_data if not log["open"]) - except: # Something went wrong with database? + except Exception: # Something went wrong with database? log_url = log_count = None # ensure core functionality still works @@ -131,7 +130,7 @@ async def setup(self, *, creator=None, category=None): if creator: mention = None else: - mention = self.bot.config.get("mention", "@here") + mention = self.bot.config["mention"] async def send_genesis_message(): info_embed = self.manager.format_info_embed( @@ -141,7 +140,7 @@ async def send_genesis_message(): msg = await channel.send(mention, embed=info_embed) self.bot.loop.create_task(msg.pin()) self.genesis_message = msg - except Exception as e: + except Exception: pass finally: self.ready = True @@ -151,10 +150,7 @@ async def send_genesis_message(): self.bot.loop.create_task(send_genesis_message()) # Once thread is ready, tell the recipient. - thread_creation_response = self.bot.config.get( - "thread_creation_response", - "The staff team will get back to you as soon as possible.", - ) + thread_creation_response = self.bot.config["thread_creation_response"] embed = discord.Embed( color=self.bot.mod_color, @@ -163,17 +159,26 @@ async def send_genesis_message(): ) footer = "Your message has been sent" - if self.bot.config.get("recipient_thread_close"): + try: + recipient_thread_close = strtobool(self.bot.config["recipient_thread_close"]) + except ValueError: + recipient_thread_close = self.bot.config.remove('recipient_thread_close') + + if recipient_thread_close: footer = "Click the lock to close the thread" - footer = self.bot.config.get("thread_creation_footer", footer) + _footer = self.bot.config["thread_creation_footer"] + if _footer is not None: + footer = _footer + embed.set_footer(text=footer, icon_url=self.bot.guild.icon_url) - embed.title = self.bot.config.get("thread_creation_title", "Thread Created") + embed.title = self.bot.config["thread_creation_title"] if creator is None: msg = await recipient.send(embed=embed) - if self.bot.config.get("recipient_thread_close"): - close_emoji = self.bot.config.get("close_emoji", "🔒") + + if recipient_thread_close: + close_emoji = self.bot.config["close_emoji"] close_emoji = await self.bot.convert_emoji(close_emoji) await msg.add_reaction(close_emoji) @@ -211,7 +216,7 @@ async def close( "message": message, "auto_close": auto_close, } - self.bot.config.closures[str(self.id)] = items + self.bot.config['closures'][str(self.id)] = items await self.bot.config.update() task = self.bot.loop.call_later( @@ -228,14 +233,14 @@ async def close( async def _close( self, closer, silent=False, delete_channel=True, message=None, scheduled=False ): - del self.manager.cache[self.id] + self.manager.cache.pop(self.id) await self.cancel_closure(all=True) # Cancel auto closing the thread if closed by any means. - if str(self.id) in self.bot.config.subscriptions: - del self.bot.config.subscriptions[str(self.id)] + if str(self.id) in self.bot.config['subscriptions']: + self.bot.config['subscriptions'].pop(str(self.id)) # Logging log_data = await self.bot.api.post_log( @@ -255,10 +260,10 @@ async def _close( ) if log_data is not None and isinstance(log_data, dict): - prefix = os.getenv("LOG_URL_PREFIX", "/logs") + prefix = self.bot.config['log_url_prefix'] if prefix == "NONE": prefix = "" - log_url = f"{self.bot.config.log_url.strip('/')}{prefix}/{log_data['key']}" + log_url = f"{self.bot.config['log_url'].strip('/')}{prefix}/{log_data['key']}" if log_data["messages"]: content = str(log_data["messages"][0]["content"]) @@ -293,36 +298,27 @@ async def _close( tasks = [self.bot.config.update()] - try: + if self.bot.log_channel is not None: tasks.append(self.bot.log_channel.send(embed=embed)) - except (ValueError, AttributeError): - pass # Thread closed message embed = discord.Embed( - title=self.bot.config.get("thread_close_title", "Thread Closed"), + title=self.bot.config["thread_close_title"], color=discord.Color.red(), timestamp=datetime.utcnow(), ) if not message: if self.id == closer.id: - message = self.bot.config.get( - "thread_self_close_response", "You have closed this Modmail thread." - ) + message = self.bot.config["thread_self_close_response"] else: - message = self.bot.config.get( - "thread_close_response", - "{closer.mention} has closed this Modmail thread.", - ) + message = self.bot.config["thread_close_response"] message = message.format(closer=closer, loglink=log_url, logkey=log_data["key"]) embed.description = message - footer = self.bot.config.get( - "thread_close_footer", "Replying will create a new thread" - ) + footer = self.bot.config["thread_close_footer"] embed.set_footer(text=footer, icon_url=self.bot.guild.icon_url) if not silent and self.recipient is not None: @@ -341,7 +337,7 @@ async def cancel_closure(self, auto_close: bool = False, all: bool = False) -> N self.auto_close_task.cancel() self.auto_close_task = None - to_update = self.bot.config.closures.pop(str(self.id), None) + to_update = self.bot.config['closures'].pop(str(self.id), None) if to_update is not None: await self.bot.config.update() @@ -364,8 +360,8 @@ async def _fetch_timeout( :returns: None if no timeout is set. """ - timeout = self.bot.config.get("thread_auto_close") - if timeout is None: + timeout = self.bot.config["thread_auto_close"] + if not timeout: return timeout else: try: @@ -377,9 +373,9 @@ async def _fetch_timeout( 'greater than 0 days, not "%s".', str(timeout), ) - del self.bot.config.cache["thread_auto_close"] + timeout = self.bot.config.remove("thread_auto_close") await self.bot.config.update() - timeout = None + return timeout async def _restart_close_timer(self): @@ -390,7 +386,7 @@ async def _restart_close_timer(self): timeout = await self._fetch_timeout() # Exit if timeout was not set - if timeout is None: + if not timeout: return # Set timeout seconds @@ -400,22 +396,19 @@ async def _restart_close_timer(self): human_time = human_timedelta(dt=reset_time) # Grab message - close_message = self.bot.config.get( - "thread_auto_close_response", - f"This thread has been closed automatically due to inactivity " - f"after {human_time}.", - ) + close_message = self.bot.config["thread_auto_close_response"].format(timeout=human_time) + time_marker_regex = "%t" if len(re.findall(time_marker_regex, close_message)) == 1: close_message = re.sub(time_marker_regex, str(human_time), close_message) elif len(re.findall(time_marker_regex, close_message)) > 1: logger.warning( - "The thread_auto_close_response should only contain one" - f" '{time_marker_regex}' to specify time." + "The thread_auto_close_response should only contain one '%s' to specify time.", + time_marker_regex ) await self.close( - closer=self.bot.user, after=seconds, message=close_message, auto_close=True + closer=self.bot.user, after=int(seconds), message=close_message, auto_close=True ) async def edit_message(self, message_id: int, message: str) -> None: @@ -568,11 +561,15 @@ async def send( and not isinstance(destination, discord.TextChannel) ): # Anonymously sending to the user. - tag = self.bot.config.get("mod_tag", str(message.author.top_role)) - name = self.bot.config.get("anon_username", tag) - avatar_url = self.bot.config.get( - "anon_avatar_url", self.bot.guild.icon_url - ) + tag = self.bot.config['mod_tag'] + if tag is None: + tag = str(message.author.top_role) + name = self.bot.config['anon_username'] + if name is None: + name = tag + avatar_url = self.bot.config['anon_avatar_url'] + if avatar_url is None: + avatar_url = self.bot.guild.icon_url else: # Normal message name = str(author) @@ -597,7 +594,7 @@ async def send( image_links = [ (link, None) for link in re.findall( - r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+", + r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+", message.content, ) ] @@ -655,10 +652,12 @@ async def send( embed.set_footer(text="Anonymous Reply") # Normal messages elif not anonymous: - tag = self.bot.config.get("mod_tag", str(message.author.top_role)) - embed.set_footer(text=tag) # Normal messages + mod_tag = self.bot.config['mod_tag'] + if mod_tag is None: + mod_tag = str(message.author.top_role) + embed.set_footer(text=mod_tag) # Normal messages else: - embed.set_footer(text=self.bot.config.get("anon_tag", "Response")) + embed.set_footer(text=self.bot.config["anon_tag"]) elif note: # noinspection PyUnresolvedReferences,PyDunderSlots embed.color = discord.Color.blurple() # pylint: disable=E0237 @@ -687,16 +686,15 @@ async def send( return _msg def get_notifications(self) -> str: - config = self.bot.config key = str(self.id) mentions = [] - mentions.extend(config["subscriptions"].get(key, [])) + mentions.extend(self.bot.config["subscriptions"].get(key, [])) - if key in config["notification_squad"]: - mentions.extend(config["notification_squad"][key]) - del config["notification_squad"][key] - self.bot.loop.create_task(config.update()) + if key in self.bot.config["notification_squad"]: + mentions.extend(self.bot.config["notification_squad"][key]) + self.bot.config["notification_squad"].pop(key) + self.bot.loop.create_task(self.bot.config.update()) return " ".join(mentions) diff --git a/core/time.py b/core/time.py index c001bebcd1..a99efee78b 100644 --- a/core/time.py +++ b/core/time.py @@ -188,7 +188,7 @@ async def convert(self, ctx, argument): if not (end < len(argument) and argument[end] == '"'): raise BadArgument("If the time is quoted, you must unquote it.") - remaining = argument[end + 1 :].lstrip(" ,.!") + remaining = argument[end + 1:].lstrip(" ,.!") else: remaining = argument[end:].lstrip(" ,.!") elif len(argument) == end: diff --git a/core/utils.py b/core/utils.py index bf88af1bff..ca97c658cd 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,5 +1,6 @@ import re import typing +from distutils.util import strtobool as _stb from urllib import parse from discord import Object @@ -9,6 +10,12 @@ from core.models import PermissionLevel +def strtobool(val): + if isinstance(val, bool): + return val + return _stb(str(val)) + + def info(*msgs): return f'{Fore.CYAN}{" ".join(msgs)}{Style.RESET_ALL}' From 6c756a108f36825d96a1b5bc025bc018f59cc4b4 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Mon, 1 Jul 2019 22:22:55 -0700 Subject: [PATCH 03/50] Fixed a db problem and new config var --- CHANGELOG.md | 5 +++-- bot.py | 23 +++++++++++------------ cogs/modmail.py | 7 +++++++ core/clients.py | 15 ++++++++++++--- core/config.py | 13 +++++++------ core/thread.py | 9 +++------ 6 files changed, 43 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adcaf554e5..cdc1a393eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,21 +5,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -# v3.1.0 +# [UNRELEASED] ### Breaking - `disable_recipient_thread_close` is removed, a new configuration variable `recipient_thread_close` replaces it which defaults to False. - Truthy and falsy values for binary configuration variables are now interpreted respectfully. - ### Changes - `thread_auto_close_response` has a configurable variable `{timeout}`. +- New configuration variable `thread_self_closable_creation_footer`, the footer when `recipient_thread_close` is enabled. ### Internal - Removed supporting code for GitHub interaction. +- All default values moved to `core/config.py`. # v3.0.3 diff --git a/bot.py b/bot.py index 406521dac4..990f826676 100644 --- a/bot.py +++ b/bot.py @@ -304,37 +304,31 @@ def prefix(self) -> str: return str(self.config["prefix"]) @property - def mod_color(self) -> typing.Union[discord.Color, int]: + def mod_color(self) -> int: color = self.config.get("mod_color") - if isinstance(color, discord.Color): - return color try: return int(color.lstrip("#"), base=16) except ValueError: logger.error(error("Invalid mod_color provided.")) - return self.config.remove('mod_color') + return int(self.config.remove('mod_color').lstrip("#"), base=16) @property - def recipient_color(self) -> typing.Union[discord.Color, int]: + def recipient_color(self) -> int: color = self.config.get("recipient_color") - if isinstance(color, discord.Color): - return color try: return int(color.lstrip("#"), base=16) except ValueError: logger.error(error("Invalid recipient_color provided.")) - return self.config.remove('recipient_color') + return int(self.config.remove('recipient_color').lstrip("#"), base=16) @property - def main_color(self) -> typing.Union[discord.Color, int]: + def main_color(self) -> int: color = self.config.get("main_color") - if isinstance(color, discord.Color): - return color try: return int(color.lstrip("#"), base=16) except ValueError: logger.error(error("Invalid main_color provided.")) - return self.config.remove('main_color') + return int(self.config.remove('main_color').lstrip("#"), base=16) async def on_connect(self): logger.info(LINE) @@ -478,6 +472,11 @@ async def _process_blocked(self, message: discord.Message) -> bool: account_age = self.config["account_age"] guild_age = self.config["guild_age"] + if account_age is None: + account_age = isodate.Duration() + if guild_age is None: + guild_age = isodate.Duration() + if not isinstance(account_age, isodate.Duration): try: account_age = isodate.parse_duration(account_age) diff --git a/cogs/modmail.py b/cogs/modmail.py index 96d95ac096..4d9f126510 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -197,6 +197,13 @@ async def snippets_remove(self, ctx, *, name: str.lower): @snippets.command(name="edit") @checks.has_permissions(PermissionLevel.SUPPORTER) async def snippets_edit(self, ctx, name: str.lower, *, value): + """ + Edit a snippet. + + To edit a multi-word snippet name, use quotes: ``` + {prefix}snippets edit "two word" this is a new two word snippet. + ``` + """ if name in self.bot.snippets: self.bot.snippets[name] = value await self.bot.config.update() diff --git a/core/clients.py b/core/clients.py index 69b18c1dc9..48da5f8518 100644 --- a/core/clients.py +++ b/core/clients.py @@ -148,9 +148,18 @@ async def update_config(self, data: dict): toset = self.bot.config.filter_valid(data) unset = self.bot.config.filter_valid({k: 1 for k in self.bot.config.all_keys if k not in data}) - return await self.db.config.update_one( - {"bot_id": self.bot.user.id}, {"$set": toset, "$unset": unset} - ) + if toset and unset: + return await self.db.config.update_one( + {"bot_id": self.bot.user.id}, {"$set": toset, "$unset": unset} + ) + if toset: + return await self.db.config.update_one( + {"bot_id": self.bot.user.id}, {"$set": toset} + ) + if unset: + return await self.db.config.update_one( + {"bot_id": self.bot.user.id}, {"$unset": unset} + ) async def edit_message(self, message_id: Union[int, str], new_content: str) -> None: await self.logs.update_one( diff --git a/core/config.py b/core/config.py index a745ae3231..fcd6ada080 100644 --- a/core/config.py +++ b/core/config.py @@ -27,11 +27,11 @@ class ConfigManager: "main_category_id": None, "prefix": '?', "mention": '@here', - "main_color": discord.Color.blurple(), + "main_color": '#7289da', "user_typing": False, "mod_typing": False, - "account_age": isodate.Duration(), - "guild_age": isodate.Duration(), + "account_age": None, + "guild_age": None, "reply_without_command": False, # logging "log_channel_id": None, @@ -43,16 +43,17 @@ class ConfigManager: "thread_auto_close": 0, "thread_auto_close_response": "This thread has been closed automatically due to inactivity after {timeout}.", "thread_creation_response": "The staff team will get back to you as soon as possible.", - "thread_creation_footer": None, + "thread_creation_footer": 'Your message has been sent', + "thread_self_closable_creation_footer": "Click the lock to close the thread", "thread_creation_title": 'Thread Created', "thread_close_footer": 'Replying will create a new thread', "thread_close_title": 'Thread Closed', "thread_close_response": '{closer.mention} has closed this Modmail thread.', "thread_self_close_response": 'You have closed this Modmail thread.', # moderation - "recipient_color": discord.Color.gold(), + "recipient_color": '#f1c40f', "mod_tag": None, - "mod_color": discord.Color.green(), + "mod_color": '#2ecc71', # anonymous message "anon_username": None, "anon_avatar_url": None, diff --git a/core/thread.py b/core/thread.py index 8c0033238c..93cee5007b 100644 --- a/core/thread.py +++ b/core/thread.py @@ -158,18 +158,15 @@ async def send_genesis_message(): timestamp=channel.created_at, ) - footer = "Your message has been sent" try: recipient_thread_close = strtobool(self.bot.config["recipient_thread_close"]) except ValueError: recipient_thread_close = self.bot.config.remove('recipient_thread_close') if recipient_thread_close: - footer = "Click the lock to close the thread" - - _footer = self.bot.config["thread_creation_footer"] - if _footer is not None: - footer = _footer + footer = self.bot.config["thread_self_closable_creation_footer"] + else: + footer = self.bot.config["thread_creation_footer"] embed.set_footer(text=footer, icon_url=self.bot.guild.icon_url) embed.title = self.bot.config["thread_creation_title"] From 863fdeb5bb537e812cec40f4911a52d1be8199b2 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Tue, 2 Jul 2019 12:29:24 -0700 Subject: [PATCH 04/50] Some fixes --- CHANGELOG.md | 4 +++- bot.py | 6 +++--- core/clients.py | 5 +---- core/config.py | 43 +++++++++++++++++++++++++++++-------------- core/models.py | 7 +++++++ 5 files changed, 43 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdc1a393eb..f09660b7b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Internal - Removed supporting code for GitHub interaction. -- All default values moved to `core/config.py`. +- All default config values moved to `core/config.py`. +- `config.cache` is no longer accessible, use `config['key']` for getting, `config['key'] = value` for setting, `config.remove('key')` for removing. +- Dynamic attribute for configs are removed, must use `config['key']` or `config.get('key')`. # v3.0.3 diff --git a/bot.py b/bot.py index 990f826676..dcff051e42 100644 --- a/bot.py +++ b/bot.py @@ -305,7 +305,7 @@ def prefix(self) -> str: @property def mod_color(self) -> int: - color = self.config.get("mod_color") + color = self.config["mod_color"] try: return int(color.lstrip("#"), base=16) except ValueError: @@ -314,7 +314,7 @@ def mod_color(self) -> int: @property def recipient_color(self) -> int: - color = self.config.get("recipient_color") + color = self.config["recipient_color"] try: return int(color.lstrip("#"), base=16) except ValueError: @@ -323,7 +323,7 @@ def recipient_color(self) -> int: @property def main_color(self) -> int: - color = self.config.get("main_color") + color = self.config["main_color"] try: return int(color.lstrip("#"), base=16) except ValueError: diff --git a/core/clients.py b/core/clients.py index 48da5f8518..2924c12488 100644 --- a/core/clients.py +++ b/core/clients.py @@ -2,15 +2,12 @@ import secrets from datetime import datetime from json import JSONDecodeError -from typing import Union, Optional +from typing import Union from discord import Member, DMChannel, TextChannel, Message -from discord.ext import commands from aiohttp import ClientResponseError, ClientResponse -from core.utils import info - logger = logging.getLogger("Modmail") diff --git a/core/config.py b/core/config.py index fcd6ada080..399f723f6f 100644 --- a/core/config.py +++ b/core/config.py @@ -1,5 +1,6 @@ import asyncio import json +import logging import os import typing from copy import deepcopy @@ -11,10 +12,10 @@ from discord.ext.commands import BadArgument from core._color_data import ALL_COLORS -from core.models import InvalidConfigError +from core.models import InvalidConfigError, Default from core.time import UserFriendlyTime - +logger = logging.getLogger("Modmail") load_dotenv() @@ -27,7 +28,7 @@ class ConfigManager: "main_category_id": None, "prefix": '?', "mention": '@here', - "main_color": '#7289da', + "main_color": str(discord.Color.blurple()), "user_typing": False, "mod_typing": False, "account_age": None, @@ -51,9 +52,9 @@ class ConfigManager: "thread_close_response": '{closer.mention} has closed this Modmail thread.', "thread_self_close_response": 'You have closed this Modmail thread.', # moderation - "recipient_color": '#f1c40f', + "recipient_color": str(discord.Color.gold()), "mod_tag": None, - "mod_color": '#2ecc71', + "mod_color": str(discord.Color.green()), # anonymous message "anon_username": None, "anon_avatar_url": None, @@ -178,19 +179,22 @@ async def clean_data(self, key: str, val: typing.Any) -> typing.Tuple[str, str]: async def update(self): """Updates the config with data from the cache""" - await self.api.update_config(self._cache) + await self.api.update_config(self.filter_default(self._cache)) async def refresh(self) -> dict: """Refreshes internal cache with data from database""" - data = await self.api.get_config() + data = {k.lower(): v for k, v in (await self.api.get_config()).items() if k.lower() in self.all_keys} self._cache.update(data) - self.ready_event.set() + if not self.ready_event.is_set(): + self.ready_event.set() + logger.info('Config ready.') return self._cache async def wait_until_ready(self) -> None: await self.ready_event.wait() def __setitem__(self, key: str, item: typing.Any) -> None: + logger.info('Setting %s.', key) if key not in self.all_keys: raise InvalidConfigError(f'Configuration "{key}" is invalid.') self._cache[key] = item @@ -199,23 +203,29 @@ def __getitem__(self, key: str) -> typing.Any: if key not in self.all_keys: raise InvalidConfigError(f'Configuration "{key}" is invalid.') if key not in self._cache: - val = deepcopy(self.defaults[key]) - self._cache[key] = val + self._cache[key] = deepcopy(self.defaults[key]) return self._cache[key] - def get(self, key: str, default: typing.Any = None) -> typing.Any: + def get(self, key: str, default: typing.Any = Default) -> typing.Any: if key not in self.all_keys: raise InvalidConfigError(f'Configuration "{key}" is invalid.') if key not in self._cache: - self._cache[key] = default + self._cache[key] = deepcopy(self.defaults[key]) + if default is Default: + return self._cache[key] + return default + if self._cache[key] == self.defaults[key] and default is not Default: + return default return self._cache[key] def set(self, key: str, item: typing.Any) -> None: + logger.info('Setting %s.', key) if key not in self.all_keys: raise InvalidConfigError(f'Configuration "{key}" is invalid.') self._cache[key] = item def remove(self, key: str) -> typing.Any: + logger.info('Removing %s.', key) if key not in self.all_keys: raise InvalidConfigError(f'Configuration "{key}" is invalid.') self._cache[key] = deepcopy(self.defaults[key]) @@ -224,6 +234,11 @@ def remove(self, key: str) -> typing.Any: def items(self) -> typing.Iterable: return self._cache.items() - def filter_valid(self, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + @classmethod + def filter_valid(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: return {k.lower(): v for k, v in data.items() - if k.lower() in self.public_keys or k.lower() in self.private_keys} + if k.lower() in cls.public_keys or k.lower() in cls.private_keys} + + @classmethod + def filter_default(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + return {k.lower(): v for k, v in data.items() if v != cls.defaults[k.lower()]} diff --git a/core/models.py b/core/models.py index 3877ab23d6..36e480f809 100644 --- a/core/models.py +++ b/core/models.py @@ -24,3 +24,10 @@ def __init__(self, msg, *args): @property def embed(self): return Embed(title="Error", description=self.msg, color=Color.red()) + + +class _Default: + pass + + +Default = _Default() From f3afd990a7b3d5de548a425c7feafb1804f5c36a Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Wed, 3 Jul 2019 13:30:20 -0700 Subject: [PATCH 05/50] Some fixes + changelog --- CHANGELOG.md | 9 +++++++++ bot.py | 4 +++- cogs/modmail.py | 34 +++++++++++++++++++++------------- cogs/utility.py | 11 +++++++---- core/config.py | 1 + core/thread.py | 4 ++-- 6 files changed, 43 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f09660b7b9..c1969c6cc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `thread_auto_close_response` has a configurable variable `{timeout}`. - New configuration variable `thread_self_closable_creation_footer`, the footer when `recipient_thread_close` is enabled. +- `?snippet` is now the default command name instead of `?snippets` (`?snippets` is still usable). This is to make this consistent with `?alias`/`?aliases`. + +### Fixes + +- `?notify` no longer carries over to the next thread. + +### Added + +- `?sfw`, mark a thread as "safe for work", undos `?nsfw`. ### Internal diff --git a/bot.py b/bot.py index dcff051e42..d6b89df692 100644 --- a/bot.py +++ b/bot.py @@ -173,6 +173,8 @@ def _load_extensions(self): def run(self, *args, **kwargs): try: self.loop.run_until_complete(self.start(self.token)) + except KeyboardInterrupt: + pass except discord.LoginFailure: logger.critical(error("Invalid token")) except Exception: @@ -391,7 +393,7 @@ async def on_ready(self): info(f"There are {len(closures)} thread(s) pending to be closed.") ) - for recipient_id, items in closures.items(): + for recipient_id, items in tuple(closures.items()): after = ( datetime.fromisoformat(items["time"]) - datetime.utcnow() ).total_seconds() diff --git a/cogs/modmail.py b/cogs/modmail.py index 4d9f126510..3d3450d7d0 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -90,19 +90,19 @@ async def setup(self, ctx): await self.bot.update_perms(PermissionLevel.REGULAR, -1) await self.bot.update_perms(PermissionLevel.OWNER, ctx.author.id) - @commands.group(invoke_without_command=True) + @commands.group(aliases=['snippets'], invoke_without_command=True) @checks.has_permissions(PermissionLevel.SUPPORTER) - async def snippets(self, ctx): + async def snippet(self, ctx): """ Create pre-defined messages for use in threads. - When `{prefix}snippets` is used by itself, this will retrieve + When `{prefix}snippet` is used by itself, this will retrieve a list of snippets that are currently set. To use snippets: First create a snippet using: - - `{prefix}snippets add snippet-name A pre-defined text.` + - `{prefix}snippet add snippet-name A pre-defined text.` Afterwards, you can use your snippet in a thread channel with `{prefix}snippet-name`, the message "A pre-defined text." @@ -125,7 +125,7 @@ async def snippets(self, ctx): description="You dont have any snippets at the moment.", ) embed.set_footer( - text=f"Do {self.bot.prefix}help snippets for more commands." + text=f"Do {self.bot.prefix}help snippet for more commands." ) embed.set_author(name="Snippets", icon_url=ctx.guild.icon_url) @@ -143,14 +143,14 @@ async def snippets(self, ctx): session = PaginatorSession(ctx, *embeds) await session.run() - @snippets.command(name="add") + @snippet.command(name="add") @checks.has_permissions(PermissionLevel.SUPPORTER) - async def snippets_add(self, ctx, name: str.lower, *, value): + async def snippet_add(self, ctx, name: str.lower, *, value): """ Add a snippet. To add a multi-word snippet name, use quotes: ``` - {prefix}snippets add "two word" this is a two word snippet. + {prefix}snippet add "two word" this is a two word snippet. ``` """ if name in self.bot.snippets: @@ -171,9 +171,9 @@ async def snippets_add(self, ctx, name: str.lower, *, value): await ctx.send(embed=embed) - @snippets.command(name="remove", aliases=["del", "delete", "rm"]) + @snippet.command(name="remove", aliases=["del", "delete", "rm"]) @checks.has_permissions(PermissionLevel.SUPPORTER) - async def snippets_remove(self, ctx, *, name: str.lower): + async def snippet_remove(self, ctx, *, name: str.lower): """Remove a snippet.""" if name in self.bot.snippets: @@ -194,14 +194,14 @@ async def snippets_remove(self, ctx, *, name: str.lower): await ctx.send(embed=embed) - @snippets.command(name="edit") + @snippet.command(name="edit") @checks.has_permissions(PermissionLevel.SUPPORTER) - async def snippets_edit(self, ctx, name: str.lower, *, value): + async def snippet_edit(self, ctx, name: str.lower, *, value): """ Edit a snippet. To edit a multi-word snippet name, use quotes: ``` - {prefix}snippets edit "two word" this is a new two word snippet. + {prefix}snippet edit "two word" this is a new two word snippet. ``` """ if name in self.bot.snippets: @@ -502,6 +502,14 @@ async def nsfw(self, ctx): await ctx.channel.edit(nsfw=True) await ctx.message.add_reaction("✅") + @commands.command() + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def sfw(self, ctx): + """Flags a Modmail thread as SFW (safe for work).""" + await ctx.channel.edit(nsfw=False) + await ctx.message.add_reaction("✅") + @commands.command() @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() diff --git a/cogs/utility.py b/cogs/utility.py index d1909df4da..e271cfc0a3 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -787,14 +787,14 @@ async def alias(self, ctx): To use alias: - First create a snippet using: + First create an alias using: - `{prefix}alias add alias-name other-command` For example: - `{prefix}alias add r reply` - Now you can use `{prefix}r` as an replacement for `{prefix}reply`. - See also `{prefix}snippets`. + See also `{prefix}snippet`. """ embeds = [] @@ -808,7 +808,7 @@ async def alias(self, ctx): description="You dont have any aliases at the moment.", ) embed.set_author(name="Command aliases", icon_url=ctx.guild.icon_url) - embed.set_footer(text=f"Do {self.bot.prefix}" "help aliases for more commands.") + embed.set_footer(text=f"Do {self.bot.prefix}help alias for more commands.") embeds.append(embed) for name, value in self.bot.aliases.items(): @@ -816,7 +816,7 @@ async def alias(self, ctx): embed = Embed(color=self.bot.main_color, description=desc) embed.set_author(name="Command aliases", icon_url=ctx.guild.icon_url) embed.set_footer( - text=f"Do {self.bot.prefix}help " "aliases for more commands." + text=f"Do {self.bot.prefix}help alias for more commands." ) embeds.append(embed) @@ -886,6 +886,9 @@ async def alias_remove(self, ctx, *, name: str.lower): @alias.command(name="edit") @checks.has_permissions(PermissionLevel.MODERATOR) async def alias_edit(self, ctx, name: str.lower, *, value): + """ + Edit an alias. + """ if name not in self.bot.aliases: embed = Embed( title="Error", diff --git a/core/config.py b/core/config.py index 399f723f6f..2a774ae6eb 100644 --- a/core/config.py +++ b/core/config.py @@ -241,4 +241,5 @@ def filter_valid(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, ty @classmethod def filter_default(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + # TODO: use .get to prevent errors return {k.lower(): v for k, v in data.items() if v != cls.defaults[k.lower()]} diff --git a/core/thread.py b/core/thread.py index 93cee5007b..843acb9fd7 100644 --- a/core/thread.py +++ b/core/thread.py @@ -236,8 +236,8 @@ async def _close( # Cancel auto closing the thread if closed by any means. - if str(self.id) in self.bot.config['subscriptions']: - self.bot.config['subscriptions'].pop(str(self.id)) + self.bot.config['subscriptions'].pop(str(self.id), None) + self.bot.config['notification_squad'].pop(str(self.id), None) # Logging log_data = await self.bot.api.post_log( From 2bb545fd7d6dee70bfebbdd9973a7ac4ca126a9b Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Fri, 5 Jul 2019 19:10:11 -0700 Subject: [PATCH 06/50] Update - see changelog --- CHANGELOG.md | 3 +- bot.py | 196 ++++++++++++++++++++++-------------------------- cogs/modmail.py | 10 +-- cogs/plugins.py | 13 ++-- cogs/utility.py | 21 ++---- core/checks.py | 9 +-- core/config.py | 6 +- core/models.py | 42 +++++++++++ core/thread.py | 5 +- core/time.py | 4 +- core/utils.py | 9 --- 11 files changed, 161 insertions(+), 157 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1969c6cc9..7b37abfc87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixes - `?notify` no longer carries over to the next thread. - +- `discord.NotFound` errors for `on_raw_reaction_add`. ### Added - `?sfw`, mark a thread as "safe for work", undos `?nsfw`. @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - All default config values moved to `core/config.py`. - `config.cache` is no longer accessible, use `config['key']` for getting, `config['key'] = value` for setting, `config.remove('key')` for removing. - Dynamic attribute for configs are removed, must use `config['key']` or `config.get('key')`. +- Removed helper functions `info()` and `error()` for formatting logging, it's formatted automatically now. # v3.0.3 diff --git a/bot.py b/bot.py index d6b89df692..8726a9a7cf 100644 --- a/bot.py +++ b/bot.py @@ -17,20 +17,20 @@ import isodate from aiohttp import ClientSession -from colorama import init, Fore, Style from emoji import UNICODE_EMOJI from motor.motor_asyncio import AsyncIOMotorClient from core.clients import ApiClient, PluginDatabaseClient from core.config import ConfigManager -from core.utils import info, error, human_join, strtobool -from core.models import PermissionLevel +from core.utils import human_join, strtobool +from core.models import PermissionLevel, ModmailLogger from core.thread import ThreadManager from core.time import human_timedelta -init() -logger = logging.getLogger("Modmail") +logger: ModmailLogger = logging.getLogger("Modmail") +logger.__class__ = ModmailLogger + logger.setLevel(logging.INFO) ch = logging.StreamHandler(stream=sys.stdout) @@ -52,17 +52,18 @@ def format(self, record): if not os.path.exists(temp_dir): os.mkdir(temp_dir) -LINE = Fore.BLACK + Style.BRIGHT + "-------------------------" + Style.RESET_ALL - class ModmailBot(commands.Bot): def __init__(self): super().__init__(command_prefix=None) # implemented in `get_prefix` - self._session = ClientSession(loop=self.loop) - self._config = ConfigManager(self) - - self.start_time = datetime.utcnow() + self._session = None + self._api = None self._connected = asyncio.Event() + self.start_time = datetime.utcnow() + + self.config = ConfigManager(self) + self.config.populate_cache() + self.threads = ThreadManager(self) self._configure_logging() @@ -70,9 +71,7 @@ def __init__(self): if mongo_uri is None: raise ValueError('A Mongo URI is necessary for the bot to function.') - self._db = AsyncIOMotorClient(mongo_uri).modmail_bot - self._threads = ThreadManager(self) - self.api = ApiClient(self) + self.db = AsyncIOMotorClient(mongo_uri).modmail_bot self.plugin_db = PluginDatabaseClient(self) self.metadata_task = self.loop.create_task(self.metadata_loop()) @@ -116,59 +115,56 @@ def _configure_logging(self): logger.addHandler(ch_debug) log_level = logging_levels.get(level_text) - logger.info(LINE) + if log_level is None: + log_level = self.config.remove('log_level') + + logger.line() if log_level is not None: logger.setLevel(log_level) ch.setLevel(log_level) - logger.info(info("Logging level: " + level_text)) + logger.info("Logging level: " + level_text) else: - logger.info(error("Invalid logging level set. ")) - logger.info(info("Using default logging level: INFO")) + logger.info("Invalid logging level set. ") + logger.warning("Using default logging level: INFO") @property def version(self) -> str: return __version__ - @property - def db(self) -> AsyncIOMotorClient: - if self._db is None: - raise ValueError('No database instance found.') - return self._db - - @property - def config(self) -> ConfigManager: - return self._config - @property def session(self) -> ClientSession: + if self._session is None: + self._session = ClientSession(loop=self.loop) return self._session @property - def threads(self) -> ThreadManager: - return self._threads + def api(self): + if self._api is None: + self._api = ApiClient(self) + return self._api async def get_prefix(self, message=None): return [self.prefix, f"<@{self.user.id}> ", f"<@!{self.user.id}> "] def _load_extensions(self): """Adds commands automatically""" - logger.info(LINE) - logger.info(info("┌┬┐┌─┐┌┬┐┌┬┐┌─┐┬┬")) - logger.info(info("││││ │ │││││├─┤││")) - logger.info(info("┴ ┴└─┘─┴┘┴ ┴┴ ┴┴┴─┘")) - logger.info(info(f"v{__version__}")) - logger.info(info("Authors: kyb3r, fourjr, Taaku18")) - logger.info(LINE) + logger.line() + logger.info("┌┬┐┌─┐┌┬┐┌┬┐┌─┐┬┬") + logger.info("││││ │ │││││├─┤││") + logger.info("┴ ┴└─┘─┴┘┴ ┴┴ ┴┴┴─┘") + logger.info(f"v{__version__}") + logger.info("Authors: kyb3r, fourjr, Taaku18") + logger.line() for file in os.listdir("cogs"): if not file.endswith(".py"): continue cog = f"cogs.{file[:-3]}" - logger.info(info(f"Loading {cog}")) + logger.info(f"Loading {cog}") try: self.load_extension(cog) except Exception: - logger.exception(error(f"Failed to load {cog}")) + logger.exception(f"Failed to load {cog}") def run(self, *args, **kwargs): try: @@ -176,27 +172,26 @@ def run(self, *args, **kwargs): except KeyboardInterrupt: pass except discord.LoginFailure: - logger.critical(error("Invalid token")) + logger.critical("Invalid token") except Exception: - logger.critical(error("Fatal exception"), exc_info=True) + logger.critical("Fatal exception", exc_info=True) finally: try: self.metadata_task.cancel() self.loop.run_until_complete(self.metadata_task) except asyncio.CancelledError: - logger.debug(info("metadata_task has been cancelled.")) + logger.debug("metadata_task has been cancelled.") self.loop.run_until_complete(self.logout()) - for task in asyncio.Task.all_tasks(): + for task in asyncio.all_tasks(self.loop): task.cancel() try: - self.loop.run_until_complete(asyncio.gather(*asyncio.Task.all_tasks())) + self.loop.run_until_complete(asyncio.gather(*asyncio.all_tasks(self.loop))) except asyncio.CancelledError: - logger.debug(info("All pending tasks has been cancelled.")) + logger.debug("All pending tasks has been cancelled.") finally: self.loop.run_until_complete(self.session.close()) - self.loop.close() - logger.info(error(" - Shutting down bot - ")) + logger.error(" - Shutting down bot - ") async def is_owner(self, user: discord.User) -> bool: owners = self.config['owners'] @@ -218,8 +213,8 @@ def log_channel(self) -> typing.Optional[discord.TextChannel]: return self.main_category.channels[0] except IndexError: pass - logger.info(info(f'No log channel set, set one with `%ssetup` or ' - f'`%sconfig set log_channel_id `.'), self.prefix, self.prefix) + logger.info(f'No log channel set, set one with `%ssetup` or ' + f'`%sconfig set log_channel_id `.', self.prefix, self.prefix) @property def is_connected(self) -> bool: @@ -275,7 +270,7 @@ def modmail_guild(self) -> typing.Optional[discord.Guild]: if guild is not None: return guild self.config.remove('modmail_guild_id') - logger.error(error('Invalid modmail_guild_id set.')) + logger.error('Invalid modmail_guild_id set.') return self.guild @property @@ -311,7 +306,7 @@ def mod_color(self) -> int: try: return int(color.lstrip("#"), base=16) except ValueError: - logger.error(error("Invalid mod_color provided.")) + logger.error("Invalid mod_color provided.") return int(self.config.remove('mod_color').lstrip("#"), base=16) @property @@ -320,7 +315,7 @@ def recipient_color(self) -> int: try: return int(color.lstrip("#"), base=16) except ValueError: - logger.error(error("Invalid recipient_color provided.")) + logger.error("Invalid recipient_color provided.") return int(self.config.remove('recipient_color').lstrip("#"), base=16) @property @@ -329,18 +324,18 @@ def main_color(self) -> int: try: return int(color.lstrip("#"), base=16) except ValueError: - logger.error(error("Invalid main_color provided.")) + logger.error("Invalid main_color provided.") return int(self.config.remove('main_color').lstrip("#"), base=16) async def on_connect(self): - logger.info(LINE) + logger.line() try: await self.validate_database_connection() except Exception: return await self.logout() - logger.info(LINE) - logger.info(info("Connected to gateway.")) + logger.line() + logger.info("Connected to gateway.") await self.config.refresh() await self.setup_indexes() self._connected.set() @@ -355,12 +350,12 @@ async def setup_indexes(self): # Backwards compatibility old_index = "messages.content_text_messages.author.name_text" if old_index in index_info: - logger.info(info(f"Dropping old index: {old_index}")) + logger.info(f"Dropping old index: {old_index}") await coll.drop_index(old_index) if index_name not in index_info: - logger.info(info('Creating "text" index for logs collection.')) - logger.info(info("Name: " + index_name)) + logger.info('Creating "text" index for logs collection.') + logger.info("Name: " + index_name) await coll.create_index( [ ("messages.content", "text"), @@ -375,23 +370,21 @@ async def on_ready(self): # Wait until config cache is populated with stuff from db and on_connect ran await self.wait_for_connected() - logger.info(LINE) - logger.info(info("Client ready.")) - logger.info(LINE) - logger.info(info(f"Logged in as: {self.user}")) - logger.info(info(f"User ID: {self.user.id}")) - logger.info(info(f"Prefix: {self.prefix}")) - logger.info(info(f"Guild Name: {self.guild.name if self.guild else 'Invalid'}")) - logger.info(info(f"Guild ID: {self.guild.id if self.guild else 'Invalid'}")) - logger.info(LINE) + logger.line() + logger.info("Client ready.") + logger.line() + logger.info("Logged in as: %s", str(self.user)) + logger.info("User ID: %s", str(self.user.id)) + logger.info("Prefix: %s", str(self.prefix)) + logger.info("Guild Name: %s", self.guild.name if self.guild else 'Invalid') + logger.info("Guild ID: %s", self.guild.id if self.guild else 'Invalid') + logger.line() await self.threads.populate_cache() # closures closures = self.config['closures'] - logger.info( - info(f"There are {len(closures)} thread(s) pending to be closed.") - ) + logger.info("There are %d thread(s) pending to be closed.", len(closures)) for recipient_id, items in tuple(closures.items()): after = ( @@ -417,7 +410,7 @@ async def on_ready(self): auto_close=items.get("auto_close", False), ) - logger.info(LINE) + logger.line() async def convert_emoji(self, name: str) -> str: ctx = SimpleNamespace(bot=self, guild=self.modmail_guild) @@ -427,7 +420,7 @@ async def convert_emoji(self, name: str) -> str: try: name = await converter.convert(ctx, name.strip(":")) except commands.BadArgument: - logger.warning(info("%s is not a valid emoji."), name) + logger.warning("%s is not a valid emoji.", name) raise return name @@ -440,14 +433,14 @@ async def retrieve_emoji(self) -> typing.Tuple[str, str]: try: sent_emoji = await self.convert_emoji(sent_emoji) except commands.BadArgument: - logger.warning(error("Removed sent emoji (%s)."), sent_emoji) + logger.warning("Removed sent emoji (%s).", sent_emoji) sent_emoji = self.config.remove("sent_emoji") if blocked_emoji != "disable": try: blocked_emoji = await self.convert_emoji(blocked_emoji) except commands.BadArgument: - logger.warning(error("Removed blocked emoji (%s)."), blocked_emoji) + logger.warning("Removed blocked emoji (%s).", blocked_emoji) blocked_emoji = self.config.remove("blocked_emoji") await self.config.update() @@ -660,7 +653,7 @@ async def update_perms( else: if value in permissions[name]: permissions[name].remove(value) - logger.info(info(f"Updating permissions for {name}, {value} (add={add}).")) + logger.info(f"Updating permissions for {name}, {value} (add={add}).") await self.config.update() async def on_message(self, message): @@ -747,7 +740,11 @@ async def on_raw_reaction_add(self, payload): return channel = await _thread.recipient.create_dm() - message = await channel.fetch_message(payload.message_id) + try: + message = await channel.fetch_message(payload.message_id) + except discord.NotFound: + return + reaction = payload.emoji close_emoji = await self.convert_emoji(self.config["close_emoji"]) @@ -859,8 +856,8 @@ async def on_message_edit(self, before, after): break async def on_error(self, event_method, *args, **kwargs): - logger.error(error("Ignoring exception in {}".format(event_method))) - logger.error(error("Unexpected exception:"), exc_info=sys.exc_info()) + logger.error("Ignoring exception in %s", str(event_method)) + logger.error("Unexpected exception:", exc_info=sys.exc_info()) async def on_command_error(self, context, exception): if isinstance(exception, commands.BadUnionArgument): @@ -880,7 +877,7 @@ async def on_command_error(self, context, exception): ) ) elif isinstance(exception, commands.CommandNotFound): - logger.warning(error("CommandNotFound: " + str(exception))) + logger.warning("CommandNotFound: %s", str(exception)) elif isinstance(exception, commands.MissingRequiredArgument): await context.send_help(context.command) elif isinstance(exception, commands.CheckFailure): @@ -891,9 +888,9 @@ async def on_command_error(self, context, exception): color=discord.Color.red(), description=check.fail_msg ) ) - logger.warning(error("CheckFailure: " + str(exception))) + logger.warning("CheckFailure: %s", str(exception)) else: - logger.error(error("Unexpected exception:"), exc_info=exception) + logger.error("Unexpected exception:", exc_info=exception) @staticmethod def overwrites(ctx: commands.Context) -> dict: @@ -912,37 +909,22 @@ async def validate_database_connection(self): try: await self.db.command("buildinfo") except Exception as exc: - logger.critical( - error("Something went wrong " "while connecting to the database.") - ) + logger.critical("Something went wrong " "while connecting to the database.") message = f"{type(exc).__name__}: {str(exc)}" - logger.critical(error(message)) + logger.critical(message) if "ServerSelectionTimeoutError" in message: - logger.critical( - error( - "This may have been caused by not whitelisting " - "IPs correctly. Make sure to whitelist all " - "IPs (0.0.0.0/0) https://i.imgur.com/mILuQ5U.png" - ) - ) + logger.critical("This may have been caused by not whitelisting " + "IPs correctly. Make sure to whitelist all " + "IPs (0.0.0.0/0) https://i.imgur.com/mILuQ5U.png") if "OperationFailure" in message: - logger.critical( - error( - "This is due to having invalid credentials in your MONGO_URI." - ) - ) - logger.critical( - error( - "Recheck the username/password and make sure to url encode them. " - "https://www.urlencoder.io/" - ) - ) - + logger.critical("This is due to having invalid credentials in your MONGO_URI.") + logger.critical("Recheck the username/password and make sure to url encode them. " + "https://www.urlencoder.io/") raise else: - logger.info(info("Successfully connected to the database.")) + logger.info("Successfully connected to the database.") async def metadata_loop(self): await self.wait_for_connected() @@ -969,7 +951,7 @@ async def metadata_loop(self): } async with self.session.post("https://api.modmail.tk/metadata", json=data): - logger.debug(info("Uploading metadata to Modmail server.")) + logger.debug("Uploading metadata to Modmail server.") await asyncio.sleep(3600) diff --git a/cogs/modmail.py b/cogs/modmail.py index 3d3450d7d0..923927fc0c 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -1,5 +1,4 @@ import asyncio -import os from datetime import datetime from typing import Optional, Union from types import SimpleNamespace as param @@ -966,7 +965,9 @@ async def block( reason = None extend = f" for `{reason}`" if reason is not None else "" - msg = self.bot.blocked_users.get(str(user.id), "") + msg = self.bot.blocked_users.get(str(user.id)) + if msg is None: + msg = '' if ( str(user.id) not in self.bot.blocked_users @@ -1021,10 +1022,7 @@ async def unblock(self, ctx, *, user: User = None): mention = getattr(user, "mention", f"`{user.id}`") if str(user.id) in self.bot.blocked_users: - msg = self.bot.blocked_users.get(str(user.id)) - if msg is None: - msg = "" - self.bot.blocked_users.pop(str(user.id)) + msg = self.bot.blocked_users.pop(str(user.id)) or '' await self.bot.config.update() if msg.startswith("System Message: "): diff --git a/cogs/plugins.py b/cogs/plugins.py index bcfd2a7dd5..c9ecf11d06 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -18,7 +18,6 @@ from core import checks from core.models import PermissionLevel from core.paginator import PaginatorSession -from core.utils import error, info logger = logging.getLogger("Modmail") @@ -84,13 +83,13 @@ async def download_initial_plugins(self): await self.download_plugin_repo(username, repo, branch) except DownloadError as exc: msg = f"{username}/{repo}@{branch} - {exc}" - logger.error(error(msg)) + logger.error(msg) else: try: await self.load_plugin(username, repo, name, branch) except DownloadError as exc: msg = f"{username}/{repo}@{branch}[{name}] - {exc}" - logger.error(error(msg)) + logger.error(msg) async def download_plugin_repo(self, username, repo, branch): try: @@ -140,7 +139,7 @@ async def load_plugin(self, username, repo, plugin_name, branch): if err: msg = f"Requirements Download Error: {username}/{repo}@{branch}[{plugin_name}]" - logger.error(error(msg)) + logger.error(msg) raise DownloadError( f"Unable to download requirements: ```\n{err}\n```" ) from exc @@ -155,11 +154,11 @@ async def load_plugin(self, username, repo, plugin_name, branch): self.bot.load_extension(ext) except commands.ExtensionError as exc: msg = f"Plugin Load Failure: {username}/{repo}@{branch}[{plugin_name}]" - logger.error(error(msg)) + logger.error(msg) raise DownloadError("Invalid plugin") from exc else: msg = f"Loaded Plugin: {username}/{repo}@{branch}[{plugin_name}]" - logger.info(info(msg)) + logger.info(msg) @commands.group(aliases=["plugins"], invoke_without_command=True) @checks.has_permissions(PermissionLevel.OWNER) @@ -291,7 +290,7 @@ def onerror(func, path, exc_info): # pylint: disable=W0613 except Exception as exc: logger.error(str(exc)) self.bot.config['plugins'].append(plugin_name) - logger.error(error(exc)) + logger.error(exc) raise exc await self.bot.config.update() diff --git a/cogs/utility.py b/cogs/utility.py index e271cfc0a3..a5b8a71966 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -25,7 +25,7 @@ from core.decorators import trigger_typing from core.models import InvalidConfigError, PermissionLevel from core.paginator import PaginatorSession, MessagePaginatorSession -from core.utils import cleanup_code, info, error, User, get_perm_level +from core.utils import cleanup_code, User, get_perm_level logger = logging.getLogger("Modmail") @@ -169,7 +169,7 @@ async def send_group_help(self, group): await self.get_destination().send(embed=embed) async def send_error_message(self, msg): # pylint: disable=W0221 - logger.warning(error(f"CommandNotFound: {msg}")) + logger.warning("CommandNotFound: %s", str(msg)) embed = Embed(color=Color.red()) embed.set_footer( @@ -518,7 +518,7 @@ async def set_presence( except (KeyError, ValueError): if status_identifier is not None: msg = f"Invalid status type: {status_identifier}" - logger.warning(error(msg)) + logger.warning(msg) if activity_identifier is None: if activity_message is not None: @@ -536,7 +536,7 @@ async def set_presence( except (KeyError, ValueError): if activity_identifier is not None: msg = f"Invalid activity type: {activity_identifier}" - logger.warning(error(msg)) + logger.warning(msg) else: url = None activity_message = ( @@ -555,7 +555,7 @@ async def set_presence( activity = Activity(type=activity_type, name=activity_message, url=url) else: msg = "You must supply an activity message to use custom activity." - logger.warning(error(msg)) + logger.warning(msg) await self.bot.change_presence(activity=activity, status=status) @@ -577,8 +577,8 @@ async def set_presence( async def on_ready(self): # Wait until config cache is populated with stuff from db await self.bot.wait_for_connected() - logger.info(info(self.presence["activity"][1])) - logger.info(info(self.presence["status"][1])) + logger.info(self.presence["activity"][1]) + logger.info(self.presence["status"][1]) async def loop_presence(self): """Set presence to the configured value every hour.""" @@ -764,12 +764,7 @@ async def config_get(self, ctx, key: str.lower = None): "set configuration variables.", ) embed.set_author(name="Current config", icon_url=self.bot.user.avatar_url) - - config = { - key: val - for key, val in self.bot.config.items() - if val != self.bot.config.defaults['key'] and key in keys - } + config = self.bot.config.filter_defaults(self.bot.config.items()) for name, value in reversed(list(config.items())): embed.add_field(name=name, value=f"`{value}`", inline=False) diff --git a/core/checks.py b/core/checks.py index 72561b4a57..0c495bf747 100644 --- a/core/checks.py +++ b/core/checks.py @@ -3,7 +3,6 @@ from discord.ext import commands from core.models import PermissionLevel -from core.utils import error logger = logging.getLogger("Modmail") @@ -33,12 +32,8 @@ async def predicate(ctx): ) if not has_perm and ctx.command.qualified_name != "help": - logger.error( - error( - f"You does not have permission to use this command: " - f"`{ctx.command.qualified_name}` ({permission_level.name})." - ) - ) + logger.error("You does not have permission to use this command: `%s` (%s).", + str(ctx.command.qualified_name), str(permission_level.name)) return has_perm predicate.permission_level = permission_level diff --git a/core/config.py b/core/config.py index 2a774ae6eb..147ab9f89c 100644 --- a/core/config.py +++ b/core/config.py @@ -107,7 +107,6 @@ def __init__(self, bot): self.bot = bot self._cache = {} self.ready_event = asyncio.Event() - self.populate_cache() def __repr__(self): return repr(self._cache) @@ -194,12 +193,14 @@ async def wait_until_ready(self) -> None: await self.ready_event.wait() def __setitem__(self, key: str, item: typing.Any) -> None: + key = key.lower() logger.info('Setting %s.', key) if key not in self.all_keys: raise InvalidConfigError(f'Configuration "{key}" is invalid.') self._cache[key] = item def __getitem__(self, key: str) -> typing.Any: + key = key.lower() if key not in self.all_keys: raise InvalidConfigError(f'Configuration "{key}" is invalid.') if key not in self._cache: @@ -207,6 +208,7 @@ def __getitem__(self, key: str) -> typing.Any: return self._cache[key] def get(self, key: str, default: typing.Any = Default) -> typing.Any: + key = key.lower() if key not in self.all_keys: raise InvalidConfigError(f'Configuration "{key}" is invalid.') if key not in self._cache: @@ -219,12 +221,14 @@ def get(self, key: str, default: typing.Any = Default) -> typing.Any: return self._cache[key] def set(self, key: str, item: typing.Any) -> None: + key = key.lower() logger.info('Setting %s.', key) if key not in self.all_keys: raise InvalidConfigError(f'Configuration "{key}" is invalid.') self._cache[key] = item def remove(self, key: str) -> typing.Any: + key = key.lower() logger.info('Removing %s.', key) if key not in self.all_keys: raise InvalidConfigError(f'Configuration "{key}" is invalid.') diff --git a/core/models.py b/core/models.py index 36e480f809..1ac2929777 100644 --- a/core/models.py +++ b/core/models.py @@ -1,8 +1,11 @@ +import logging from enum import IntEnum from discord import Color, Embed from discord.ext import commands +from colorama import Fore, Style, init + class PermissionLevel(IntEnum): OWNER = 5 @@ -26,6 +29,45 @@ def embed(self): return Embed(title="Error", description=self.msg, color=Color.red()) +class ModmailLogger(logging.Logger): + def __init__(self, *args, **kwargs): + init() + super().__init__(*args, **kwargs) + + @staticmethod + def _debug_(*msgs): + return f'{Fore.CYAN}{" ".join(msgs)}{Style.RESET_ALL}' + + @staticmethod + def _info_(*msgs): + return f'{Fore.GREEN}{" ".join(msgs)}{Style.RESET_ALL}' + + @staticmethod + def _error_(*msgs): + return f'{Fore.RED}{" ".join(msgs)}{Style.RESET_ALL}' + + def debug(self, msg, *args, **kwargs): + return super().debug(self._debug_(msg), *args, **kwargs) + + def info(self, msg, *args, **kwargs): + return super().info(self._info_(msg), *args, **kwargs) + + def warning(self, msg, *args, **kwargs): + return super().warning(self._error_(msg), *args, **kwargs) + + def error(self, msg, *args, **kwargs): + return super().error(self._error_(msg), *args, **kwargs) + + def critical(self, msg, *args, **kwargs): + return super().critical(self._error_(msg), *args, **kwargs) + + def exception(self, msg, *args, exc_info=True, **kwargs): + return super().exception(self._error_(msg), *args, exc_info, **kwargs) + + def line(self): + super().info(Fore.BLACK + Style.BRIGHT + "-------------------------" + Style.RESET_ALL) + + class _Default: pass diff --git a/core/thread.py b/core/thread.py index 843acb9fd7..42f3a8faab 100644 --- a/core/thread.py +++ b/core/thread.py @@ -1,6 +1,5 @@ import asyncio import logging -import os import re import string import typing @@ -12,7 +11,7 @@ from discord.ext.commands import MissingRequiredArgument, CommandError from core.time import human_timedelta -from core.utils import is_image_url, days, match_user_id, truncate, ignore, error, strtobool +from core.utils import is_image_url, days, match_user_id, truncate, ignore, strtobool logger = logging.getLogger("Modmail") @@ -463,7 +462,7 @@ async def reply(self, message: discord.Message, anonymous: bool = False) -> None message, destination=self.recipient, from_mod=True, anonymous=anonymous ) except Exception: - logger.info(error("Message delivery failed:"), exc_info=True) + logger.error("Message delivery failed:", exc_info=True) tasks.append( message.channel.send( embed=discord.Embed( diff --git a/core/time.py b/core/time.py index a99efee78b..b4689cc518 100644 --- a/core/time.py +++ b/core/time.py @@ -12,8 +12,6 @@ import parsedatetime as pdt from dateutil.relativedelta import relativedelta -from core.utils import error - logger = logging.getLogger("Modmail") @@ -196,7 +194,7 @@ async def convert(self, ctx, argument): return await self.check_constraints(ctx, self.now, remaining) except Exception: - logger.exception(error("Something went wrong while parsing the time")) + logger.exception("Something went wrong while parsing the time.") raise diff --git a/core/utils.py b/core/utils.py index ca97c658cd..0d999b8f47 100644 --- a/core/utils.py +++ b/core/utils.py @@ -6,7 +6,6 @@ from discord import Object from discord.ext import commands -from colorama import Fore, Style from core.models import PermissionLevel @@ -16,14 +15,6 @@ def strtobool(val): return _stb(str(val)) -def info(*msgs): - return f'{Fore.CYAN}{" ".join(msgs)}{Style.RESET_ALL}' - - -def error(*msgs): - return f'{Fore.RED}{" ".join(msgs)}{Style.RESET_ALL}' - - class User(commands.IDConverter): """ A custom discord.py `Converter` that From ef678c0c59d24280457ee91d1b6d413284affe7c Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Fri, 5 Jul 2019 19:22:03 -0700 Subject: [PATCH 07/50] Fixed some logging problems --- CHANGELOG.md | 1 + bot.py | 2 +- core/models.py | 17 +++++++++++------ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b37abfc87..568adf91b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `?notify` no longer carries over to the next thread. - `discord.NotFound` errors for `on_raw_reaction_add`. + ### Added - `?sfw`, mark a thread as "safe for work", undos `?nsfw`. diff --git a/bot.py b/bot.py index 8726a9a7cf..0874bd54a9 100644 --- a/bot.py +++ b/bot.py @@ -35,7 +35,7 @@ ch = logging.StreamHandler(stream=sys.stdout) ch.setLevel(logging.INFO) -formatter = logging.Formatter("%(filename)s - %(levelname)s: %(message)s") +formatter = logging.Formatter("%(filename)s[%(lineno)d] - %(levelname)s: %(message)s") ch.setFormatter(formatter) logger.addHandler(ch) diff --git a/core/models.py b/core/models.py index 1ac2929777..e27904b07c 100644 --- a/core/models.py +++ b/core/models.py @@ -50,22 +50,27 @@ def debug(self, msg, *args, **kwargs): return super().debug(self._debug_(msg), *args, **kwargs) def info(self, msg, *args, **kwargs): - return super().info(self._info_(msg), *args, **kwargs) + if self.isEnabledFor(logging.INFO): + self._log(logging.INFO, self._info_(msg), args, **kwargs) def warning(self, msg, *args, **kwargs): - return super().warning(self._error_(msg), *args, **kwargs) + if self.isEnabledFor(logging.WARNING): + self._log(logging.WARNING, self._error_(msg), args, **kwargs) def error(self, msg, *args, **kwargs): - return super().error(self._error_(msg), *args, **kwargs) + if self.isEnabledFor(logging.ERROR): + self._log(logging.ERROR, self._error_(msg), args, **kwargs) def critical(self, msg, *args, **kwargs): - return super().critical(self._error_(msg), *args, **kwargs) + if self.isEnabledFor(logging.CRITICAL): + self._log(logging.CRITICAL, self._error_(msg), args, **kwargs) def exception(self, msg, *args, exc_info=True, **kwargs): - return super().exception(self._error_(msg), *args, exc_info, **kwargs) + self.error(msg, *args, exc_info=exc_info, **kwargs) def line(self): - super().info(Fore.BLACK + Style.BRIGHT + "-------------------------" + Style.RESET_ALL) + if self.isEnabledFor(logging.INFO): + self._log(logging.INFO, Fore.BLACK + Style.BRIGHT + "-------------------------" + Style.RESET_ALL, []) class _Default: From f40de028770563b84e65a8e8594036b82270cd1c Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Fri, 5 Jul 2019 19:48:39 -0700 Subject: [PATCH 08/50] Added config check --- bot.py | 2 +- cogs/utility.py | 29 ++++++++++++++++++----------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/bot.py b/bot.py index 0874bd54a9..d83b34c908 100644 --- a/bot.py +++ b/bot.py @@ -108,7 +108,7 @@ def _configure_logging(self): ch_debug.setLevel(logging.DEBUG) formatter_debug = FileFormatter( - "%(asctime)s %(filename)s - " "%(levelname)s: %(message)s", + "%(asctime)s %(filename)s[%(lineno)d] - %(levelname)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) ch_debug.setFormatter(formatter_debug) diff --git a/cogs/utility.py b/cogs/utility.py index a5b8a71966..c72ec2a5a5 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -7,7 +7,7 @@ from contextlib import redirect_stdout from datetime import datetime from difflib import get_close_matches -from io import StringIO +from io import StringIO, BytesIO from typing import Union from types import SimpleNamespace as param from json import JSONDecodeError, loads @@ -359,21 +359,26 @@ async def debug_hastebin(self, ctx): os.path.dirname(os.path.abspath(__file__)), f"../temp/{log_file_name}.log", ), - "r+", + "rb+", ) as f: - logs = f.read().strip() + logs = BytesIO(f.read().strip()) try: async with self.bot.session.post( haste_url + "/documents", data=logs ) as resp: - key = (await resp.json())["key"] + data = await resp.json() + try: + key = data["key"] + except KeyError: + logger.error(data['message']) + raise embed = Embed( title="Debug Logs", color=self.bot.main_color, description=f"{haste_url}/" + key, ) - except (JSONDecodeError, ClientResponseError, IndexError): + except (JSONDecodeError, ClientResponseError, IndexError, KeyError): embed = Embed( title="Debug Logs", color=self.bot.main_color, @@ -607,18 +612,19 @@ async def mention(self, ctx, *, mention: str = None): Type only `{prefix}mention` to retrieve your current "mention" message. """ + # TODO: ability to disable mention. current = self.bot.config["mention"] if mention is None: embed = Embed( - title="Current text", + title="Current mention:", color=self.bot.main_color, description=str(current), ) else: embed = Embed( title="Changed mention!", - description=f"On thread creation the bot now says {mention}.", + description=f'On thread creation the bot now says "{mention}".', color=self.bot.main_color, ) self.bot.config["mention"] = mention @@ -710,7 +716,7 @@ async def config_set(self, ctx, key: str.lower, *, value: str): @checks.has_permissions(PermissionLevel.OWNER) async def config_remove(self, ctx, key: str.lower): """Delete a set configuration variable.""" - keys = self.bot.config.allowed_to_change_in_command + keys = self.bot.config.public_keys if key in keys: self.bot.config.remove(key) await self.bot.config.update() @@ -764,10 +770,11 @@ async def config_get(self, ctx, key: str.lower = None): "set configuration variables.", ) embed.set_author(name="Current config", icon_url=self.bot.user.avatar_url) - config = self.bot.config.filter_defaults(self.bot.config.items()) + config = self.bot.config.filter_default(self.bot.config) - for name, value in reversed(list(config.items())): - embed.add_field(name=name, value=f"`{value}`", inline=False) + for name, value in config.items(): + if name in self.bot.config.public_keys: + embed.add_field(name=name, value=f"`{value}`", inline=False) return await ctx.send(embed=embed) From f387f34ae48709ebc59471dc03c6b4abc3d5470c Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Mon, 8 Jul 2019 20:02:17 -0700 Subject: [PATCH 09/50] Some changes see cl --- CHANGELOG.md | 1 + bot.py | 29 +++++++++++++++++++++++++---- cogs/modmail.py | 7 ++++--- cogs/utility.py | 6 +++--- core/config.py | 15 +++++++++++++-- 5 files changed, 46 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 568adf91b3..9bdc482732 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `?notify` no longer carries over to the next thread. - `discord.NotFound` errors for `on_raw_reaction_add`. +- `mod_typing` and `user_typing` will no longer show when user is blocked. ### Added diff --git a/bot.py b/bot.py index d83b34c908..77f5be4161 100644 --- a/bot.py +++ b/bot.py @@ -210,11 +210,14 @@ def log_channel(self) -> typing.Optional[discord.TextChannel]: self.config.remove('log_channel_id') if self.main_category is not None: try: - return self.main_category.channels[0] + channel = self.main_category.channels[0] + self.config['log_channel_id'] = channel.id + logger.debug('No log channel set, however, one was found. Setting...') + return channel except IndexError: pass - logger.info(f'No log channel set, set one with `%ssetup` or ' - f'`%sconfig set log_channel_id `.', self.prefix, self.prefix) + logger.info('No log channel set, set one with `%ssetup` or ' + '`%sconfig set log_channel_id `.', self.prefix, self.prefix) @property def is_connected(self) -> bool: @@ -286,7 +289,11 @@ def main_category(self) -> typing.Optional[discord.CategoryChannel]: if cat is not None: return cat self.config.remove("main_category_id") - return discord.utils.get(self.modmail_guild.categories, name="Modmail") + cat = discord.utils.get(self.modmail_guild.categories, name="Modmail") + if cat is not None: + self.config['main_category_id'] = cat.id + logger.debug('No main category set, however, one was found. Setting...') + return cat @property def blocked_users(self) -> typing.Dict[str, str]: @@ -705,7 +712,16 @@ async def on_typing(self, channel, user, _): if user.bot: return + + async def _(*args, **kwargs): + pass + if isinstance(channel, discord.DMChannel): + if await self._process_blocked(SimpleNamespace(author=user, + channel=SimpleNamespace(send=_), + add_reaction=_)): + return + try: user_typing = strtobool(self.config["user_typing"]) except ValueError: @@ -714,6 +730,7 @@ async def on_typing(self, channel, user, _): return thread = await self.threads.find(recipient=user) + if thread: await thread.channel.trigger_typing() else: @@ -726,6 +743,10 @@ async def on_typing(self, channel, user, _): thread = await self.threads.find(channel=channel) if thread is not None and thread.recipient: + if await self._process_blocked(SimpleNamespace(author=thread.recipient, + channel=SimpleNamespace(send=_), + add_reaction=_)): + return await thread.recipient.trigger_typing() async def on_raw_reaction_add(self, payload): diff --git a/cogs/modmail.py b/cogs/modmail.py index 923927fc0c..88dd7b2a55 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -85,7 +85,7 @@ async def setup(self, ctx): f"Type `{self.bot.prefix}permissions` for more info." ) - if not self.bot.config["permissions"]: + if not self.bot.config["command_permissions"] and not self.bot.config["level_permissions"]: await self.bot.update_perms(PermissionLevel.REGULAR, -1) await self.bot.update_perms(PermissionLevel.OWNER, ctx.author.id) @@ -1020,6 +1020,7 @@ async def unblock(self, ctx, *, user: User = None): raise commands.MissingRequiredArgument(param(name="user")) mention = getattr(user, "mention", f"`{user.id}`") + name = getattr(user, "name", f"`{user.id}`") if str(user.id) in self.bot.blocked_users: msg = self.bot.blocked_users.pop(str(user.id)) or '' @@ -1037,8 +1038,8 @@ async def unblock(self, ctx, *, user: User = None): ) embed.set_footer( text="However, if the original system block reason still apply, " - f"{mention} will be automatically blocked again. Use " - f'"{self.bot.prefix}blocked whitelist {mention}" to whitelist the user.' + f"{name} will be automatically blocked again. Use " + f'"{self.bot.prefix}blocked whitelist {user.id}" to whitelist the user.' ) else: embed = discord.Embed( diff --git a/cogs/utility.py b/cogs/utility.py index c72ec2a5a5..49d5362cc3 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -767,9 +767,9 @@ async def config_get(self, ctx, key: str.lower = None): embed = Embed( color=self.bot.main_color, description="Here is a list of currently " - "set configuration variables.", + "set configuration variable(s).", ) - embed.set_author(name="Current config", icon_url=self.bot.user.avatar_url) + embed.set_author(name="Current config(s):", icon_url=self.bot.user.avatar_url) config = self.bot.config.filter_default(self.bot.config) for name, value in config.items(): @@ -809,7 +809,7 @@ async def alias(self, ctx): color=self.bot.main_color, description="You dont have any aliases at the moment.", ) - embed.set_author(name="Command aliases", icon_url=ctx.guild.icon_url) + embed.set_author(name="Command aliases:", icon_url=ctx.guild.icon_url) embed.set_footer(text=f"Do {self.bot.prefix}help alias for more commands.") embeds.append(embed) diff --git a/core/config.py b/core/config.py index 147ab9f89c..826dae8371 100644 --- a/core/config.py +++ b/core/config.py @@ -14,6 +14,7 @@ from core._color_data import ALL_COLORS from core.models import InvalidConfigError, Default from core.time import UserFriendlyTime +from core.utils import strtobool logger = logging.getLogger("Modmail") load_dotenv() @@ -100,6 +101,8 @@ class ConfigManager: time_deltas = {"account_age", "guild_age", "thread_auto_close"} + booleans = {"user_typing", "mod_typing", "reply_without_command", "recipient_thread_close"} + defaults = {**public_keys, **private_keys, **protected_keys} all_keys = set(defaults.keys()) @@ -174,6 +177,12 @@ async def clean_data(self, key: str, val: typing.Any) -> typing.Tuple[str, str]: clean_value = isodate.duration_isoformat(time.dt - converter.now) value_text = f"{val} ({clean_value})" + elif key in self.booleans: + try: + clean_value = value_text = strtobool(val) + except ValueError: + raise InvalidConfigError("Must be a yes/no value.") + return clean_value, value_text async def update(self): @@ -182,8 +191,10 @@ async def update(self): async def refresh(self) -> dict: """Refreshes internal cache with data from database""" - data = {k.lower(): v for k, v in (await self.api.get_config()).items() if k.lower() in self.all_keys} - self._cache.update(data) + for k, v in (await self.api.get_config()).items(): + k = k.lower() + if k in self.all_keys: + self._cache[k] = v if not self.ready_event.is_set(): self.ready_event.set() logger.info('Config ready.') From 12c414745ce17e1bf6d4c19789c8661ba61f8dff Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Wed, 10 Jul 2019 20:06:46 -0700 Subject: [PATCH 10/50] Use black format --- bot.py | 131 ++++++++++++++++++++++++++++-------------------- cogs/modmail.py | 21 +++++--- cogs/plugins.py | 23 +++++---- cogs/utility.py | 14 +++--- core/checks.py | 11 ++-- core/clients.py | 4 +- core/config.py | 76 +++++++++++++++++----------- core/models.py | 9 +++- core/thread.py | 39 ++++++++------ core/time.py | 2 +- 10 files changed, 202 insertions(+), 128 deletions(-) diff --git a/bot.py b/bot.py index 77f5be4161..bbe810cd44 100644 --- a/bot.py +++ b/bot.py @@ -67,9 +67,9 @@ def __init__(self): self._configure_logging() - mongo_uri = self.config['mongo_uri'] + mongo_uri = self.config["mongo_uri"] if mongo_uri is None: - raise ValueError('A Mongo URI is necessary for the bot to function.') + raise ValueError("A Mongo URI is necessary for the bot to function.") self.db = AsyncIOMotorClient(mongo_uri).modmail_bot self.plugin_db = PluginDatabaseClient(self) @@ -92,7 +92,7 @@ def uptime(self) -> str: return fmt.format(d=days, h=hours, m=minutes, s=seconds) def _configure_logging(self): - level_text = self.config['log_level'].upper() + level_text = self.config["log_level"].upper() logging_levels = { "CRITICAL": logging.CRITICAL, "ERROR": logging.ERROR, @@ -116,7 +116,7 @@ def _configure_logging(self): log_level = logging_levels.get(level_text) if log_level is None: - log_level = self.config.remove('log_level') + log_level = self.config.remove("log_level") logger.line() if log_level is not None: @@ -186,7 +186,9 @@ def run(self, *args, **kwargs): for task in asyncio.all_tasks(self.loop): task.cancel() try: - self.loop.run_until_complete(asyncio.gather(*asyncio.all_tasks(self.loop))) + self.loop.run_until_complete( + asyncio.gather(*asyncio.all_tasks(self.loop)) + ) except asyncio.CancelledError: logger.debug("All pending tasks has been cancelled.") finally: @@ -194,9 +196,9 @@ def run(self, *args, **kwargs): logger.error(" - Shutting down bot - ") async def is_owner(self, user: discord.User) -> bool: - owners = self.config['owners'] + owners = self.config["owners"] if owners is not None: - if user.id in set(map(int, str(owners).split(','))): + if user.id in set(map(int, str(owners).split(","))): return True return await super().is_owner(user) @@ -207,17 +209,21 @@ def log_channel(self) -> typing.Optional[discord.TextChannel]: channel = self.get_channel(int(channel_id)) if channel is not None: return channel - self.config.remove('log_channel_id') + self.config.remove("log_channel_id") if self.main_category is not None: try: channel = self.main_category.channels[0] - self.config['log_channel_id'] = channel.id - logger.debug('No log channel set, however, one was found. Setting...') + self.config["log_channel_id"] = channel.id + logger.debug("No log channel set, however, one was found. Setting...") return channel except IndexError: pass - logger.info('No log channel set, set one with `%ssetup` or ' - '`%sconfig set log_channel_id `.', self.prefix, self.prefix) + logger.info( + "No log channel set, set one with `%ssetup` or " + "`%sconfig set log_channel_id `.", + self.prefix, + self.prefix, + ) @property def is_connected(self) -> bool: @@ -238,19 +244,19 @@ def aliases(self) -> typing.Dict[str, str]: @property def token(self) -> str: - token = self.config['token'] + token = self.config["token"] if token is None: - raise ValueError('TOKEN must be set, this is your bot token.') + raise ValueError("TOKEN must be set, this is your bot token.") return token @property def guild_id(self) -> typing.Optional[int]: - guild_id = self.config['guild_id'] + guild_id = self.config["guild_id"] if guild_id is not None: try: return int(str(guild_id)) except ValueError: - raise ValueError('Invalid guild_id set.') + raise ValueError("Invalid guild_id set.") @property def guild(self) -> typing.Optional[discord.Guild]: @@ -272,8 +278,8 @@ def modmail_guild(self) -> typing.Optional[discord.Guild]: guild = discord.utils.get(self.guilds, id=int(modmail_guild_id)) if guild is not None: return guild - self.config.remove('modmail_guild_id') - logger.error('Invalid modmail_guild_id set.') + self.config.remove("modmail_guild_id") + logger.error("Invalid modmail_guild_id set.") return self.guild @property @@ -285,14 +291,16 @@ def main_category(self) -> typing.Optional[discord.CategoryChannel]: if self.modmail_guild is not None: category_id = self.config["main_category_id"] if category_id is not None: - cat = discord.utils.get(self.modmail_guild.categories, id=int(category_id)) + cat = discord.utils.get( + self.modmail_guild.categories, id=int(category_id) + ) if cat is not None: return cat self.config.remove("main_category_id") cat = discord.utils.get(self.modmail_guild.categories, name="Modmail") if cat is not None: - self.config['main_category_id'] = cat.id - logger.debug('No main category set, however, one was found. Setting...') + self.config["main_category_id"] = cat.id + logger.debug("No main category set, however, one was found. Setting...") return cat @property @@ -314,7 +322,7 @@ def mod_color(self) -> int: return int(color.lstrip("#"), base=16) except ValueError: logger.error("Invalid mod_color provided.") - return int(self.config.remove('mod_color').lstrip("#"), base=16) + return int(self.config.remove("mod_color").lstrip("#"), base=16) @property def recipient_color(self) -> int: @@ -323,7 +331,7 @@ def recipient_color(self) -> int: return int(color.lstrip("#"), base=16) except ValueError: logger.error("Invalid recipient_color provided.") - return int(self.config.remove('recipient_color').lstrip("#"), base=16) + return int(self.config.remove("recipient_color").lstrip("#"), base=16) @property def main_color(self) -> int: @@ -332,7 +340,7 @@ def main_color(self) -> int: return int(color.lstrip("#"), base=16) except ValueError: logger.error("Invalid main_color provided.") - return int(self.config.remove('main_color').lstrip("#"), base=16) + return int(self.config.remove("main_color").lstrip("#"), base=16) async def on_connect(self): logger.line() @@ -383,14 +391,14 @@ async def on_ready(self): logger.info("Logged in as: %s", str(self.user)) logger.info("User ID: %s", str(self.user.id)) logger.info("Prefix: %s", str(self.prefix)) - logger.info("Guild Name: %s", self.guild.name if self.guild else 'Invalid') - logger.info("Guild ID: %s", self.guild.id if self.guild else 'Invalid') + logger.info("Guild Name: %s", self.guild.name if self.guild else "Invalid") + logger.info("Guild ID: %s", self.guild.id if self.guild else "Invalid") logger.line() await self.threads.populate_cache() # closures - closures = self.config['closures'] + closures = self.config["closures"] logger.info("There are %d thread(s) pending to be closed.", len(closures)) for recipient_id, items in tuple(closures.items()): @@ -404,7 +412,7 @@ async def on_ready(self): if not thread: # If the channel is deleted - self.config['closures'].pop(recipient_id) + self.config["closures"].pop(recipient_id) await self.config.update() continue @@ -503,7 +511,7 @@ async def _process_blocked(self, message: discord.Message) -> bool: ) guild_age = self.config.remove("guild_age") - reason = self.blocked_users.get(str(message.author.id)) or '' + reason = self.blocked_users.get(str(message.author.id)) or "" min_guild_age = min_account_age = now try: @@ -513,7 +521,7 @@ async def _process_blocked(self, message: discord.Message) -> bool: self.config.remove("account_age") try: - joined_at = getattr(message.author, 'joined_at', None) + joined_at = getattr(message.author, "joined_at", None) if joined_at is not None: min_guild_age = joined_at + guild_age except ValueError: @@ -646,10 +654,10 @@ async def update_perms( self, name: typing.Union[PermissionLevel, str], value: int, add: bool = True ) -> None: if isinstance(name, PermissionLevel): - permissions = self.config['level_permissions'] + permissions = self.config["level_permissions"] name = name.name else: - permissions = self.config['command_permissions'] + permissions = self.config["command_permissions"] if name not in permissions: if add: permissions[name] = [value] @@ -678,7 +686,7 @@ async def on_message(self, message): prefix = self.prefix if message.content.startswith(prefix): - cmd = message.content[len(prefix):].strip() + cmd = message.content[len(prefix) :].strip() if cmd in self.snippets: thread = await self.threads.find(channel=message.channel) snippet = self.snippets[cmd] @@ -695,7 +703,7 @@ async def on_message(self, message): try: reply_without_command = strtobool(self.config["reply_without_command"]) except ValueError: - reply_without_command = self.config.remove('reply_without_command') + reply_without_command = self.config.remove("reply_without_command") if reply_without_command: await thread.reply(message) @@ -713,19 +721,21 @@ async def on_typing(self, channel, user, _): if user.bot: return - async def _(*args, **kwargs): + async def _void(*_args, **_kwargs): pass if isinstance(channel, discord.DMChannel): - if await self._process_blocked(SimpleNamespace(author=user, - channel=SimpleNamespace(send=_), - add_reaction=_)): + if await self._process_blocked( + SimpleNamespace( + author=user, channel=SimpleNamespace(send=_void), add_reaction=_void + ) + ): return try: user_typing = strtobool(self.config["user_typing"]) except ValueError: - user_typing = self.config.remove('user_typing') + user_typing = self.config.remove("user_typing") if not user_typing: return @@ -737,15 +747,19 @@ async def _(*args, **kwargs): try: mod_typing = strtobool(self.config["mod_typing"]) except ValueError: - mod_typing = self.config.remove('mod_typing') + mod_typing = self.config.remove("mod_typing") if not mod_typing: return thread = await self.threads.find(channel=channel) if thread is not None and thread.recipient: - if await self._process_blocked(SimpleNamespace(author=thread.recipient, - channel=SimpleNamespace(send=_), - add_reaction=_)): + if await self._process_blocked( + SimpleNamespace( + author=thread.recipient, + channel=SimpleNamespace(send=_void), + add_reaction=_void, + ) + ): return await thread.recipient.trigger_typing() @@ -777,9 +791,13 @@ async def on_raw_reaction_add(self, payload): if thread and ts == thread.channel.created_at: # the reacted message is the corresponding thread creation embed try: - recipient_thread_close = strtobool(self.config["recipient_thread_close"]) + recipient_thread_close = strtobool( + self.config["recipient_thread_close"] + ) except ValueError: - recipient_thread_close = self.config.remove('recipient_thread_close') + recipient_thread_close = self.config.remove( + "recipient_thread_close" + ) if recipient_thread_close: await thread.close(closer=user) else: @@ -808,7 +826,7 @@ async def on_guild_channel_delete(self, channel): if isinstance(channel, discord.CategoryChannel): if self.main_category.id == channel.id: - self.config.remove('main_category_id') + self.config.remove("main_category_id") await self.config.update() return @@ -816,7 +834,7 @@ async def on_guild_channel_delete(self, channel): return if self.log_channel is None or self.log_channel.id == channel.id: - self.config.remove('log_channel_id') + self.config.remove("log_channel_id") await self.config.update() return @@ -935,14 +953,20 @@ async def validate_database_connection(self): logger.critical(message) if "ServerSelectionTimeoutError" in message: - logger.critical("This may have been caused by not whitelisting " - "IPs correctly. Make sure to whitelist all " - "IPs (0.0.0.0/0) https://i.imgur.com/mILuQ5U.png") + logger.critical( + "This may have been caused by not whitelisting " + "IPs correctly. Make sure to whitelist all " + "IPs (0.0.0.0/0) https://i.imgur.com/mILuQ5U.png" + ) if "OperationFailure" in message: - logger.critical("This is due to having invalid credentials in your MONGO_URI.") - logger.critical("Recheck the username/password and make sure to url encode them. " - "https://www.urlencoder.io/") + logger.critical( + "This is due to having invalid credentials in your MONGO_URI." + ) + logger.critical( + "Recheck the username/password and make sure to url encode them. " + "https://www.urlencoder.io/" + ) raise else: logger.info("Successfully connected to the database.") @@ -980,6 +1004,7 @@ async def metadata_loop(self): if __name__ == "__main__": if os.name != "nt": import uvloop + uvloop.install() bot = ModmailBot() bot.run() diff --git a/cogs/modmail.py b/cogs/modmail.py index 88dd7b2a55..9e4228d4c5 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -38,9 +38,11 @@ async def setup(self, ctx): return await ctx.send(f"{self.bot.modmail_guild} is already set up.") if self.bot.modmail_guild is None: - embed = discord.Embed(title='Error', - description='Modmail functioning guild not found.', - color=discord.Color.red()) + embed = discord.Embed( + title="Error", + description="Modmail functioning guild not found.", + color=discord.Color.red(), + ) return await ctx.send(embed=embed) category = await self.bot.modmail_guild.create_category( @@ -85,11 +87,14 @@ async def setup(self, ctx): f"Type `{self.bot.prefix}permissions` for more info." ) - if not self.bot.config["command_permissions"] and not self.bot.config["level_permissions"]: + if ( + not self.bot.config["command_permissions"] + and not self.bot.config["level_permissions"] + ): await self.bot.update_perms(PermissionLevel.REGULAR, -1) await self.bot.update_perms(PermissionLevel.OWNER, ctx.author.id) - @commands.group(aliases=['snippets'], invoke_without_command=True) + @commands.group(aliases=["snippets"], invoke_without_command=True) @checks.has_permissions(PermissionLevel.SUPPORTER) async def snippet(self, ctx): """ @@ -534,7 +539,7 @@ def format_log_embeds(self, logs, avatar_url): if prefix == "NONE": prefix = "" - log_url = self.bot.config['log_url'].strip("/") + f"{prefix}/{key}" + log_url = self.bot.config["log_url"].strip("/") + f"{prefix}/{key}" username = entry["recipient"]["name"] + "#" username += entry["recipient"]["discriminator"] @@ -967,7 +972,7 @@ async def block( extend = f" for `{reason}`" if reason is not None else "" msg = self.bot.blocked_users.get(str(user.id)) if msg is None: - msg = '' + msg = "" if ( str(user.id) not in self.bot.blocked_users @@ -1023,7 +1028,7 @@ async def unblock(self, ctx, *, user: User = None): name = getattr(user, "name", f"`{user.id}`") if str(user.id) in self.bot.blocked_users: - msg = self.bot.blocked_users.pop(str(user.id)) or '' + msg = self.bot.blocked_users.pop(str(user.id)) or "" await self.bot.config.update() if msg.startswith("System Message: "): diff --git a/cogs/plugins.py b/cogs/plugins.py index c9ecf11d06..ddef4c9e77 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -57,7 +57,7 @@ def parse_plugin(name): # default branch = master try: # when names are formatted with inline code - result = name.strip('`').split("/") + result = name.strip("`").split("/") result[2] = "/".join(result[2:]) if "@" in result[2]: # branch is specified @@ -76,7 +76,7 @@ def parse_plugin(name): async def download_initial_plugins(self): await self.bot.wait_for_connected() - for i in self.bot.config['plugins']: + for i in self.bot.config["plugins"]: username, repo, name, branch = self.parse_plugin(i) try: @@ -186,7 +186,7 @@ async def plugin_add(self, ctx, *, plugin_name: str): ) return await ctx.send(embed=embed) - if plugin_name in self.bot.config['plugins']: + if plugin_name in self.bot.config["plugins"]: embed = discord.Embed( description="This plugin is already installed.", color=self.bot.main_color, @@ -233,7 +233,7 @@ async def plugin_add(self, ctx, *, plugin_name: str): # if it makes it here, it has passed all checks and should # be entered into the config - self.bot.config['plugins'].append(plugin_name) + self.bot.config["plugins"].append(plugin_name) await self.bot.config.update() embed = discord.Embed( @@ -260,7 +260,7 @@ async def plugin_remove(self, ctx, *, plugin_name: str): details["repository"] + "/" + plugin_name + "@" + details["branch"] ) - if plugin_name in self.bot.config['plugins']: + if plugin_name in self.bot.config["plugins"]: try: username, repo, name, branch = self.parse_plugin(plugin_name) @@ -270,12 +270,13 @@ async def plugin_remove(self, ctx, *, plugin_name: str): except Exception: pass - self.bot.config['plugins'].remove(plugin_name) + self.bot.config["plugins"].remove(plugin_name) try: # BUG: Local variables 'username', 'branch' and 'repo' might be referenced before assignment if not any( - i.startswith(f"{username}/{repo}") for i in self.bot.config['plugins'] + i.startswith(f"{username}/{repo}") + for i in self.bot.config["plugins"] ): # if there are no more of such repos, delete the folder def onerror(func, path, exc_info): # pylint: disable=W0613 @@ -289,7 +290,7 @@ def onerror(func, path, exc_info): # pylint: disable=W0613 ) except Exception as exc: logger.error(str(exc)) - self.bot.config['plugins'].append(plugin_name) + self.bot.config["plugins"].append(plugin_name) logger.error(exc) raise exc @@ -317,7 +318,7 @@ async def plugin_update(self, ctx, *, plugin_name: str): details["repository"] + "/" + plugin_name + "@" + details["branch"] ) - if plugin_name not in self.bot.config['plugins']: + if plugin_name not in self.bot.config["plugins"]: embed = discord.Embed( description="That plugin is not installed.", color=self.bot.main_color ) @@ -367,8 +368,8 @@ async def plugin_update(self, ctx, *, plugin_name: str): async def plugin_enabled(self, ctx): """Shows a list of currently enabled plugins.""" - if self.bot.config['plugins']: - msg = "```\n" + "\n".join(self.bot.config['plugins']) + "\n```" + if self.bot.config["plugins"]: + msg = "```\n" + "\n".join(self.bot.config["plugins"]) + "\n```" embed = discord.Embed(description=msg, color=self.bot.main_color) await ctx.send(embed=embed) else: diff --git a/cogs/utility.py b/cogs/utility.py index 49d5362cc3..79fc0c213b 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -371,7 +371,7 @@ async def debug_hastebin(self, ctx): try: key = data["key"] except KeyError: - logger.error(data['message']) + logger.error(data["message"]) raise embed = Embed( title="Debug Logs", @@ -769,7 +769,9 @@ async def config_get(self, ctx, key: str.lower = None): description="Here is a list of currently " "set configuration variable(s).", ) - embed.set_author(name="Current config(s):", icon_url=self.bot.user.avatar_url) + embed.set_author( + name="Current config(s):", icon_url=self.bot.user.avatar_url + ) config = self.bot.config.filter_default(self.bot.config) for name, value in config.items(): @@ -1132,11 +1134,11 @@ async def permissions_get(self, ctx, *, user_or_role: Union[User, Role, str]): cmds = [] levels = [] for cmd in self.bot.commands: - permissions = self.bot.config['command_permissions'].get(cmd.name, []) + permissions = self.bot.config["command_permissions"].get(cmd.name, []) if value in permissions: cmds.append(cmd.name) for level in PermissionLevel: - permissions = self.bot.config['level_permissions'].get(level.name, []) + permissions = self.bot.config["level_permissions"].get(level.name, []) if value in permissions: levels.append(level.name) mention = user_or_role.name if hasattr(user_or_role, "name") else user_or_role @@ -1172,7 +1174,7 @@ async def permissions_get_command(self, ctx, *, command: str = None): """View currently-set permissions for a command.""" def get_command(cmd): - permissions = self.bot.config['command_permissions'].get(cmd.name, []) + permissions = self.bot.config["command_permissions"].get(cmd.name, []) if not permissions: embed = Embed( title=f"Permission entries for command `{cmd.name}`:", @@ -1230,7 +1232,7 @@ async def permissions_get_level(self, ctx, *, level: str = None): """View currently-set permissions for commands of a permission level.""" def get_level(perm_level): - permissions = self.bot.config['level_permissions'].get(perm_level.name, []) + permissions = self.bot.config["level_permissions"].get(perm_level.name, []) if not permissions: embed = Embed( title="Permission entries for permission " diff --git a/core/checks.py b/core/checks.py index 0c495bf747..b706d2bea3 100644 --- a/core/checks.py +++ b/core/checks.py @@ -32,8 +32,11 @@ async def predicate(ctx): ) if not has_perm and ctx.command.qualified_name != "help": - logger.error("You does not have permission to use this command: `%s` (%s).", - str(ctx.command.qualified_name), str(permission_level.name)) + logger.error( + "You does not have permission to use this command: `%s` (%s).", + str(ctx.command.qualified_name), + str(permission_level.name), + ) return has_perm predicate.permission_level = permission_level @@ -53,7 +56,7 @@ async def check_permissions(ctx, command_name, permission_level) -> bool: # Administrators have permission to all non-owner commands return True - command_permissions = ctx.bot.config['command_permissions'] + command_permissions = ctx.bot.config["command_permissions"] author_roles = ctx.author.roles if command_name in command_permissions: @@ -66,7 +69,7 @@ async def check_permissions(ctx, command_name, permission_level) -> bool: has_perm_id = ctx.author.id in command_permissions[command_name] return has_perm_role or has_perm_id - level_permissions = ctx.bot.config['level_permissions'] + level_permissions = ctx.bot.config["level_permissions"] for level in PermissionLevel: if level >= permission_level and level.name in level_permissions: diff --git a/core/clients.py b/core/clients.py index 2924c12488..88ffbd8743 100644 --- a/core/clients.py +++ b/core/clients.py @@ -143,7 +143,9 @@ async def get_config(self) -> dict: async def update_config(self, data: dict): toset = self.bot.config.filter_valid(data) - unset = self.bot.config.filter_valid({k: 1 for k in self.bot.config.all_keys if k not in data}) + unset = self.bot.config.filter_valid( + {k: 1 for k in self.bot.config.all_keys if k not in data} + ) if toset and unset: return await self.db.config.update_one( diff --git a/core/config.py b/core/config.py index 826dae8371..8d29a3c2b9 100644 --- a/core/config.py +++ b/core/config.py @@ -24,11 +24,11 @@ class ConfigManager: public_keys = { # activity - "twitch_url": 'https://www.twitch.tv/discord-modmail/', + "twitch_url": "https://www.twitch.tv/discord-modmail/", # bot settings "main_category_id": None, - "prefix": '?', - "mention": '@here', + "prefix": "?", + "mention": "@here", "main_color": str(discord.Color.blurple()), "user_typing": False, "mod_typing": False, @@ -38,20 +38,20 @@ class ConfigManager: # logging "log_channel_id": None, # threads - "sent_emoji": '✅', - "blocked_emoji": '🚫', - "close_emoji": '🔒', + "sent_emoji": "✅", + "blocked_emoji": "🚫", + "close_emoji": "🔒", "recipient_thread_close": False, "thread_auto_close": 0, "thread_auto_close_response": "This thread has been closed automatically due to inactivity after {timeout}.", "thread_creation_response": "The staff team will get back to you as soon as possible.", - "thread_creation_footer": 'Your message has been sent', + "thread_creation_footer": "Your message has been sent", "thread_self_closable_creation_footer": "Click the lock to close the thread", - "thread_creation_title": 'Thread Created', - "thread_close_footer": 'Replying will create a new thread', - "thread_close_title": 'Thread Closed', - "thread_close_response": '{closer.mention} has closed this Modmail thread.', - "thread_self_close_response": 'You have closed this Modmail thread.', + "thread_creation_title": "Thread Created", + "thread_close_footer": "Replying will create a new thread", + "thread_close_title": "Thread Closed", + "thread_close_response": "{closer.mention} has closed this Modmail thread.", + "thread_self_close_response": "You have closed this Modmail thread.", # moderation "recipient_color": str(discord.Color.gold()), "mod_tag": None, @@ -59,12 +59,12 @@ class ConfigManager: # anonymous message "anon_username": None, "anon_avatar_url": None, - "anon_tag": 'Response', + "anon_tag": "Response", } private_keys = { # bot presence - "activity_message": '', + "activity_message": "", "activity_type": None, "status": None, "oauth_whitelist": [], @@ -87,8 +87,8 @@ class ConfigManager: # Modmail "modmail_guild_id": None, "guild_id": None, - "log_url": 'https://example.com/', - "log_url_prefix": '/logs', + "log_url": "https://example.com/", + "log_url_prefix": "/logs", "mongo_uri": None, "owners": None, # bot @@ -101,7 +101,12 @@ class ConfigManager: time_deltas = {"account_age", "guild_age", "thread_auto_close"} - booleans = {"user_typing", "mod_typing", "reply_without_command", "recipient_thread_close"} + booleans = { + "user_typing", + "mod_typing", + "reply_without_command", + "recipient_thread_close", + } defaults = {**public_keys, **private_keys, **protected_keys} all_keys = set(defaults.keys()) @@ -122,12 +127,20 @@ def populate_cache(self) -> dict: data = deepcopy(self.defaults) # populate from env var and .env file - data.update({k.lower(): v for k, v in os.environ.items() if k.lower() in self.all_keys}) + data.update( + {k.lower(): v for k, v in os.environ.items() if k.lower() in self.all_keys} + ) if os.path.exists("config.json"): with open("config.json") as f: # Config json should override env vars - data.update({k.lower(): v for k, v in json.load(f).items() if k.lower() in self.all_keys}) + data.update( + { + k.lower(): v + for k, v in json.load(f).items() + if k.lower() in self.all_keys + } + ) self._cache = data return self._cache @@ -145,7 +158,7 @@ async def clean_data(self, key: str, val: typing.Any) -> typing.Tuple[str, str]: if hex_.startswith("#"): hex_ = hex_[1:] if len(hex_) == 3: - hex_ = ''.join(s for s in hex_ for _ in range(2)) + hex_ = "".join(s for s in hex_ for _ in range(2)) if len(hex_) != 6: raise InvalidConfigError("Invalid color name or hex.") try: @@ -197,7 +210,7 @@ async def refresh(self) -> dict: self._cache[k] = v if not self.ready_event.is_set(): self.ready_event.set() - logger.info('Config ready.') + logger.info("Config ready.") return self._cache async def wait_until_ready(self) -> None: @@ -205,7 +218,7 @@ async def wait_until_ready(self) -> None: def __setitem__(self, key: str, item: typing.Any) -> None: key = key.lower() - logger.info('Setting %s.', key) + logger.info("Setting %s.", key) if key not in self.all_keys: raise InvalidConfigError(f'Configuration "{key}" is invalid.') self._cache[key] = item @@ -233,14 +246,14 @@ def get(self, key: str, default: typing.Any = Default) -> typing.Any: def set(self, key: str, item: typing.Any) -> None: key = key.lower() - logger.info('Setting %s.', key) + logger.info("Setting %s.", key) if key not in self.all_keys: raise InvalidConfigError(f'Configuration "{key}" is invalid.') self._cache[key] = item def remove(self, key: str) -> typing.Any: key = key.lower() - logger.info('Removing %s.', key) + logger.info("Removing %s.", key) if key not in self.all_keys: raise InvalidConfigError(f'Configuration "{key}" is invalid.') self._cache[key] = deepcopy(self.defaults[key]) @@ -250,11 +263,18 @@ def items(self) -> typing.Iterable: return self._cache.items() @classmethod - def filter_valid(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: - return {k.lower(): v for k, v in data.items() - if k.lower() in cls.public_keys or k.lower() in cls.private_keys} + def filter_valid( + cls, data: typing.Dict[str, typing.Any] + ) -> typing.Dict[str, typing.Any]: + return { + k.lower(): v + for k, v in data.items() + if k.lower() in cls.public_keys or k.lower() in cls.private_keys + } @classmethod - def filter_default(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + def filter_default( + cls, data: typing.Dict[str, typing.Any] + ) -> typing.Dict[str, typing.Any]: # TODO: use .get to prevent errors return {k.lower(): v for k, v in data.items() if v != cls.defaults[k.lower()]} diff --git a/core/models.py b/core/models.py index e27904b07c..4ba3964231 100644 --- a/core/models.py +++ b/core/models.py @@ -70,7 +70,14 @@ def exception(self, msg, *args, exc_info=True, **kwargs): def line(self): if self.isEnabledFor(logging.INFO): - self._log(logging.INFO, Fore.BLACK + Style.BRIGHT + "-------------------------" + Style.RESET_ALL, []) + self._log( + logging.INFO, + Fore.BLACK + + Style.BRIGHT + + "-------------------------" + + Style.RESET_ALL, + [], + ) class _Default: diff --git a/core/thread.py b/core/thread.py index 42f3a8faab..03aa06302a 100644 --- a/core/thread.py +++ b/core/thread.py @@ -158,9 +158,11 @@ async def send_genesis_message(): ) try: - recipient_thread_close = strtobool(self.bot.config["recipient_thread_close"]) + recipient_thread_close = strtobool( + self.bot.config["recipient_thread_close"] + ) except ValueError: - recipient_thread_close = self.bot.config.remove('recipient_thread_close') + recipient_thread_close = self.bot.config.remove("recipient_thread_close") if recipient_thread_close: footer = self.bot.config["thread_self_closable_creation_footer"] @@ -212,7 +214,7 @@ async def close( "message": message, "auto_close": auto_close, } - self.bot.config['closures'][str(self.id)] = items + self.bot.config["closures"][str(self.id)] = items await self.bot.config.update() task = self.bot.loop.call_later( @@ -235,8 +237,8 @@ async def _close( # Cancel auto closing the thread if closed by any means. - self.bot.config['subscriptions'].pop(str(self.id), None) - self.bot.config['notification_squad'].pop(str(self.id), None) + self.bot.config["subscriptions"].pop(str(self.id), None) + self.bot.config["notification_squad"].pop(str(self.id), None) # Logging log_data = await self.bot.api.post_log( @@ -256,10 +258,12 @@ async def _close( ) if log_data is not None and isinstance(log_data, dict): - prefix = self.bot.config['log_url_prefix'] + prefix = self.bot.config["log_url_prefix"] if prefix == "NONE": prefix = "" - log_url = f"{self.bot.config['log_url'].strip('/')}{prefix}/{log_data['key']}" + log_url = ( + f"{self.bot.config['log_url'].strip('/')}{prefix}/{log_data['key']}" + ) if log_data["messages"]: content = str(log_data["messages"][0]["content"]) @@ -333,7 +337,7 @@ async def cancel_closure(self, auto_close: bool = False, all: bool = False) -> N self.auto_close_task.cancel() self.auto_close_task = None - to_update = self.bot.config['closures'].pop(str(self.id), None) + to_update = self.bot.config["closures"].pop(str(self.id), None) if to_update is not None: await self.bot.config.update() @@ -392,7 +396,9 @@ async def _restart_close_timer(self): human_time = human_timedelta(dt=reset_time) # Grab message - close_message = self.bot.config["thread_auto_close_response"].format(timeout=human_time) + close_message = self.bot.config["thread_auto_close_response"].format( + timeout=human_time + ) time_marker_regex = "%t" if len(re.findall(time_marker_regex, close_message)) == 1: @@ -400,11 +406,14 @@ async def _restart_close_timer(self): elif len(re.findall(time_marker_regex, close_message)) > 1: logger.warning( "The thread_auto_close_response should only contain one '%s' to specify time.", - time_marker_regex + time_marker_regex, ) await self.close( - closer=self.bot.user, after=int(seconds), message=close_message, auto_close=True + closer=self.bot.user, + after=int(seconds), + message=close_message, + auto_close=True, ) async def edit_message(self, message_id: int, message: str) -> None: @@ -557,13 +566,13 @@ async def send( and not isinstance(destination, discord.TextChannel) ): # Anonymously sending to the user. - tag = self.bot.config['mod_tag'] + tag = self.bot.config["mod_tag"] if tag is None: tag = str(message.author.top_role) - name = self.bot.config['anon_username'] + name = self.bot.config["anon_username"] if name is None: name = tag - avatar_url = self.bot.config['anon_avatar_url'] + avatar_url = self.bot.config["anon_avatar_url"] if avatar_url is None: avatar_url = self.bot.guild.icon_url else: @@ -648,7 +657,7 @@ async def send( embed.set_footer(text="Anonymous Reply") # Normal messages elif not anonymous: - mod_tag = self.bot.config['mod_tag'] + mod_tag = self.bot.config["mod_tag"] if mod_tag is None: mod_tag = str(message.author.top_role) embed.set_footer(text=mod_tag) # Normal messages diff --git a/core/time.py b/core/time.py index b4689cc518..da9bdafcb4 100644 --- a/core/time.py +++ b/core/time.py @@ -186,7 +186,7 @@ async def convert(self, ctx, argument): if not (end < len(argument) and argument[end] == '"'): raise BadArgument("If the time is quoted, you must unquote it.") - remaining = argument[end + 1:].lstrip(" ,.!") + remaining = argument[end + 1 :].lstrip(" ,.!") else: remaining = argument[end:].lstrip(" ,.!") elif len(argument) == end: From 24f9a15dbc7383ccd9cf8b2e09197739349798c4 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Thu, 11 Jul 2019 20:36:41 -0700 Subject: [PATCH 11/50] Formatting --- .pylintrc | 3 ++- bot.py | 26 +++++++++++++------------- cogs/plugins.py | 7 +++---- cogs/utility.py | 23 +++++++++++++++++------ core/thread.py | 13 ++++++------- 5 files changed, 41 insertions(+), 31 deletions(-) diff --git a/.pylintrc b/.pylintrc index 60d8d81041..55e09c1a93 100644 --- a/.pylintrc +++ b/.pylintrc @@ -82,7 +82,8 @@ disable=raw-checker-failed, too-many-lines, line-too-long, bad-continuation, - invalid-name + invalid-name, + logging-too-many-args # Enable the message, report, category or checker with the given id(s). You can diff --git a/bot.py b/bot.py index bbe810cd44..2a7611a332 100644 --- a/bot.py +++ b/bot.py @@ -122,10 +122,10 @@ def _configure_logging(self): if log_level is not None: logger.setLevel(log_level) ch.setLevel(log_level) - logger.info("Logging level: " + level_text) + logger.info("Logging level: %s", level_text) else: - logger.info("Invalid logging level set. ") - logger.warning("Using default logging level: INFO") + logger.info("Invalid logging level set.") + logger.warning("Using default logging level: INFO.") @property def version(self) -> str: @@ -152,7 +152,7 @@ def _load_extensions(self): logger.info("┌┬┐┌─┐┌┬┐┌┬┐┌─┐┬┬") logger.info("││││ │ │││││├─┤││") logger.info("┴ ┴└─┘─┴┘┴ ┴┴ ┴┴┴─┘") - logger.info(f"v{__version__}") + logger.info("v%s", __version__) logger.info("Authors: kyb3r, fourjr, Taaku18") logger.line() @@ -160,11 +160,11 @@ def _load_extensions(self): if not file.endswith(".py"): continue cog = f"cogs.{file[:-3]}" - logger.info(f"Loading {cog}") + logger.info("Loading %s.", cog) try: self.load_extension(cog) except Exception: - logger.exception(f"Failed to load {cog}") + logger.exception("Failed to load %s.", cog) def run(self, *args, **kwargs): try: @@ -388,9 +388,9 @@ async def on_ready(self): logger.line() logger.info("Client ready.") logger.line() - logger.info("Logged in as: %s", str(self.user)) - logger.info("User ID: %s", str(self.user.id)) - logger.info("Prefix: %s", str(self.prefix)) + logger.info("Logged in as: %s", self.user) + logger.info("User ID: %s", self.user.id) + logger.info("Prefix: %s", self.prefix) logger.info("Guild Name: %s", self.guild.name if self.guild else "Invalid") logger.info("Guild ID: %s", self.guild.id if self.guild else "Invalid") logger.line() @@ -668,7 +668,7 @@ async def update_perms( else: if value in permissions[name]: permissions[name].remove(value) - logger.info(f"Updating permissions for {name}, {value} (add={add}).") + logger.info("Updating permissions for %s, %s (add=%s).", name, value, add) await self.config.update() async def on_message(self, message): @@ -895,7 +895,7 @@ async def on_message_edit(self, before, after): break async def on_error(self, event_method, *args, **kwargs): - logger.error("Ignoring exception in %s", str(event_method)) + logger.error("Ignoring exception in %s.", event_method) logger.error("Unexpected exception:", exc_info=sys.exc_info()) async def on_command_error(self, context, exception): @@ -916,7 +916,7 @@ async def on_command_error(self, context, exception): ) ) elif isinstance(exception, commands.CommandNotFound): - logger.warning("CommandNotFound: %s", str(exception)) + logger.warning("CommandNotFound: %s", exception) elif isinstance(exception, commands.MissingRequiredArgument): await context.send_help(context.command) elif isinstance(exception, commands.CheckFailure): @@ -927,7 +927,7 @@ async def on_command_error(self, context, exception): color=discord.Color.red(), description=check.fail_msg ) ) - logger.warning("CheckFailure: %s", str(exception)) + logger.warning("CheckFailure: %s", exception) else: logger.error("Unexpected exception:", exc_info=exception) diff --git a/cogs/plugins.py b/cogs/plugins.py index ddef4c9e77..b59e645616 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -397,10 +397,9 @@ def find_index(find_name): if find_name == n: return i - index = 0 - if plugin_name in self.registry: - index = find_index(plugin_name) - elif plugin_name is not None: + index = next((i for i, (n, _) in enumerate(registry) if plugin_name == n), None) + + if index is None: embed = discord.Embed( color=discord.Color.red(), description=f'Could not find a plugin with name "{plugin_name}" within the registry.', diff --git a/cogs/utility.py b/cogs/utility.py index 79fc0c213b..e05fd8600f 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -78,11 +78,11 @@ async def format_cog_help(self, cog): def process_help_msg(self, help_: str): return help_.format(prefix=self.clean_prefix) if help_ else "No help message." - async def send_bot_help(self, cogs): + async def send_bot_help(self, mapping): embeds = [] # TODO: Implement for no cog commands - cogs = list(filter(None, cogs)) + cogs = list(filter(None, mapping)) bot = self.context.bot @@ -230,7 +230,7 @@ async def changelog(self, ctx): try: paginator = PaginatorSession(ctx, *changelog.embeds) await paginator.run() - except: + except Exception: await ctx.send(changelog.CHANGELOG_URL) @commands.command(aliases=["bot", "info"]) @@ -239,7 +239,11 @@ async def changelog(self, ctx): async def about(self, ctx): """Shows information about this bot.""" embed = Embed(color=self.bot.main_color, timestamp=datetime.utcnow()) - embed.set_author(name="Modmail - About", icon_url=self.bot.user.avatar_url) + embed.set_author( + name="Modmail - About", + icon_url=self.bot.user.avatar_url, + url="https://discord.gg/F34cRU8", + ) embed.set_thumbnail(url=self.bot.user.avatar_url) desc = "This is an open source Discord bot that serves as a means for " @@ -264,7 +268,14 @@ async def about(self, ctx): name="GitHub", value="https://github.com/kyb3r/modmail", inline=False ) - embed.add_field(name="Donate", value="[Patreon](https://patreon.com/kyber)") + embed.add_field( + name="Discord Server", value="https://discord.gg/F34cRU8", inline=False + ) + + embed.add_field( + name="Donate", + value="Support this bot on [`Patreon`](https://patreon.com/kyber).", + ) embed.set_footer(text=footer) await ctx.send(embed=embed) @@ -1289,7 +1300,7 @@ def get_level(perm_level): @checks.has_permissions(PermissionLevel.OWNER) async def oauth(self, ctx): """Commands relating to Logviewer oauth2 login authentication. - + This functionality on your logviewer site is a [**Patron**](https://patreon.com/kyber) only feature. """ await ctx.send_help(ctx.command) diff --git a/core/thread.py b/core/thread.py index 03aa06302a..a05b67fb42 100644 --- a/core/thread.py +++ b/core/thread.py @@ -6,8 +6,9 @@ from datetime import datetime, timedelta from types import SimpleNamespace as param -import discord import isodate + +import discord from discord.ext.commands import MissingRequiredArgument, CommandError from core.time import human_timedelta @@ -98,14 +99,14 @@ async def setup(self, *, creator=None, category=None): name=self.manager.format_channel_name(recipient), category=category, overwrites=overwrites, - reason="Creating a thread channel", + reason="Creating a thread channel.", ) except discord.HTTPException as e: # Failed to create due to 50 channel limit. self.manager.cache.pop(self.id) embed = discord.Embed(color=discord.Color.red()) - embed.title = "Error while trying to create a thread" - embed.description = e.message + embed.title = "Error while trying to create a thread." + embed.description = str(e) embed.add_field(name="Recipient", value=recipient.mention) if self.bot.log_channel is not None: @@ -361,9 +362,7 @@ async def _fetch_timeout( :returns: None if no timeout is set. """ timeout = self.bot.config["thread_auto_close"] - if not timeout: - return timeout - else: + if timeout: try: timeout = isodate.parse_duration(timeout) except isodate.ISO8601Error: From e68b99704209070a32c8b413840416a8310b9498 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Thu, 11 Jul 2019 21:16:35 -0700 Subject: [PATCH 12/50] Lint + black --- .pylintrc | 2 +- bot.py | 9 +++++--- cogs/plugins.py | 7 +------ cogs/utility.py | 21 +++++++++++-------- core/checks.py | 7 ++++--- core/thread.py | 56 ++++++++++++++++++++++++++----------------------- core/utils.py | 6 +++--- 7 files changed, 57 insertions(+), 51 deletions(-) diff --git a/.pylintrc b/.pylintrc index 55e09c1a93..1bed8185c7 100644 --- a/.pylintrc +++ b/.pylintrc @@ -508,4 +508,4 @@ min-public-methods=2 # Exceptions that will emit a warning when being caught. Defaults to # "Exception". -overgeneral-exceptions=Exception +overgeneral-exceptions=BaseException diff --git a/bot.py b/bot.py index 2a7611a332..32960b4336 100644 --- a/bot.py +++ b/bot.py @@ -224,6 +224,7 @@ def log_channel(self) -> typing.Optional[discord.TextChannel]: self.prefix, self.prefix, ) + return None @property def is_connected(self) -> bool: @@ -257,6 +258,7 @@ def guild_id(self) -> typing.Optional[int]: return int(str(guild_id)) except ValueError: raise ValueError("Invalid guild_id set.") + return None @property def guild(self) -> typing.Optional[discord.Guild]: @@ -302,6 +304,7 @@ def main_category(self) -> typing.Optional[discord.CategoryChannel]: self.config["main_category_id"] = cat.id logger.debug("No main category set, however, one was found. Setting...") return cat + return None @property def blocked_users(self) -> typing.Dict[str, str]: @@ -365,12 +368,12 @@ async def setup_indexes(self): # Backwards compatibility old_index = "messages.content_text_messages.author.name_text" if old_index in index_info: - logger.info(f"Dropping old index: {old_index}") + logger.info("Dropping old index: %s", old_index) await coll.drop_index(old_index) if index_name not in index_info: logger.info('Creating "text" index for logs collection.') - logger.info("Name: " + index_name) + logger.info("Name: %s", index_name) await coll.create_index( [ ("messages.content", "text"), @@ -638,7 +641,7 @@ async def get_context(self, message, *, cls=commands.Context): # Check if there is any aliases being called. alias = self.aliases.get(invoker) if alias is not None: - ctx._alias_invoked = True + ctx._alias_invoked = True # pylint: disable=W0212 len_ = len(f"{invoked_prefix}{invoker}") view = StringView(f"{alias}{ctx.message.content[len_:]}") ctx.view = view diff --git a/cogs/plugins.py b/cogs/plugins.py index b59e645616..7f2dcec871 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -336,7 +336,7 @@ async def plugin_update(self, ctx, *, plugin_name: str): err = exc.stderr.decode("utf8").strip() embed = discord.Embed( - description=f"An error occured while updating: {err}.", + description=f"An error occurred while updating: {err}.", color=self.bot.main_color, ) await ctx.send(embed=embed) @@ -392,11 +392,6 @@ async def plugin_registry(self, ctx, *, plugin_name: str = None): registry = list(self.registry.items()) random.shuffle(registry) - def find_index(find_name): - for i, (n, _) in enumerate(registry): - if find_name == n: - return i - index = next((i for i, (n, _) in enumerate(registry) if plugin_name == n), None) if index is None: diff --git a/cogs/utility.py b/cogs/utility.py index e05fd8600f..89bb079267 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -8,10 +8,10 @@ from datetime import datetime from difflib import get_close_matches from io import StringIO, BytesIO -from typing import Union -from types import SimpleNamespace as param from json import JSONDecodeError, loads from textwrap import indent +from types import SimpleNamespace as param +from typing import Union from discord import Embed, Color, Activity, Role from discord.enums import ActivityType, Status @@ -206,9 +206,12 @@ def __init__(self, bot): verify_checks=False, command_attrs={"help": "Shows this help message."} ) # Looks a bit ugly - self.bot.help_command._command_impl = checks.has_permissions( + # noinspection PyProtectedMember + self.bot.help_command._command_impl = checks.has_permissions( # pylint: disable=W0212 PermissionLevel.REGULAR - )(self.bot.help_command._command_impl) + )( + self.bot.help_command._command_impl # pylint: disable=W0212 + ) self.bot.help_command.cog = self @@ -1299,7 +1302,7 @@ def get_level(perm_level): @commands.group(invoke_without_command=True) @checks.has_permissions(PermissionLevel.OWNER) async def oauth(self, ctx): - """Commands relating to Logviewer oauth2 login authentication. + """Commands relating to logviewer oauth2 login authentication. This functionality on your logviewer site is a [**Patron**](https://patreon.com/kyber) only feature. """ @@ -1407,7 +1410,7 @@ def paginate(text: str): try: exec(to_compile, env) # pylint: disable=exec-used - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: await ctx.send(f"```py\n{exc.__class__.__name__}: {exc}\n```") return await ctx.message.add_reaction("\u2049") @@ -1415,7 +1418,7 @@ def paginate(text: str): try: with redirect_stdout(stdout): ret = await func() - except Exception: # pylint: disable=broad-except + except Exception: value = stdout.getvalue() await ctx.send(f"```py\n{value}{traceback.format_exc()}\n```") return await ctx.message.add_reaction("\u2049") @@ -1426,7 +1429,7 @@ def paginate(text: str): if value: try: await ctx.send(f"```py\n{value}\n```") - except Exception: # pylint: disable=broad-except + except Exception: paginated_text = paginate(value) for page in paginated_text: if page == paginated_text[-1]: @@ -1436,7 +1439,7 @@ def paginate(text: str): else: try: await ctx.send(f"```py\n{value}{ret}\n```") - except Exception: # pylint: disable=broad-except + except Exception: paginated_text = paginate(f"{value}{ret}") for page in paginated_text: if page == paginated_text[-1]: diff --git a/core/checks.py b/core/checks.py index b706d2bea3..42da8cf4b8 100644 --- a/core/checks.py +++ b/core/checks.py @@ -43,7 +43,9 @@ async def predicate(ctx): return commands.check(predicate) -async def check_permissions(ctx, command_name, permission_level) -> bool: +async def check_permissions( # pylint: disable=R0911 + ctx, command_name, permission_level +) -> bool: """Logic for checking permissions for a command for a user""" if await ctx.bot.is_owner(ctx.author): # Direct bot owner (creator) has absolute power over the bot @@ -79,8 +81,7 @@ async def check_permissions(ctx, command_name, permission_level) -> bool: role.id in level_permissions[level.name] for role in author_roles ) has_perm_id = ctx.author.id in level_permissions[level.name] - if has_perm_role or has_perm_id: - return True + return has_perm_role or has_perm_id return False diff --git a/core/thread.py b/core/thread.py index a05b67fb42..c2796a342f 100644 --- a/core/thread.py +++ b/core/thread.py @@ -330,7 +330,9 @@ async def _close( await asyncio.gather(*tasks) - async def cancel_closure(self, auto_close: bool = False, all: bool = False) -> None: + async def cancel_closure( + self, auto_close: bool = False, all: bool = False # pylint: disable=W0622 + ) -> None: if self.close_task is not None and (not auto_close or all): self.close_task.cancel() self.close_task = None @@ -590,21 +592,23 @@ async def send( delete_message = not bool(message.attachments) - attachments = [(a.url, a.filename) for a in message.attachments] + ext = [(a.url, a.filename) for a in message.attachments] - images = [x for x in attachments if is_image_url(*x)] - attachments = [x for x in attachments if not is_image_url(*x)] + images = [] + attachments = [] + for attachment in ext: + if is_image_url(attachment[0]): + images.append(attachment) + else: + attachments.append(attachment) - image_links = [ - (link, None) - for link in re.findall( - r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+", - message.content, - ) - ] + image_urls = re.findall( + r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+", + message.content, + ) - image_links = [x for x in image_links if is_image_url(*x)] - images.extend(image_links) + image_urls = [(url, None) for url in image_urls if is_image_url(url)] + images.extend(image_urls) embedded_image = False @@ -613,15 +617,15 @@ async def send( additional_images = [] additional_count = 1 - for att in images: + for url, filename in images: if not prioritize_uploads or ( - is_image_url(*att) and not embedded_image and att[1] + is_image_url(url) and not embedded_image and filename ): - embed.set_image(url=att[0]) - if att[1]: - embed.add_field(name="Image", value=f"[{att[1]}]({att[0]})") + embed.set_image(url=url) + if filename: + embed.add_field(name="Image", value=f"[{filename}]({url})") embedded_image = True - elif att[1] is not None: + elif filename is not None: if note: color = discord.Color.blurple() elif from_mod: @@ -630,9 +634,9 @@ async def send( color = self.bot.recipient_color img_embed = discord.Embed(color=color) - img_embed.set_image(url=att[0]) - img_embed.title = att[1] - img_embed.url = att[0] + img_embed.set_image(url=url) + img_embed.title = filename + img_embed.url = url img_embed.set_footer( text=f"Additional Image Upload ({additional_count})" ) @@ -642,9 +646,9 @@ async def send( file_upload_count = 1 - for att in attachments: + for url, filename in attachments: embed.add_field( - name=f"File upload ({file_upload_count})", value=f"[{att[1]}]({att[0]})" + name=f"File upload ({file_upload_count})", value=f"[{filename}]({url})" ) file_upload_count += 1 @@ -677,7 +681,7 @@ async def send( else: mentions = None - _msg = await destination.send(mentions, embed=embed) + msg = await destination.send(mentions, embed=embed) if additional_images: self.ready = False @@ -687,7 +691,7 @@ async def send( if delete_message: self.bot.loop.create_task(ignore(message.delete())) - return _msg + return msg def get_notifications(self) -> str: key = str(self.id) diff --git a/core/utils.py b/core/utils.py index 0d999b8f47..4f76d6d4d6 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,6 +1,6 @@ import re import typing -from distutils.util import strtobool as _stb +from distutils.util import strtobool as _stb # pylint: disable=E0401 from urllib import parse from discord import Object @@ -37,7 +37,7 @@ async def convert(self, ctx, argument): return Object(int(match.group(1))) -def truncate(text: str, max: int = 50) -> str: +def truncate(text: str, max: int = 50) -> str: # pylint: disable=W0622 """ Reduces the string to `max` length, by trimming the message into "...". @@ -85,7 +85,7 @@ def format_preview(messages: typing.List[typing.Dict[str, typing.Any]]): return out or "No Messages" -def is_image_url(url: str, _=None) -> bool: +def is_image_url(url: str) -> bool: """ Check if the URL is pointing to an image. From 3c917b4bbbb5a629e4156cd0b25e27b76a83fe67 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Thu, 11 Jul 2019 21:33:02 -0700 Subject: [PATCH 13/50] Updated pipfile --- .travis.yml | 6 +- Pipfile | 5 +- Pipfile.lock | 167 +++++++++++++++++++++++++++------------------------ Procfile | 2 +- bot.py | 8 ++- 5 files changed, 100 insertions(+), 88 deletions(-) diff --git a/.travis.yml b/.travis.yml index 7458b1db96..14f9928d1b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,7 @@ matrix: dist: xenial install: - - pipenv install - - pipenv install pylint + - pipenv install -d -script: python .lint.py +script: + - pipenv run python .lint.py diff --git a/Pipfile b/Pipfile index a37ea80cf9..095270dc6c 100644 --- a/Pipfile +++ b/Pipfile @@ -5,6 +5,7 @@ verify_ssl = true [dev-packages] black = "==19.3b0" +pylint = "*" [packages] colorama = ">=0.4.0" @@ -17,10 +18,12 @@ isodate = ">=0.6.0" dnspython = "~=1.16.0" parsedatetime = "==2.4" aiohttp = "<3.6.0,>=3.3.0" -pylint = "*" python-dotenv = ">=0.10.3" pipenv = "==2018.11.26" "discord.py" = "==1.1.1" [requires] python_version = "3.7" + +[scripts] +bot = "python bot.py" diff --git a/Pipfile.lock b/Pipfile.lock index a39f536e5f..c845f3a614 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "e4f5169004c14f38445d36c1bb89baeeaacc7dd5f43aa88f51b91d0df7dac2de" + "sha256": "4c7d21c64125daa0eea6dfb5499eef9d589d2164d6955689e845c83e37d115ae" }, "pipfile-spec": 6, "requires": { @@ -44,13 +44,6 @@ "index": "pypi", "version": "==3.5.4" }, - "astroid": { - "hashes": [ - "sha256:6560e1e1749f68c64a4b5dee4e091fce798d2f0d84ebe638cf0e0585a343acf4", - "sha256:b65db1bbaac9f9f4d190199bb8680af6f6f84fd3769a5ea883df8a91fe68b4c4" - ], - "version": "==2.2.5" - }, "async-timeout": { "hashes": [ "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", @@ -131,43 +124,6 @@ "index": "pypi", "version": "==0.6.0" }, - "isort": { - "hashes": [ - "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", - "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd" - ], - "version": "==4.3.21" - }, - "lazy-object-proxy": { - "hashes": [ - "sha256:159a745e61422217881c4de71f9eafd9d703b93af95618635849fe469a283661", - "sha256:23f63c0821cc96a23332e45dfaa83266feff8adc72b9bcaef86c202af765244f", - "sha256:3b11be575475db2e8a6e11215f5aa95b9ec14de658628776e10d96fa0b4dac13", - "sha256:3f447aff8bc61ca8b42b73304f6a44fa0d915487de144652816f950a3f1ab821", - "sha256:4ba73f6089cd9b9478bc0a4fa807b47dbdb8fad1d8f31a0f0a5dbf26a4527a71", - "sha256:4f53eadd9932055eac465bd3ca1bd610e4d7141e1278012bd1f28646aebc1d0e", - "sha256:64483bd7154580158ea90de5b8e5e6fc29a16a9b4db24f10193f0c1ae3f9d1ea", - "sha256:6f72d42b0d04bfee2397aa1862262654b56922c20a9bb66bb76b6f0e5e4f9229", - "sha256:7c7f1ec07b227bdc561299fa2328e85000f90179a2f44ea30579d38e037cb3d4", - "sha256:7c8b1ba1e15c10b13cad4171cfa77f5bb5ec2580abc5a353907780805ebe158e", - "sha256:8559b94b823f85342e10d3d9ca4ba5478168e1ac5658a8a2f18c991ba9c52c20", - "sha256:a262c7dfb046f00e12a2bdd1bafaed2408114a89ac414b0af8755c696eb3fc16", - "sha256:acce4e3267610c4fdb6632b3886fe3f2f7dd641158a843cf6b6a68e4ce81477b", - "sha256:be089bb6b83fac7f29d357b2dc4cf2b8eb8d98fe9d9ff89f9ea6012970a853c7", - "sha256:bfab710d859c779f273cc48fb86af38d6e9210f38287df0069a63e40b45a2f5c", - "sha256:c10d29019927301d524a22ced72706380de7cfc50f767217485a912b4c8bd82a", - "sha256:dd6e2b598849b3d7aee2295ac765a578879830fb8966f70be8cd472e6069932e", - "sha256:e408f1eacc0a68fed0c08da45f31d0ebb38079f043328dce69ff133b95c29dc1" - ], - "version": "==1.4.1" - }, - "mccabe": { - "hashes": [ - "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", - "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" - ], - "version": "==0.6.1" - }, "motor": { "hashes": [ "sha256:462fbb824f4289481c158227a2579d6adaf1ec7c70cf7ebe60ed6ceb321e5869", @@ -234,14 +190,6 @@ "index": "pypi", "version": "==2018.11.26" }, - "pylint": { - "hashes": [ - "sha256:5d77031694a5fb97ea95e828c8d10fc770a1df6eb3906067aaed42201a8a6a09", - "sha256:723e3db49555abaf9bf79dc474c6b9e2935ad82230b10c1138a71ea41ac0fff1" - ], - "index": "pypi", - "version": "==2.3.1" - }, "pymongo": { "hashes": [ "sha256:32421df60d06f479d71b6b539642e410ece3006e8910688e68df962c8eb40a21", @@ -298,27 +246,6 @@ ], "version": "==1.12.0" }, - "typed-ast": { - "hashes": [ - "sha256:18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e", - "sha256:262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e", - "sha256:2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0", - "sha256:354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c", - "sha256:4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631", - "sha256:630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4", - "sha256:66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34", - "sha256:71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b", - "sha256:95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a", - "sha256:bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233", - "sha256:cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1", - "sha256:d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36", - "sha256:d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d", - "sha256:d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a", - "sha256:ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12" - ], - "markers": "implementation_name == 'cpython'", - "version": "==1.4.0" - }, "uvloop": { "hashes": [ "sha256:0fcd894f6fc3226a962ee7ad895c4f52e3f5c3c55098e21efb17c071849a0573", @@ -376,12 +303,6 @@ ], "version": "==6.0" }, - "wrapt": { - "hashes": [ - "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1" - ], - "version": "==1.11.2" - }, "yarl": { "hashes": [ "sha256:024ecdc12bc02b321bc66b41327f930d1c2c543fa9a561b39861da9388ba7aa9", @@ -407,6 +328,13 @@ ], "version": "==1.4.3" }, + "astroid": { + "hashes": [ + "sha256:6560e1e1749f68c64a4b5dee4e091fce798d2f0d84ebe638cf0e0585a343acf4", + "sha256:b65db1bbaac9f9f4d190199bb8680af6f6f84fd3769a5ea883df8a91fe68b4c4" + ], + "version": "==2.2.5" + }, "attrs": { "hashes": [ "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", @@ -429,12 +357,91 @@ ], "version": "==7.0" }, + "isort": { + "hashes": [ + "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", + "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd" + ], + "version": "==4.3.21" + }, + "lazy-object-proxy": { + "hashes": [ + "sha256:159a745e61422217881c4de71f9eafd9d703b93af95618635849fe469a283661", + "sha256:23f63c0821cc96a23332e45dfaa83266feff8adc72b9bcaef86c202af765244f", + "sha256:3b11be575475db2e8a6e11215f5aa95b9ec14de658628776e10d96fa0b4dac13", + "sha256:3f447aff8bc61ca8b42b73304f6a44fa0d915487de144652816f950a3f1ab821", + "sha256:4ba73f6089cd9b9478bc0a4fa807b47dbdb8fad1d8f31a0f0a5dbf26a4527a71", + "sha256:4f53eadd9932055eac465bd3ca1bd610e4d7141e1278012bd1f28646aebc1d0e", + "sha256:64483bd7154580158ea90de5b8e5e6fc29a16a9b4db24f10193f0c1ae3f9d1ea", + "sha256:6f72d42b0d04bfee2397aa1862262654b56922c20a9bb66bb76b6f0e5e4f9229", + "sha256:7c7f1ec07b227bdc561299fa2328e85000f90179a2f44ea30579d38e037cb3d4", + "sha256:7c8b1ba1e15c10b13cad4171cfa77f5bb5ec2580abc5a353907780805ebe158e", + "sha256:8559b94b823f85342e10d3d9ca4ba5478168e1ac5658a8a2f18c991ba9c52c20", + "sha256:a262c7dfb046f00e12a2bdd1bafaed2408114a89ac414b0af8755c696eb3fc16", + "sha256:acce4e3267610c4fdb6632b3886fe3f2f7dd641158a843cf6b6a68e4ce81477b", + "sha256:be089bb6b83fac7f29d357b2dc4cf2b8eb8d98fe9d9ff89f9ea6012970a853c7", + "sha256:bfab710d859c779f273cc48fb86af38d6e9210f38287df0069a63e40b45a2f5c", + "sha256:c10d29019927301d524a22ced72706380de7cfc50f767217485a912b4c8bd82a", + "sha256:dd6e2b598849b3d7aee2295ac765a578879830fb8966f70be8cd472e6069932e", + "sha256:e408f1eacc0a68fed0c08da45f31d0ebb38079f043328dce69ff133b95c29dc1" + ], + "version": "==1.4.1" + }, + "mccabe": { + "hashes": [ + "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", + "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + ], + "version": "==0.6.1" + }, + "pylint": { + "hashes": [ + "sha256:5d77031694a5fb97ea95e828c8d10fc770a1df6eb3906067aaed42201a8a6a09", + "sha256:723e3db49555abaf9bf79dc474c6b9e2935ad82230b10c1138a71ea41ac0fff1" + ], + "index": "pypi", + "version": "==2.3.1" + }, + "six": { + "hashes": [ + "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", + "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" + ], + "version": "==1.12.0" + }, "toml": { "hashes": [ "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e" ], "version": "==0.10.0" + }, + "typed-ast": { + "hashes": [ + "sha256:18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e", + "sha256:262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e", + "sha256:2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0", + "sha256:354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c", + "sha256:4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631", + "sha256:630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4", + "sha256:66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34", + "sha256:71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b", + "sha256:95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a", + "sha256:bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233", + "sha256:cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1", + "sha256:d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36", + "sha256:d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d", + "sha256:d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a", + "sha256:ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12" + ], + "markers": "implementation_name == 'cpython'", + "version": "==1.4.0" + }, + "wrapt": { + "hashes": [ + "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1" + ], + "version": "==1.11.2" } } } diff --git a/Procfile b/Procfile index 29cff6d9d1..5ae4640def 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -worker: python bot.py +worker: pipenv run bot diff --git a/bot.py b/bot.py index 32960b4336..c83d76ac50 100644 --- a/bot.py +++ b/bot.py @@ -1006,8 +1006,10 @@ async def metadata_loop(self): if __name__ == "__main__": if os.name != "nt": - import uvloop - - uvloop.install() + try: + import uvloop + uvloop.install() + except ImportError: + pass bot = ModmailBot() bot.run() From 1dce4750296fa2311ecbf6900c7b9b8020f46b16 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Thu, 11 Jul 2019 21:50:08 -0700 Subject: [PATCH 14/50] A minimal version of req.txt --- requirements.txt.min | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 requirements.txt.min diff --git a/requirements.txt.min b/requirements.txt.min new file mode 100644 index 0000000000..27849727ad --- /dev/null +++ b/requirements.txt.min @@ -0,0 +1,15 @@ +# Generated as of July 11, 2019 +# This is the bare minimum requirements.txt for running Modmail. +# To install requirements.txt run: pip install -r requirements.txt.min + +aiohttp==3.5.4 +colorama==0.4.1 +discord.py==1.1.1 +dnspython==1.16.0 +emoji==0.5.2 +isodate==0.6.0 +motor==2.0.0 +natural==0.2.0 +parsedatetime==2.4 +python-dateutil==2.8.0 +python-dotenv==0.10.3 From f9781c319f71831018e71bb0265ba11b0a8f44ae Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Fri, 12 Jul 2019 17:04:45 -0700 Subject: [PATCH 15/50] Remove requirement for colorama --- bot.py | 6 ++++++ core/models.py | 9 ++++----- core/thread.py | 1 + requirements.txt.min => requirements.min.txt | 3 +-- 4 files changed, 12 insertions(+), 7 deletions(-) rename requirements.txt.min => requirements.min.txt (75%) diff --git a/bot.py b/bot.py index c83d76ac50..6e95cc0ba7 100644 --- a/bot.py +++ b/bot.py @@ -20,6 +20,12 @@ from emoji import UNICODE_EMOJI from motor.motor_asyncio import AsyncIOMotorClient +try: + from colorama import init + init() +except ImportError: + pass + from core.clients import ApiClient, PluginDatabaseClient from core.config import ConfigManager from core.utils import human_join, strtobool diff --git a/core/models.py b/core/models.py index 4ba3964231..98780a3002 100644 --- a/core/models.py +++ b/core/models.py @@ -4,7 +4,10 @@ from discord import Color, Embed from discord.ext import commands -from colorama import Fore, Style, init +try: + from colorama import Fore, Style +except ImportError: + Fore = Style = type('Dummy', (object,), {'__getattr__': lambda self, item: ''})() class PermissionLevel(IntEnum): @@ -30,10 +33,6 @@ def embed(self): class ModmailLogger(logging.Logger): - def __init__(self, *args, **kwargs): - init() - super().__init__(*args, **kwargs) - @staticmethod def _debug_(*msgs): return f'{Fore.CYAN}{" ".join(msgs)}{Style.RESET_ALL}' diff --git a/core/thread.py b/core/thread.py index c2796a342f..11521d74ac 100644 --- a/core/thread.py +++ b/core/thread.py @@ -808,6 +808,7 @@ async def _find_from_channel(self, channel): thread.ready = True return thread + return None def create( self, diff --git a/requirements.txt.min b/requirements.min.txt similarity index 75% rename from requirements.txt.min rename to requirements.min.txt index 27849727ad..70a2c8ae59 100644 --- a/requirements.txt.min +++ b/requirements.min.txt @@ -1,9 +1,8 @@ # Generated as of July 11, 2019 # This is the bare minimum requirements.txt for running Modmail. -# To install requirements.txt run: pip install -r requirements.txt.min +# To install requirements.txt run: pip install -r requirements.min.txt aiohttp==3.5.4 -colorama==0.4.1 discord.py==1.1.1 dnspython==1.16.0 emoji==0.5.2 From a2787e89ea82afabf78ff4aef3ac3e33ef3256f9 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Fri, 12 Jul 2019 17:44:22 -0700 Subject: [PATCH 16/50] thread_auto_close_silently + some fixes --- CHANGELOG.md | 13 ++++++++----- cogs/modmail.py | 4 ++-- core/config.py | 2 ++ core/thread.py | 27 +++++++++++++++++++++++++-- 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bdc482732..41c690dbf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `disable_recipient_thread_close` is removed, a new configuration variable `recipient_thread_close` replaces it which defaults to False. - Truthy and falsy values for binary configuration variables are now interpreted respectfully. +### Added + +- `?sfw`, mark a thread as "safe for work", undos `?nsfw`. +- New config variable, `thread_auto_close_silently`, when set to a truthy value, no message will be sent when thread is auto-closed. +- New configuration variable `thread_self_closable_creation_footer` — the footer when `recipient_thread_close` is enabled. + ### Changes - `thread_auto_close_response` has a configurable variable `{timeout}`. -- New configuration variable `thread_self_closable_creation_footer`, the footer when `recipient_thread_close` is enabled. - `?snippet` is now the default command name instead of `?snippets` (`?snippets` is still usable). This is to make this consistent with `?alias`/`?aliases`. ### Fixes @@ -23,10 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `?notify` no longer carries over to the next thread. - `discord.NotFound` errors for `on_raw_reaction_add`. - `mod_typing` and `user_typing` will no longer show when user is blocked. - -### Added - -- `?sfw`, mark a thread as "safe for work", undos `?nsfw`. +- Better `?block` usage message. +- Resolves errors when message was sent by mods after thread is closed somehow. ### Internal diff --git a/cogs/modmail.py b/cogs/modmail.py index 9e4228d4c5..dc4e92e87b 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -917,7 +917,7 @@ async def blocked_whitelist(self, ctx, *, user: User = None): return await ctx.send(embed=embed) - @commands.command() + @commands.command(usage="[user] [duration] [close message]") @checks.has_permissions(PermissionLevel.MODERATOR) @trigger_typing async def block( @@ -931,7 +931,7 @@ async def block( Leave `user` blank when this command is used within a thread channel to block the current recipient. `user` may be a user ID, mention, or name. - `after` may be a simple "human-readable" time text. See `{prefix}help close` for examples. + `duration` may be a simple "human-readable" time text. See `{prefix}help close` for examples. """ reason = "" diff --git a/core/config.py b/core/config.py index 8d29a3c2b9..f5f3b9c50b 100644 --- a/core/config.py +++ b/core/config.py @@ -42,6 +42,7 @@ class ConfigManager: "blocked_emoji": "🚫", "close_emoji": "🔒", "recipient_thread_close": False, + "thread_auto_close_silently": False, "thread_auto_close": 0, "thread_auto_close_response": "This thread has been closed automatically due to inactivity after {timeout}.", "thread_creation_response": "The staff team will get back to you as soon as possible.", @@ -106,6 +107,7 @@ class ConfigManager: "mod_typing", "reply_without_command", "recipient_thread_close", + "thread_auto_close_silently" } defaults = {**public_keys, **private_keys, **protected_keys} diff --git a/core/thread.py b/core/thread.py index 11521d74ac..696afb16f7 100644 --- a/core/thread.py +++ b/core/thread.py @@ -232,7 +232,11 @@ async def close( async def _close( self, closer, silent=False, delete_channel=True, message=None, scheduled=False ): - self.manager.cache.pop(self.id) + try: + self.manager.cache.pop(self.id) + except KeyError: + logger.warning('Thread already closed.', exc_info=True) + return await self.cancel_closure(all=True) @@ -396,6 +400,21 @@ async def _restart_close_timer(self): reset_time = datetime.utcnow() + timedelta(seconds=seconds) human_time = human_timedelta(dt=reset_time) + try: + thread_auto_close_silently = strtobool( + self.bot.config["thread_auto_close_silently"] + ) + except ValueError: + thread_auto_close_silently = self.bot.config.remove("thread_auto_close_silently") + + if thread_auto_close_silently: + return await self.close( + closer=self.bot.user, + silent=True, + after=int(seconds), + auto_close=True, + ) + # Grab message close_message = self.bot.config["thread_auto_close_response"].format( timeout=human_time @@ -674,7 +693,11 @@ async def send( # noinspection PyUnresolvedReferences,PyDunderSlots embed.color = self.bot.recipient_color # pylint: disable=E0237 - await destination.trigger_typing() + try: + await destination.trigger_typing() + except discord.NotFound: + logger.warning('Channel not found.', exc_info=True) + return if not from_mod and not note: mentions = self.get_notifications() From 8969a79cba9007069ff8d1581fff5a9b4275d351 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Fri, 12 Jul 2019 18:03:00 -0700 Subject: [PATCH 17/50] Black formatting --- bot.py | 2 ++ core/config.py | 4 ++-- core/models.py | 2 +- core/thread.py | 13 ++++++------- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/bot.py b/bot.py index 6e95cc0ba7..fba3490fea 100644 --- a/bot.py +++ b/bot.py @@ -22,6 +22,7 @@ try: from colorama import init + init() except ImportError: pass @@ -1014,6 +1015,7 @@ async def metadata_loop(self): if os.name != "nt": try: import uvloop + uvloop.install() except ImportError: pass diff --git a/core/config.py b/core/config.py index f5f3b9c50b..5f7eda1ea1 100644 --- a/core/config.py +++ b/core/config.py @@ -43,7 +43,7 @@ class ConfigManager: "close_emoji": "🔒", "recipient_thread_close": False, "thread_auto_close_silently": False, - "thread_auto_close": 0, + "thread_auto_close": None, "thread_auto_close_response": "This thread has been closed automatically due to inactivity after {timeout}.", "thread_creation_response": "The staff team will get back to you as soon as possible.", "thread_creation_footer": "Your message has been sent", @@ -107,7 +107,7 @@ class ConfigManager: "mod_typing", "reply_without_command", "recipient_thread_close", - "thread_auto_close_silently" + "thread_auto_close_silently", } defaults = {**public_keys, **private_keys, **protected_keys} diff --git a/core/models.py b/core/models.py index 98780a3002..34c79da1c5 100644 --- a/core/models.py +++ b/core/models.py @@ -7,7 +7,7 @@ try: from colorama import Fore, Style except ImportError: - Fore = Style = type('Dummy', (object,), {'__getattr__': lambda self, item: ''})() + Fore = Style = type("Dummy", (object,), {"__getattr__": lambda self, item: ""})() class PermissionLevel(IntEnum): diff --git a/core/thread.py b/core/thread.py index 696afb16f7..e85a734bd6 100644 --- a/core/thread.py +++ b/core/thread.py @@ -235,7 +235,7 @@ async def _close( try: self.manager.cache.pop(self.id) except KeyError: - logger.warning('Thread already closed.', exc_info=True) + logger.warning("Thread already closed.", exc_info=True) return await self.cancel_closure(all=True) @@ -405,14 +405,13 @@ async def _restart_close_timer(self): self.bot.config["thread_auto_close_silently"] ) except ValueError: - thread_auto_close_silently = self.bot.config.remove("thread_auto_close_silently") + thread_auto_close_silently = self.bot.config.remove( + "thread_auto_close_silently" + ) if thread_auto_close_silently: return await self.close( - closer=self.bot.user, - silent=True, - after=int(seconds), - auto_close=True, + closer=self.bot.user, silent=True, after=int(seconds), auto_close=True ) # Grab message @@ -696,7 +695,7 @@ async def send( try: await destination.trigger_typing() except discord.NotFound: - logger.warning('Channel not found.', exc_info=True) + logger.warning("Channel not found.", exc_info=True) return if not from_mod and not note: From 41f7207513c5eb6100a742efaaf88dc316ff2d2e Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Fri, 12 Jul 2019 18:06:58 -0700 Subject: [PATCH 18/50] Bump: v3.1.0 --- CHANGELOG.md | 2 +- bot.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41c690dbf4..6c50478373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -# [UNRELEASED] +# v3.1.0 ### Breaking diff --git a/bot.py b/bot.py index fba3490fea..4b3f30464d 100644 --- a/bot.py +++ b/bot.py @@ -1,4 +1,4 @@ -__version__ = "3.0.3" +__version__ = "3.1.0" import asyncio import logging From 40fee5c5c6bf1b897cacc03d8ddea93d090a5e2b Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Fri, 12 Jul 2019 18:16:00 -0700 Subject: [PATCH 19/50] Updated changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c50478373..e4a095d418 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,11 +17,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `?sfw`, mark a thread as "safe for work", undos `?nsfw`. - New config variable, `thread_auto_close_silently`, when set to a truthy value, no message will be sent when thread is auto-closed. - New configuration variable `thread_self_closable_creation_footer` — the footer when `recipient_thread_close` is enabled. +- Added a minimalistic version of requirements.txt (named requirements.min.txt) that contains only the absolute minimum of Modmail. + - For users having trouble with pipenv or any other reason. ### Changes - `thread_auto_close_response` has a configurable variable `{timeout}`. - `?snippet` is now the default command name instead of `?snippets` (`?snippets` is still usable). This is to make this consistent with `?alias`/`?aliases`. +- Colorama is no longer a necessity, this is due to some unsupported OS. ### Fixes From e585b23f7bd2e500882f837395ac4bc91958b05f Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Fri, 12 Jul 2019 18:46:40 -0700 Subject: [PATCH 20/50] Update README.md --- README.md | 88 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 52 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index effaa06338..72e382be11 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

- A feature rich Modmail bot for Discord. + A feature-rich Modmail bot for Discord.

@@ -32,70 +32,86 @@ Modmail is similar to Reddit's Modmail both in functionality and purpose. It serves as a shared inbox for server staff to communicate with their users in a seamless way. +This bot is free for everyone and always will be. If you like this project and would like to show your appreciation, you can support us on **[Patreon](https://www.patreon.com/kyber)**, cool benefits included! + ## How does it work? When a member sends a direct message to the bot, a channel or "thread" is created within an isolated category for that member. This channel is where messages will be relayed and where any available staff member can respond to that user. -All threads are logged and you can view previous threads through the corresponding generated log link. Here is an [**example**](https://logs.modmail.tk/example) +All threads are logged and you can view previous threads through the corresponding generated log link. Here is an [**example**](https://logs.modmail.tk/example). ## Features -* **Highly Customisable** +* **Highly Customisable:** * Bot activity, prefix, category, log channel, etc. - * Fully customisable command permission system. - * Interface elements (color, responses, reactions, etc.) - * Snippets and *command aliases* - * Minimum account/guild age in order to create a thread. -* **Thread logs** + * Command permission system. + * Interface elements (color, responses, reactions, etc). + * Snippets and *command aliases*. + * Minimum duration for accounts to be created before allowed to contact Modmail (`account_age`). + * Minimum duration for member to be in the guild allowed to contact Modmail (`guild_age`). +* **Thread logs:** * When you close a thread, a [log link](https://logs.modmail.tk/example) is generated and posted to your log channel. - * Rendered in styled HTML like Discord. - * Login in via Discord to protect your logs ([Patron only feature](https://patreon.com/kyber)). - * See past logs of a user with `?logs` - * Searchable by text queries using `?logs search` -* **Robust implementation** + * Native Discord dark-mode feel. + * Markdown/formatting support. + * Login via Discord to protect your logs ([premium Patreon feature](https://patreon.com/kyber)). + * See past logs of a user with `?logs`. + * Searchable by text queries using `?logs search`. +* **Robust implementation:** * Scheduled tasks in human time, e.g. `?close in 2 hours silently`. - * Editing and deleting messages is synced on both ends. - * Support for the full range of message content (mutliple images, files). + * Editing and deleting messages are synced on all channels. + * Support for the full range of message content (multiple images, files). * Paginated commands interfaces via reactions. -This list is ever growing thanks to active development and our exceptional contributors. See a full list of documented commands by using the `help` command. +This list is ever-growing thanks to active development and our exceptional contributors. See a full list of documented commands by using the `?help` command. ## Installation -### Locally -Installation locally for development reasons or otherwise is as follows, you will need `python 3.7`. +### Heroku + +This bot can be hosted on Heroku. + +Installation via Heroku is possible with only your web browser. +The [**installation guide**](https://github.com/kyb3r/modmail/wiki/Installation) will guide you through the entire installation process. If you run into any problems, join the [development server](https://discord.gg/etJNHCQ) for help and support. + +You can also set up auto-update. To do this: + - [Fork the repo](https://github.com/kyb3r/modmail/fork). + - [Install the Pull app for your fork](https://github.com/apps/pull). + - Then go to the Deploy tab in your Heroku account, select GitHub and connect your fork. + - Turn on auto-deploy for the `master` branch. + +### Hosting for patrons + +If you don't want to go through the trouble of setting up your own bot, and want to support this project as well, we offer installation, hosting and maintenance for Modmail bots for [**Patrons**](https://patreon.com/kyber). Join the support server for more info! + +### Locally + +Installation locally for development reasons or otherwise is as follows, you will need [`python 3.7`](https://www.python.org/downloads/). +Follow the [**installation guide**](https://github.com/kyb3r/modmail/wiki/Installation) and disregard deploying Heroku apps. If you run into any problems, join the [development server](https://discord.gg/etJNHCQ) for help and support. + +Clone the repo: -Clone the repo ```console $ git clone https://github.com/kyb3r/modmail $ cd modmail ``` -Install dependancies -```console -$ pipenv install -``` +Install dependencies: -Rename the `.env.example` to `.env` and fill out the fields. -And finally, run the bot. ```console -$ pipenv run python3 bot.py +$ pipenv install ``` -### Hosting for patrons - -If you don't want to go through the trouble of setting up your own bot, and want to support this project as well, we offer installation, hosting and maintainance for Modmail bots for [**Patrons**](https://patreon.com/kyber). Join the support server for more info! +Rename the `.env.example` to `.env` and fill out the fields. If `.env.example` is hidden, create a text file named `.env` and copy the contents of [`.env.example`](https://raw.githubusercontent.com/kyb3r/modmail/master/.env.example) then modify the values. -### Heroku -This bot can be hosted on heroku. Installation via Heroku is done in your web browser. The [**installation guide**](https://github.com/kyb3r/modmail/wiki/Installation) will guide you through the entire installation process. If you run into any problems, join the [development server](https://discord.gg/etJNHCQ) for help and support. +Finally, run the bot. -You can also set up autoupdates. To do this, [install the Pull app in your fork](https://github.com/apps/pull). Then go to the Deploy tab in your Heroku account, select GitHub and connect your fork. Turn on auto-deploy for the master branch. +```console +$ pipenv run bot +``` ## Plugins -Modmail supports the use of third party plugins to extend or add functionality to the bot. This allows the introduction of niche features as well as anything else outside of the scope of the core functionality of Modmail. A list of third party plugins can be found using the `plugins registry` command. To develop your own, check out the [documentation](https://github.com/kyb3r/modmail/wiki/Plugins) for plugins. +Modmail supports the use of third-party plugins to extend or add functionality to the bot. This allows the introduction of niche features as well as anything else outside of the scope of the core functionality of Modmail. A list of third party plugins can be found using the `plugins registry` command. To develop your own, check out the [documentation](https://github.com/kyb3r/modmail/wiki/Plugins) for plugins. ## Contributing -Contributions to Modmail are always welcome, whether it be improvements to the documentation or new functionality, please feel free make the change. Check out our contribution [guidelines](https://github.com/kyb3r/modmail/blob/master/CONTRIBUTING.md) before you get started. - -This bot is free for everyone and always will be. If you like this project and would like to show your appreciation, here's the link for our **[Patreon](https://www.patreon.com/kyber)**. +Contributions to Modmail are always welcome, whether it be improvements to the documentation or new functionality, please feel free to make the change. Check out our contribution [guidelines](https://github.com/kyb3r/modmail/blob/master/CONTRIBUTING.md) before you get started. From 226b3dc2ac52d5c25643f21cec7c8233be2357a9 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Thu, 18 Jul 2019 16:26:21 -0700 Subject: [PATCH 21/50] Bump discord.py version to 1.2.3 --- CHANGELOG.md | 1 + Pipfile | 2 +- Pipfile.lock | 12 ++++++------ cogs/utility.py | 4 ++-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23b77febb3..995e6a104a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `config.cache` is no longer accessible, use `config['key']` for getting, `config['key'] = value` for setting, `config.remove('key')` for removing. - Dynamic attribute for configs are removed, must use `config['key']` or `config.get('key')`. - Removed helper functions `info()` and `error()` for formatting logging, it's formatted automatically now. +- Bumped discord.py version to 1.2.3. # v3.0.3 diff --git a/Pipfile b/Pipfile index 095270dc6c..0b541bd697 100644 --- a/Pipfile +++ b/Pipfile @@ -20,7 +20,7 @@ parsedatetime = "==2.4" aiohttp = "<3.6.0,>=3.3.0" python-dotenv = ">=0.10.3" pipenv = "==2018.11.26" -"discord.py" = "==1.1.1" +"discord.py" = "==1.2.3" [requires] python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock index c845f3a614..5fbfe85392 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "4c7d21c64125daa0eea6dfb5499eef9d589d2164d6955689e845c83e37d115ae" + "sha256": "46092f8b1032f2ac529f9b0df1aa725a4c6135d04826cd4cc4844af01879a4c2" }, "pipfile-spec": 6, "requires": { @@ -82,10 +82,10 @@ }, "discord.py": { "hashes": [ - "sha256:d0ab22f1fee1fcc02ac50a67ff49a5d1f6d7bc7eba77e34e35bd160b3ad3d7e8" + "sha256:4684733fa137cc7def18087ae935af615212e423e3dbbe3e84ef01d7ae8ed17d" ], "index": "pypi", - "version": "==1.1.1" + "version": "==1.2.3" }, "dnspython": { "hashes": [ @@ -265,10 +265,10 @@ }, "virtualenv": { "hashes": [ - "sha256:b7335cddd9260a3dd214b73a2521ffc09647bde3e9457fcca31dc3be3999d04a", - "sha256:d28ca64c0f3f125f59cabf13e0a150e1c68e5eea60983cc4395d88c584495783" + "sha256:861bbce3a418110346c70f5c7a696fdcf23a261424e1d28aa4f9362fc2ccbc19", + "sha256:ba8ce6a961d842320681fb90a3d564d0e5134f41dacd0e2bae7f02441dde2d52" ], - "version": "==16.6.1" + "version": "==16.6.2" }, "virtualenv-clone": { "hashes": [ diff --git a/cogs/utility.py b/cogs/utility.py index 1cbbc96b3f..315aff0c94 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -546,7 +546,7 @@ async def set_presence( if status_by_key: status = Status[status_identifier] else: - status = Status(status_identifier) + status = Status.try_value(status_identifier) except (KeyError, ValueError): if status_identifier is not None: msg = f"Invalid status type: {status_identifier}" @@ -564,7 +564,7 @@ async def set_presence( if activity_by_key: activity_type = ActivityType[activity_identifier] else: - activity_type = ActivityType(activity_identifier) + activity_type = ActivityType.try_value(activity_identifier) except (KeyError, ValueError): if activity_identifier is not None: msg = f"Invalid activity type: {activity_identifier}" From cbdf61fc981a1242530a30674cf9ec0989cd776f Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Thu, 18 Jul 2019 17:35:44 -0700 Subject: [PATCH 22/50] Use tasks --- CHANGELOG.md | 1 + bot.py | 90 +++++++++++++++++++++++++++---------------------- cogs/utility.py | 2 +- core/thread.py | 11 ++++-- 4 files changed, 60 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 995e6a104a..2e520bb1f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Dynamic attribute for configs are removed, must use `config['key']` or `config.get('key')`. - Removed helper functions `info()` and `error()` for formatting logging, it's formatted automatically now. - Bumped discord.py version to 1.2.3. +- Use discord tasks for metadata loop. # v3.0.3 diff --git a/bot.py b/bot.py index 4b3f30464d..5043c0681d 100644 --- a/bot.py +++ b/bot.py @@ -11,7 +11,7 @@ from types import SimpleNamespace import discord -from discord.ext import commands +from discord.ext import commands, tasks from discord.ext.commands.view import StringView import isodate @@ -70,6 +70,7 @@ def __init__(self): self.config = ConfigManager(self) self.config.populate_cache() + self.threads = ThreadManager(self) self._configure_logging() @@ -81,7 +82,8 @@ def __init__(self): self.db = AsyncIOMotorClient(mongo_uri).modmail_bot self.plugin_db = PluginDatabaseClient(self) - self.metadata_task = self.loop.create_task(self.metadata_loop()) + self.metadata_loop = None + self._load_extensions() @property @@ -183,12 +185,6 @@ def run(self, *args, **kwargs): except Exception: logger.critical("Fatal exception", exc_info=True) finally: - try: - self.metadata_task.cancel() - self.loop.run_until_complete(self.metadata_task) - except asyncio.CancelledError: - logger.debug("metadata_task has been cancelled.") - self.loop.run_until_complete(self.logout()) for task in asyncio.all_tasks(self.loop): task.cancel() @@ -437,6 +433,19 @@ async def on_ready(self): logger.line() + self.metadata_loop = tasks.Loop( + self.post_metadata, + seconds=0, + minutes=0, + hours=1, + count=None, + reconnect=True, + loop=None, + ) + self.metadata_loop.before_loop(self.before_post_metadata) + self.metadata_loop.after_loop(self.after_post_metadata) + self.metadata_loop.start() + async def convert_emoji(self, name: str) -> str: ctx = SimpleNamespace(bot=self, guild=self.modmail_guild) converter = commands.EmojiConverter() @@ -958,7 +967,7 @@ async def validate_database_connection(self): try: await self.db.command("buildinfo") except Exception as exc: - logger.critical("Something went wrong " "while connecting to the database.") + logger.critical("Something went wrong while connecting to the database.") message = f"{type(exc).__name__}: {str(exc)}" logger.critical(message) @@ -981,43 +990,44 @@ async def validate_database_connection(self): else: logger.info("Successfully connected to the database.") - async def metadata_loop(self): + async def post_metadata(self): + owner = (await self.application_info()).owner + data = { + "owner_name": str(owner), + "owner_id": owner.id, + "bot_id": self.user.id, + "bot_name": str(self.user), + "avatar_url": str(self.user.avatar_url), + "guild_id": self.guild_id, + "guild_name": self.guild.name, + "member_count": len(self.guild.members), + "uptime": (datetime.utcnow() - self.start_time).total_seconds(), + "latency": f"{self.ws.latency * 1000:.4f}", + "version": self.version, + "selfhosted": True, + "last_updated": str(datetime.utcnow()), + } + + async with self.session.post("https://api.modmail.tk/metadata", json=data): + logger.debug("Uploading metadata to Modmail server.") + + async def before_post_metadata(self): + logger.info("Starting metadata loop.") await self.wait_for_connected() if not self.guild: - return - - owner = (await self.application_info()).owner + self.metadata_loop.cancel() - while not self.is_closed(): - data = { - "owner_name": str(owner), - "owner_id": owner.id, - "bot_id": self.user.id, - "bot_name": str(self.user), - "avatar_url": str(self.user.avatar_url), - "guild_id": self.guild_id, - "guild_name": self.guild.name, - "member_count": len(self.guild.members), - "uptime": (datetime.utcnow() - self.start_time).total_seconds(), - "latency": f"{self.ws.latency * 1000:.4f}", - "version": self.version, - "selfhosted": True, - "last_updated": str(datetime.utcnow()), - } - - async with self.session.post("https://api.modmail.tk/metadata", json=data): - logger.debug("Uploading metadata to Modmail server.") - - await asyncio.sleep(3600) + async def after_post_metadata(self): + logger.info("Metadata loop has been cancelled.") if __name__ == "__main__": - if os.name != "nt": - try: - import uvloop + try: + import uvloop + + uvloop.install() + except ImportError: + pass - uvloop.install() - except ImportError: - pass bot = ModmailBot() bot.run() diff --git a/cogs/utility.py b/cogs/utility.py index 315aff0c94..ee7b7c9493 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -227,7 +227,7 @@ def cog_unload(self): @commands.command() @checks.has_permissions(PermissionLevel.REGULAR) @trigger_typing - async def changelog(self, ctx, version: str.lower = ''): + async def changelog(self, ctx, version: str.lower = ""): """Shows the changelog of the Modmail.""" changelog = await Changelog.from_url(self.bot) version = version.lstrip("vV") if version else changelog.latest_version.version diff --git a/core/thread.py b/core/thread.py index bd693b02e3..04e506f7ae 100644 --- a/core/thread.py +++ b/core/thread.py @@ -319,7 +319,9 @@ async def _close( else: message = self.bot.config["thread_close_response"] - message = message.format(closer=closer, loglink=log_url, logkey=log_data["key"] if log_data else None) + message = message.format( + closer=closer, loglink=log_url, logkey=log_data["key"] if log_data else None + ) embed.description = message footer = self.bot.config["thread_close_footer"] @@ -764,9 +766,12 @@ async def find( if recipient is None and channel is not None: thread = self._find_from_channel(channel) if thread is None: - id, thread = next(((k, v) for k, v in self.cache.items() if v.channel == channel), (-1, None)) + id, thread = next( + ((k, v) for k, v in self.cache.items() if v.channel == channel), + (-1, None), + ) if thread is not None: - logger.debug('Found thread with tempered ID.') + logger.debug("Found thread with tempered ID.") await channel.edit(topic=f"User ID: {id}") return thread From 0bc17a88af49c8ab99e729484385b15c04367d90 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Sun, 21 Jul 2019 12:02:19 -0700 Subject: [PATCH 23/50] Misc --- CHANGELOG.md | 4 ++ bot.py | 126 +++++++++++++++++++++++------------- cogs/modmail.py | 38 ++++++++--- cogs/plugins.py | 86 +++++++++++++++---------- cogs/utility.py | 2 +- core/changelog.py | 6 +- core/clients.py | 8 ++- core/config.py | 49 ++++++++------ core/models.py | 3 +- core/paginator.py | 8 ++- core/thread.py | 159 +++++++++++++++++++++++----------------------- core/time.py | 6 +- 12 files changed, 297 insertions(+), 198 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e520bb1f0..bf4775ff87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `?snippet` is now the default command name instead of `?snippets` (`?snippets` is still usable). This is to make this consistent with `?alias`/`?aliases`. - `colorama` is no longer a necessity, this is due to some unsupported OS. - Changelog command can now take a version argument to jump straight to specified version. +- `?plugin enabled` results are now sorted alphabetically. +- `?plugin registry` results are now sorted alphabetically, helps user find plugins more easily. +- `?plugin registry page-number` plugin registry can specify a page number for quick access. ### Fixes @@ -44,6 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed helper functions `info()` and `error()` for formatting logging, it's formatted automatically now. - Bumped discord.py version to 1.2.3. - Use discord tasks for metadata loop. +- More debug based logging. # v3.0.3 diff --git a/bot.py b/bot.py index 5043c0681d..b6d9d39ebe 100644 --- a/bot.py +++ b/bot.py @@ -19,6 +19,7 @@ from aiohttp import ClientSession from emoji import UNICODE_EMOJI from motor.motor_asyncio import AsyncIOMotorClient +from pymongo.errors import ConfigurationError try: from colorama import init @@ -65,6 +66,8 @@ def __init__(self): super().__init__(command_prefix=None) # implemented in `get_prefix` self._session = None self._api = None + self.metadata_loop = None + self._connected = asyncio.Event() self.start_time = datetime.utcnow() @@ -77,13 +80,17 @@ def __init__(self): mongo_uri = self.config["mongo_uri"] if mongo_uri is None: - raise ValueError("A Mongo URI is necessary for the bot to function.") - - self.db = AsyncIOMotorClient(mongo_uri).modmail_bot - self.plugin_db = PluginDatabaseClient(self) + logger.critical("A Mongo URI is necessary for the bot to function.") + raise RuntimeError - self.metadata_loop = None + try: + self.db = AsyncIOMotorClient(mongo_uri).modmail_bot + except ConfigurationError as e: + logger.critical("Your MONGO_URI is copied wrong, try re-copying from the source again.") + logger.critical(str(e)) + sys.exit(0) + self.plugin_db = PluginDatabaseClient(self) self._load_extensions() @property @@ -134,7 +141,8 @@ def _configure_logging(self): logger.info("Logging level: %s", level_text) else: logger.info("Invalid logging level set.") - logger.warning("Using default logging level: INFO.") + logger.warning("Using default logging level: %s.", level_text) + logger.debug("Successfully configured logging.") @property def version(self) -> str: @@ -198,11 +206,21 @@ def run(self, *args, **kwargs): self.loop.run_until_complete(self.session.close()) logger.error(" - Shutting down bot - ") + @property + def owner_ids(self): + owner_ids = self.config["owners"] + if owner_ids is not None: + owner_ids = set(map(int, str(owner_ids).split(","))) + if self.owner_id is not None: + owner_ids.add(self.owner_id) + permissions = self.config["level_permissions"].get(PermissionLevel.OWNER.name, []) + for perm in permissions: + owner_ids.add(int(perm)) + return owner_ids + async def is_owner(self, user: discord.User) -> bool: - owners = self.config["owners"] - if owners is not None: - if user.id in set(map(int, str(owners).split(","))): - return True + if user.id in self.owner_ids: + return True return await super().is_owner(user) @property @@ -212,18 +230,18 @@ def log_channel(self) -> typing.Optional[discord.TextChannel]: channel = self.get_channel(int(channel_id)) if channel is not None: return channel + logger.debug('LOG_CHANNEL_ID was invalid, removed.') self.config.remove("log_channel_id") if self.main_category is not None: try: channel = self.main_category.channels[0] self.config["log_channel_id"] = channel.id - logger.debug("No log channel set, however, one was found. Setting...") + logger.warning("No log channel set, setting #%s to be the log channel.", channel.name) return channel except IndexError: pass - logger.info( - "No log channel set, set one with `%ssetup` or " - "`%sconfig set log_channel_id `.", + logger.warning( + "No log channel set, set one with `%ssetup` or `%sconfig set log_channel_id `.", self.prefix, self.prefix, ) @@ -250,7 +268,8 @@ def aliases(self) -> typing.Dict[str, str]: def token(self) -> str: token = self.config["token"] if token is None: - raise ValueError("TOKEN must be set, this is your bot token.") + logger.critical("TOKEN must be set, set this as bot token found on the Discord Dev Portal.") + sys.exit(0) return token @property @@ -260,7 +279,7 @@ def guild_id(self) -> typing.Optional[int]: try: return int(str(guild_id)) except ValueError: - raise ValueError("Invalid guild_id set.") + logger.critical("Invalid GUILD_ID set.") return None @property @@ -284,7 +303,7 @@ def modmail_guild(self) -> typing.Optional[discord.Guild]: if guild is not None: return guild self.config.remove("modmail_guild_id") - logger.error("Invalid modmail_guild_id set.") + logger.critical("Invalid MODMAIL_GUILD_ID set.") return self.guild @property @@ -302,10 +321,11 @@ def main_category(self) -> typing.Optional[discord.CategoryChannel]: if cat is not None: return cat self.config.remove("main_category_id") + logger.debug('MAIN_CATEGORY_ID was invalid, removed.') cat = discord.utils.get(self.modmail_guild.categories, name="Modmail") if cat is not None: self.config["main_category_id"] = cat.id - logger.debug("No main category set, however, one was found. Setting...") + logger.debug("No main category set explicitly, setting category \"Modmail\" as the main category.") return cat return None @@ -384,6 +404,7 @@ async def setup_indexes(self): ("key", "text"), ] ) + logger.debug('Successfully set up database indexes.') async def on_ready(self): """Bot startup, sets uptime.""" @@ -391,14 +412,18 @@ async def on_ready(self): # Wait until config cache is populated with stuff from db and on_connect ran await self.wait_for_connected() + if self.guild is None: + logger.debug('Logging out due to invalid GUILD_ID.') + return await self.logout() + logger.line() logger.info("Client ready.") logger.line() logger.info("Logged in as: %s", self.user) logger.info("User ID: %s", self.user.id) logger.info("Prefix: %s", self.prefix) - logger.info("Guild Name: %s", self.guild.name if self.guild else "Invalid") - logger.info("Guild ID: %s", self.guild.id if self.guild else "Invalid") + logger.info("Guild Name: %s", self.guild.name) + logger.info("Guild ID: %s", self.guild.id) logger.line() await self.threads.populate_cache() @@ -406,6 +431,7 @@ async def on_ready(self): # closures closures = self.config["closures"] logger.info("There are %d thread(s) pending to be closed.", len(closures)) + logger.line() for recipient_id, items in tuple(closures.items()): after = ( @@ -418,10 +444,13 @@ async def on_ready(self): if not thread: # If the channel is deleted + logger.debug('Failed to close thread for recipient %s.', recipient_id) self.config["closures"].pop(recipient_id) await self.config.update() continue + logger.debug('Closing thread for recipient %s.', recipient_id) + await thread.close( closer=self.get_user(items["closer_id"]), after=after, @@ -431,8 +460,6 @@ async def on_ready(self): auto_close=items.get("auto_close", False), ) - logger.line() - self.metadata_loop = tasks.Loop( self.post_metadata, seconds=0, @@ -552,6 +579,7 @@ async def _process_blocked(self, message: discord.Message) -> bool: reaction = blocked_emoji changed = False delta = human_timedelta(min_account_age) + logger.debug('Blocked due to account age, user %s.', message.author.name) if str(message.author.id) not in self.blocked_users: new_reason = ( @@ -565,7 +593,7 @@ async def _process_blocked(self, message: discord.Message) -> bool: embed=discord.Embed( title="Message not sent!", description=f"Your must wait for {delta} " - f"before you can contact {self.user.mention}.", + f"before you can contact me.", color=discord.Color.red(), ) ) @@ -575,6 +603,7 @@ async def _process_blocked(self, message: discord.Message) -> bool: reaction = blocked_emoji changed = False delta = human_timedelta(min_guild_age) + logger.debug('Blocked due to guild age, user %s.', message.author.name) if str(message.author.id) not in self.blocked_users: new_reason = ( @@ -588,22 +617,24 @@ async def _process_blocked(self, message: discord.Message) -> bool: embed=discord.Embed( title="Message not sent!", description=f"Your must wait for {delta} " - f"before you can contact {self.user.mention}.", + f"before you can contact me.", color=discord.Color.red(), ) ) elif str(message.author.id) in self.blocked_users: - reaction = blocked_emoji if reason.startswith("System Message: New Account.") or reason.startswith( "System Message: Recently Joined." ): # Met the age limit already, otherwise it would've been caught by the previous if's reaction = sent_emoji + logger.debug('No longer internally blocked, user %s.', message.author.name) self.blocked_users.pop(str(message.author.id)) else: + reaction = blocked_emoji end_time = re.search(r"%(.+?)%$", reason) if end_time is not None: + logger.debug('No longer blocked, user %s.', message.author.name) after = ( datetime.fromisoformat(end_time.group(1)) - now ).total_seconds() @@ -611,6 +642,8 @@ async def _process_blocked(self, message: discord.Message) -> bool: # No longer blocked reaction = sent_emoji self.blocked_users.pop(str(message.author.id)) + else: + logger.debug('User blocked, user %s.', message.author.name) else: reaction = sent_emoji @@ -619,7 +652,7 @@ async def _process_blocked(self, message: discord.Message) -> bool: try: await message.add_reaction(reaction) except (discord.HTTPException, discord.InvalidArgument): - pass + logger.warning('Failed to add reaction %s.', reaction, exc_info=True) return str(message.author.id) in self.blocked_users async def process_modmail(self, message: discord.Message) -> None: @@ -805,20 +838,17 @@ async def on_raw_reaction_add(self, payload): if isinstance(channel, discord.DMChannel): if str(reaction) == str(close_emoji): # closing thread + try: + recipient_thread_close = strtobool(self.config["recipient_thread_close"]) + except ValueError: + recipient_thread_close = self.config.remove("recipient_thread_close") + if not recipient_thread_close: + return thread = await self.threads.find(recipient=user) ts = message.embeds[0].timestamp if message.embeds else None if thread and ts == thread.channel.created_at: # the reacted message is the corresponding thread creation embed - try: - recipient_thread_close = strtobool( - self.config["recipient_thread_close"] - ) - except ValueError: - recipient_thread_close = self.config.remove( - "recipient_thread_close" - ) - if recipient_thread_close: - await thread.close(closer=user) + await thread.close(closer=user) else: if not message.embeds: return @@ -845,6 +875,7 @@ async def on_guild_channel_delete(self, channel): if isinstance(channel, discord.CategoryChannel): if self.main_category.id == channel.id: + logger.debug('Main category was deleted.') self.config.remove("main_category_id") await self.config.update() return @@ -853,17 +884,19 @@ async def on_guild_channel_delete(self, channel): return if self.log_channel is None or self.log_channel.id == channel.id: + logger.info('Log channel deleted.') self.config.remove("log_channel_id") await self.config.update() return thread = await self.threads.find(channel=channel) - if not thread: - return - - await thread.close(closer=mod, silent=True, delete_channel=False) + if thread: + logger.debug('Manually closed channel %s.', channel.name) + 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: embed = discord.Embed( @@ -873,6 +906,8 @@ async def on_member_remove(self, member): 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( @@ -980,11 +1015,14 @@ async def validate_database_connection(self): if "OperationFailure" in message: logger.critical( - "This is due to having invalid credentials in your MONGO_URI." + "This is due to having invalid credentials in your MONGO_URI. " + "Remember you need to substitute `` with your actual password." ) logger.critical( - "Recheck the username/password and make sure to url encode them. " - "https://www.urlencoder.io/" + "Be sure to URL encode your username and password (not the entire URL!!), " + "https://www.urlencoder.io/, if this issue persists, try changing your username and password " + "to only include alphanumeric characters, no symbols." + "" ) raise else: @@ -1024,7 +1062,7 @@ async def after_post_metadata(self): if __name__ == "__main__": try: import uvloop - + logger.debug('Setting up with uvloop.') uvloop.install() except ImportError: pass diff --git a/cogs/modmail.py b/cogs/modmail.py index dc4e92e87b..b8ebaf1adf 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -1,4 +1,5 @@ import asyncio +import logging from datetime import datetime from typing import Optional, Union from types import SimpleNamespace as param @@ -16,6 +17,8 @@ from core.time import UserFriendlyTime, human_timedelta from core.utils import format_preview, User +logger = logging.getLogger("Modmail") + class Modmail(commands.Cog): """Commands directly related to Modmail functionality.""" @@ -35,6 +38,7 @@ async def setup(self, ctx): """ if self.bot.main_category is not None: + logger.debug("Can't re-setup server, main_category is found.") return await ctx.send(f"{self.bot.modmail_guild} is already set up.") if self.bot.modmail_guild is None: @@ -58,9 +62,8 @@ async def setup(self, ctx): embed = discord.Embed( title="Friendly Reminder", description=f"You may use the `{self.bot.prefix}config set log_channel_id " - "` command to set up a custom log channel" - ", then you can delete the default " - f"{log_channel.mention} channel.", + "` command to set up a custom log channel, then you can delete this default " + f"{log_channel.mention} log channel.", color=self.bot.main_color, ) @@ -92,7 +95,8 @@ async def setup(self, ctx): and not self.bot.config["level_permissions"] ): await self.bot.update_perms(PermissionLevel.REGULAR, -1) - await self.bot.update_perms(PermissionLevel.OWNER, ctx.author.id) + for owner_ids in self.bot.owner_ids: + await self.bot.update_perms(PermissionLevel.OWNER, owner_ids) @commands.group(aliases=["snippets"], invoke_without_command=True) @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -504,7 +508,11 @@ async def unsubscribe( async def nsfw(self, ctx): """Flags a Modmail thread as NSFW (not safe for work).""" await ctx.channel.edit(nsfw=True) - await ctx.message.add_reaction("✅") + sent_emoji, _ = await self.bot.retrieve_emoji() + try: + await ctx.message.add_reaction(sent_emoji) + except (discord.HTTPException, discord.InvalidArgument): + pass @commands.command() @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -512,7 +520,11 @@ async def nsfw(self, ctx): async def sfw(self, ctx): """Flags a Modmail thread as SFW (safe for work).""" await ctx.channel.edit(nsfw=False) - await ctx.message.add_reaction("✅") + sent_emoji, _ = await self.bot.retrieve_emoji() + try: + await ctx.message.add_reaction(sent_emoji) + except (discord.HTTPException, discord.InvalidArgument): + pass @commands.command() @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -662,7 +674,7 @@ async def logs_search(self, ctx, limit: Optional[int] = None, *, query): if not embeds: embed = discord.Embed( color=discord.Color.red(), - description="No log entries have been found for that query", + description="No log entries have been found for that query.", ) return await ctx.send(embed=embed) @@ -764,7 +776,11 @@ async def edit(self, ctx, message_id: Optional[int] = None, *, message: str): self.bot.api.edit_message(linked_message_id, message), ) - await ctx.message.add_reaction("✅") + sent_emoji, _ = await self.bot.retrieve_emoji() + try: + await ctx.message.add_reaction(sent_emoji) + except (discord.HTTPException, discord.InvalidArgument): + pass @commands.command() @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -1093,7 +1109,11 @@ async def delete(self, ctx, message_id: Optional[int] = None): ) await thread.delete_message(linked_message_id) - await ctx.message.add_reaction("✅") + sent_emoji, _ = await self.bot.retrieve_emoji() + try: + await ctx.message.add_reaction(sent_emoji) + except (discord.HTTPException, discord.InvalidArgument): + pass def setup(bot): diff --git a/cogs/plugins.py b/cogs/plugins.py index 7f2dcec871..5f58c697f7 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -3,12 +3,12 @@ import json import logging import os -import random import shutil import site import stat import subprocess import sys +import typing from difflib import get_close_matches import discord @@ -57,7 +57,7 @@ def parse_plugin(name): # default branch = master try: # when names are formatted with inline code - result = name.strip("`").split("/") + result = name.split("/") result[2] = "/".join(result[2:]) if "@" in result[2]: # branch is specified @@ -83,13 +83,13 @@ async def download_initial_plugins(self): await self.download_plugin_repo(username, repo, branch) except DownloadError as exc: msg = f"{username}/{repo}@{branch} - {exc}" - logger.error(msg) + logger.error(msg, exc_info=True) else: try: await self.load_plugin(username, repo, name, branch) except DownloadError as exc: msg = f"{username}/{repo}@{branch}[{name}] - {exc}" - logger.error(msg) + logger.error(msg, exc_info=True) async def download_plugin_repo(self, username, repo, branch): try: @@ -136,7 +136,7 @@ async def load_plugin(self, username, repo, plugin_name, branch): # so there's no terminal output unless there's an error except subprocess.CalledProcessError as exc: err = exc.stderr.decode("utf8").strip() - + logger.error('Error.', exc_info=True) if err: msg = f"Requirements Download Error: {username}/{repo}@{branch}[{plugin_name}]" logger.error(msg) @@ -154,7 +154,7 @@ async def load_plugin(self, username, repo, plugin_name, branch): self.bot.load_extension(ext) except commands.ExtensionError as exc: msg = f"Plugin Load Failure: {username}/{repo}@{branch}[{plugin_name}]" - logger.error(msg) + logger.error(msg, exc_info=True) raise DownloadError("Invalid plugin") from exc else: msg = f"Loaded Plugin: {username}/{repo}@{branch}[{plugin_name}]" @@ -174,9 +174,8 @@ async def plugin_add(self, ctx, *, plugin_name: str): if plugin_name in self.registry: details = self.registry[plugin_name] - plugin_name = ( - details["repository"] + "/" + plugin_name + "@" + details["branch"] - ) + plugin_name = details["repository"] + "/" + plugin_name + "@" + details["branch"] + required_version = details["bot_version"] if parse_version(self.bot.version) < parse_version(required_version): @@ -212,7 +211,9 @@ async def plugin_add(self, ctx, *, plugin_name: str): try: await self.download_plugin_repo(username, repo, branch) - except DownloadError as exc: + except Exception as exc: + if not isinstance(exc, DownloadError): + logger.error('Unknown error when adding a plugin:', exc_info=True) embed = discord.Embed( description=f"Unable to fetch this plugin from Github: `{exc}`.", color=self.bot.main_color, @@ -224,6 +225,8 @@ async def plugin_add(self, ctx, *, plugin_name: str): try: await self.load_plugin(username, repo, name, branch) except Exception as exc: + if not isinstance(exc, DownloadError): + logger.error('Unknown error when adding a plugin:', exc_info=True) embed = discord.Embed( description=f"Unable to load this plugin: `{exc}`.", color=self.bot.main_color, @@ -238,13 +241,15 @@ async def plugin_add(self, ctx, *, plugin_name: str): embed = discord.Embed( description="The plugin is installed.\n" - "*Please note: Any plugin that you install is at your **own risk***", + "*Friendly reminder, plugins have absolute control over your bot. " + "Please only install plugins from developers you trust.*", color=self.bot.main_color, ) await ctx.send(embed=embed) else: embed = discord.Embed( - description="Invalid plugin name format: use username/repo/plugin.", + description="Invalid plugin name format: use plugin-name or " + "username/repo/plugin or username/repo/plugin@branch.", color=self.bot.main_color, ) await ctx.send(embed=embed) @@ -261,19 +266,17 @@ async def plugin_remove(self, ctx, *, plugin_name: str): ) if plugin_name in self.bot.config["plugins"]: + username, repo, name, branch = self.parse_plugin(plugin_name) try: - username, repo, name, branch = self.parse_plugin(plugin_name) - self.bot.unload_extension( f"plugins.{username}-{repo}-{branch}.{name}.{name}" ) - except Exception: - pass + except commands.ExtensionNotLoaded: + logger.error('Plugin was never loaded.') self.bot.config["plugins"].remove(plugin_name) try: - # BUG: Local variables 'username', 'branch' and 'repo' might be referenced before assignment if not any( i.startswith(f"{username}/{repo}") for i in self.bot.config["plugins"] @@ -289,10 +292,9 @@ def onerror(func, path, exc_info): # pylint: disable=W0613 f"plugins/{username}-{repo}-{branch}", onerror=onerror ) except Exception as exc: - logger.error(str(exc)) + logger.error('Failed to remove plugin %s.', plugin_name, exc_info=True) self.bot.config["plugins"].append(plugin_name) - logger.error(exc) - raise exc + return await self.bot.config.update() @@ -314,9 +316,7 @@ async def plugin_update(self, ctx, *, plugin_name: str): if plugin_name in self.registry: details = self.registry[plugin_name] - plugin_name = ( - details["repository"] + "/" + plugin_name + "@" + details["branch"] - ) + plugin_name = details["repository"] + "/" + plugin_name + "@" + details["branch"] if plugin_name not in self.bot.config["plugins"]: embed = discord.Embed( @@ -328,7 +328,8 @@ async def plugin_update(self, ctx, *, plugin_name: str): username, repo, name, branch = self.parse_plugin(plugin_name) try: - cmd = f"cd plugins/{username}-{repo}-{branch} && git reset --hard origin/{branch} && git fetch --all && git pull" + cmd = f"cd plugins/{username}-{repo}-{branch} && " \ + f"git reset --hard origin/{branch} && git fetch --all && git pull" cmd = await self.bot.loop.run_in_executor( None, self._asubprocess_run, cmd ) @@ -339,6 +340,7 @@ async def plugin_update(self, ctx, *, plugin_name: str): description=f"An error occurred while updating: {err}.", color=self.bot.main_color, ) + logger.error('An error occurred while updating plugin:', exc_info=True) await ctx.send(embed=embed) else: @@ -357,11 +359,12 @@ async def plugin_update(self, ctx, *, plugin_name: str): try: await self.load_plugin(username, repo, name, branch) except DownloadError as exc: - em = discord.Embed( + embed = discord.Embed( description=f"Unable to start the plugin: `{exc}`.", color=self.bot.main_color, ) - await ctx.send(embed=em) + logger.error('An error occurred while updating plugin:', exc_info=True) + await ctx.send(embed=embed) @plugin.command(name="enabled", aliases=["installed"]) @checks.has_permissions(PermissionLevel.OWNER) @@ -369,7 +372,7 @@ async def plugin_enabled(self, ctx): """Shows a list of currently enabled plugins.""" if self.bot.config["plugins"]: - msg = "```\n" + "\n".join(self.bot.config["plugins"]) + "\n```" + msg = "```\n" + "\n".join(sorted(self.bot.config["plugins"])) + "\n```" embed = discord.Embed(description=msg, color=self.bot.main_color) await ctx.send(embed=embed) else: @@ -382,19 +385,32 @@ async def plugin_enabled(self, ctx): invoke_without_command=True, name="registry", aliases=["list", "info"] ) @checks.has_permissions(PermissionLevel.OWNER) - async def plugin_registry(self, ctx, *, plugin_name: str = None): - """Shows a list of all approved plugins.""" + async def plugin_registry(self, ctx, *, plugin_name: typing.Union[int, str] = None): + """ + Shows a list of all approved plugins. + + Usage: + `{prefix}plugin registry` Details about all plugins. + `{prefix}plugin registry plugin-name` Details about the indicated plugin. + `{prefix}plugin registry page-number` Jump to a page in the registry. + """ await self.populate_registry() embeds = [] - registry = list(self.registry.items()) - random.shuffle(registry) + registry = sorted(self.registry.items(), key=lambda elem: elem[0]) - index = next((i for i, (n, _) in enumerate(registry) if plugin_name == n), None) + if isinstance(plugin_name, int): + index = plugin_name - 1 + if index < 0: + index = 0 + if index >= len(registry): + index = len(registry) - 1 + else: + index = next((i for i, (n, _) in enumerate(registry) if plugin_name == n), 0) - if index is None: + if not index and plugin_name is not None: embed = discord.Embed( color=discord.Color.red(), description=f'Could not find a plugin with name "{plugin_name}" within the registry.', @@ -440,13 +456,13 @@ async def plugin_registry(self, ctx, *, plugin_name: str = None): await paginator.run() @plugin_registry.command(name="compact") + @checks.has_permissions(PermissionLevel.OWNER) async def plugin_registry_compact(self, ctx): """Shows a compact view of all plugins within the registry.""" await self.populate_registry() - registry = list(self.registry.items()) - registry.sort(key=lambda elem: elem[0]) + registry = sorted(self.registry.items(), key=lambda elem: elem[0]) pages = [""] diff --git a/cogs/utility.py b/cogs/utility.py index ee7b7c9493..9f07d68e57 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -555,7 +555,7 @@ async def set_presence( if activity_identifier is None: if activity_message is not None: raise ValueError( - "activity_message must be None " "if activity_identifier is None." + "activity_message must be None if activity_identifier is None." ) activity_identifier = self.bot.config["activity_type"] activity_by_key = False diff --git a/core/changelog.py b/core/changelog.py index e3f9424546..ec12a89838 100644 --- a/core/changelog.py +++ b/core/changelog.py @@ -1,9 +1,12 @@ +import logging import re from collections import defaultdict from typing import List, Union from discord import Embed, Color +logger = logging.getLogger("Modmail") + class Version: """ @@ -117,11 +120,12 @@ class Changelog: "https://raw.githubusercontent.com/kyb3r/modmail/master/CHANGELOG.md" ) CHANGELOG_URL = "https://github.com/kyb3r/modmail/blob/master/CHANGELOG.md" - VERSION_REGEX = re.compile(r"# (v\d+\.\d+\.\d+)([\S\s]*?(?=# v|$))") + VERSION_REGEX = re.compile(r"# ([vV]\d+\.\d+(?:\.\d+)?)(.*?(?=# (?:[vV]\d+\.\d+(?:\.\d+)?)|$))", flags=re.DOTALL) def __init__(self, bot, text: str): self.bot = bot self.text = text + logger.debug('Fetching changelog from GitHub.') self.versions = [Version(bot, *m) for m in self.VERSION_REGEX.findall(text)] @property diff --git a/core/clients.py b/core/clients.py index 88ffbd8743..4b8b578cc4 100644 --- a/core/clients.py +++ b/core/clients.py @@ -87,15 +87,18 @@ def logs(self): async def get_user_logs(self, user_id: Union[str, int]) -> list: query = {"recipient.id": str(user_id), "guild_id": str(self.bot.guild_id)} - projection = {"messages": {"$slice": 5}} + logger.debug('Retrieving user %s logs.', user_id) + return await self.logs.find(query, projection).to_list(None) async def get_log(self, channel_id: Union[str, int]) -> dict: + logger.debug('Retrieving channel %s logs.', channel_id) return await self.logs.find_one({"channel_id": str(channel_id)}) async def get_log_link(self, channel_id: Union[str, int]) -> str: doc = await self.get_log(channel_id) + logger.debug('Retrieving log link for channel %s.', channel_id) return f"{self.bot.config['log_url'].strip('/')}{self.bot.config['log_url_prefix']}/{doc['key']}" async def create_log_entry( @@ -131,12 +134,13 @@ async def create_log_entry( "messages": [], } ) - + logger.debug('Created a log entry, key %s.', key) return f"{self.bot.config['log_url'].strip('/')}{self.bot.config['log_url_prefix']}/{key}" async def get_config(self) -> dict: conf = await self.db.config.find_one({"bot_id": self.bot.user.id}) if conf is None: + logger.debug('Creating a new config entry for bot %s.', self.bot.user.id) await self.db.config.insert_one({"bot_id": self.bot.user.id}) return {"bot_id": self.bot.user.id} return conf diff --git a/core/config.py b/core/config.py index 5f7eda1ea1..c54165dc93 100644 --- a/core/config.py +++ b/core/config.py @@ -115,16 +115,13 @@ class ConfigManager: def __init__(self, bot): self.bot = bot + self.api = self.bot.api self._cache = {} self.ready_event = asyncio.Event() def __repr__(self): return repr(self._cache) - @property - def api(self): - return self.bot.api - def populate_cache(self) -> dict: data = deepcopy(self.defaults) @@ -132,17 +129,21 @@ def populate_cache(self) -> dict: data.update( {k.lower(): v for k, v in os.environ.items() if k.lower() in self.all_keys} ) - - if os.path.exists("config.json"): - with open("config.json") as f: + configjson = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") + if os.path.exists(configjson): + logger.debug('Loading envs from config.json.') + with open(configjson, 'r') as f: # Config json should override env vars - data.update( - { - k.lower(): v - for k, v in json.load(f).items() - if k.lower() in self.all_keys - } - ) + try: + data.update( + { + k.lower(): v + for k, v in json.load(f).items() + if k.lower() in self.all_keys + } + ) + except json.JSONDecodeError: + logger.critical('Failed to load config.json env values.', exc_info=True) self._cache = data return self._cache @@ -212,7 +213,7 @@ async def refresh(self) -> dict: self._cache[k] = v if not self.ready_event.is_set(): self.ready_event.set() - logger.info("Config ready.") + logger.info("Successfully fetched configurations from database.") return self._cache async def wait_until_ready(self) -> None: @@ -238,12 +239,10 @@ def get(self, key: str, default: typing.Any = Default) -> typing.Any: if key not in self.all_keys: raise InvalidConfigError(f'Configuration "{key}" is invalid.') if key not in self._cache: - self._cache[key] = deepcopy(self.defaults[key]) if default is Default: - return self._cache[key] - return default - if self._cache[key] == self.defaults[key] and default is not Default: - return default + self._cache[key] = deepcopy(self.defaults[key]) + if default is not Default and self._cache[key] == self.defaults[key]: + self._cache[key] = default return self._cache[key] def set(self, key: str, item: typing.Any) -> None: @@ -279,4 +278,12 @@ def filter_default( cls, data: typing.Dict[str, typing.Any] ) -> typing.Dict[str, typing.Any]: # TODO: use .get to prevent errors - return {k.lower(): v for k, v in data.items() if v != cls.defaults[k.lower()]} + filtered = {} + for k, v in data.items(): + default = cls.defaults.get(k.lower(), Default) + if default is Default: + logger.error('Unexpected configuration detected: %s.', k) + continue + if v != default: + filtered[k.lower()] = v + return filtered diff --git a/core/models.py b/core/models.py index 34c79da1c5..58ef344115 100644 --- a/core/models.py +++ b/core/models.py @@ -46,7 +46,8 @@ def _error_(*msgs): return f'{Fore.RED}{" ".join(msgs)}{Style.RESET_ALL}' def debug(self, msg, *args, **kwargs): - return super().debug(self._debug_(msg), *args, **kwargs) + if self.isEnabledFor(logging.DEBUG): + self._log(logging.DEBUG, self._debug_(msg), args, **kwargs) def info(self, msg, *args, **kwargs): if self.isEnabledFor(logging.INFO): diff --git a/core/paginator.py b/core/paginator.py index 58fdcaea20..2fb72da8ff 100644 --- a/core/paginator.py +++ b/core/paginator.py @@ -194,8 +194,6 @@ async def close(self, delete: bool = True) -> typing.Optional[Message]: """ self.running = False - self.ctx.bot.loop.create_task(self.ctx.message.add_reaction("✅")) - if delete: return await self.base.delete() @@ -204,6 +202,12 @@ async def close(self, delete: bool = True) -> typing.Optional[Message]: except HTTPException: pass + sent_emoji, _ = await self.ctx.bot.retrieve_emoji() + try: + await self.ctx.message.add_reaction(sent_emoji) + except (HTTPException, InvalidArgument): + pass + async def first_page(self) -> None: """ Go to the first page. diff --git a/core/thread.py b/core/thread.py index 04e506f7ae..73248d915f 100644 --- a/core/thread.py +++ b/core/thread.py @@ -72,6 +72,7 @@ def ready(self) -> bool: def ready(self, flag: bool): if flag: self._ready_event.set() + self.bot.dispatch("thread_ready", self) else: self._ready_event.clear() @@ -102,6 +103,7 @@ async def setup(self, *, creator=None, category=None): reason="Creating a thread channel.", ) except discord.HTTPException as e: # Failed to create due to 50 channel limit. + logger.critical('An error occurred while creating a thread.', exc_info=True) self.manager.cache.pop(self.id) embed = discord.Embed(color=discord.Color.red()) @@ -122,7 +124,8 @@ async def setup(self, *, creator=None, category=None): ) log_count = sum(1 for log in log_data if not log["open"]) - except Exception: # Something went wrong with database? + except Exception: + logger.error('An error occurred while posting logs to the database.', exc_info=True) log_url = log_count = None # ensure core functionality still works @@ -132,18 +135,17 @@ async def setup(self, *, creator=None, category=None): mention = self.bot.config["mention"] async def send_genesis_message(): - info_embed = self.manager.format_info_embed( + info_embed = self._format_info_embed( recipient, log_url, log_count, discord.Color.green() ) try: msg = await channel.send(mention, embed=info_embed) self.bot.loop.create_task(msg.pin()) self.genesis_message = msg - except Exception: - pass + except Exception as e: + logger.error(str(e)) finally: self.ready = True - self.bot.dispatch("thread_ready", self) await channel.edit(topic=f"User ID: {recipient.id}") self.bot.loop.create_task(send_genesis_message()) @@ -180,6 +182,75 @@ async def send_genesis_message(): close_emoji = await self.bot.convert_emoji(close_emoji) await msg.add_reaction(close_emoji) + def _format_info_embed(self, user, log_url, log_count, color): + """Get information about a member of a server + supports users from the guild or not.""" + member = self.bot.guild.get_member(user.id) + time = datetime.utcnow() + + # key = log_url.split('/')[-1] + + role_names = "" + if member is None: + sep_server = self.bot.using_multiple_server_setup + separator = ", " if sep_server else " " + + roles = [] + + for role in sorted(member.roles, key=lambda r: r.position): + if role.name == "@everyone": + continue + + fmt = role.name if sep_server else role.mention + roles.append(fmt) + + if len(separator.join(roles)) > 1024: + roles.append("...") + while len(separator.join(roles)) > 1024: + roles.pop(-2) + break + + role_names = separator.join(roles) + + created = str((time - user.created_at).days) + embed = discord.Embed(color=color, description=f"{user.mention} was created {days(created)}", timestamp=time) + + # if not role_names: + # embed.add_field(name='Mention', value=user.mention) + # embed.add_field(name='Registered', value=created + days(created)) + + footer = "User ID: " + str(user.id) + embed.set_author(name=str(user), icon_url=user.avatar_url, url=log_url) + # embed.set_thumbnail(url=avi) + + if member is None: + joined = str((time - member.joined_at).days) + # embed.add_field(name='Joined', value=joined + days(joined)) + embed.description += f", joined {days(joined)}" + + if member.nick: + embed.add_field(name="Nickname", value=member.nick, inline=True) + if role_names: + embed.add_field(name="Roles", value=role_names, inline=True) + embed.set_footer(text=footer) + else: + embed.set_footer(text=f"{footer} • (not in main server)") + + if log_count is not None: + # embed.add_field(name='Past logs', value=f'{log_count}') + thread = "thread" if log_count == 1 else "threads" + embed.description += f" with **{log_count or 'no'}** past {thread}." + else: + embed.description += "." + + mutual_guilds = [g for g in self.bot.guilds if user in g.members] + if member is None or len(mutual_guilds) > 1: + embed.add_field( + name="Mutual Server(s)", value=", ".join(g.name for g in mutual_guilds) + ) + + return embed + def _close_after(self, closer, silent, delete_channel, message): return self.bot.loop.create_task( self._close(closer, silent, delete_channel, message, True) @@ -203,7 +274,6 @@ async def close( if after > 0: # TODO: Add somewhere to clean up broken closures # (when channel is already deleted) - await self.bot.config.update() now = datetime.utcnow() items = { # 'initiation_time': now.isoformat(), @@ -261,7 +331,7 @@ async def _close( }, ) - if log_data is not None and isinstance(log_data, dict): + if isinstance(log_data, dict): prefix = self.bot.config["log_url_prefix"] if prefix == "NONE": prefix = "" @@ -475,7 +545,7 @@ async def note(self, message: discord.Message) -> None: async def reply(self, message: discord.Message, anonymous: bool = False) -> None: if not message.content and not message.attachments: raise MissingRequiredArgument(param(name="msg")) - if all(not g.get_member(self.id) for g in self.bot.guilds): + if not any(g.get_member(self.id) for g in self.bot.guilds): return await message.channel.send( embed=discord.Embed( color=discord.Color.red(), @@ -853,77 +923,8 @@ def format_channel_name(self, author): ) new_name += f"-{author.discriminator}" + counter = 1 while new_name in [c.name for c in self.bot.modmail_guild.text_channels]: - new_name += "-x" # two channels with same name + new_name += f'-{counter}' # two channels with same name return new_name - - def format_info_embed(self, user, log_url, log_count, color): - """Get information about a member of a server - supports users from the guild or not.""" - member = self.bot.guild.get_member(user.id) - time = datetime.utcnow() - - # key = log_url.split('/')[-1] - - role_names = "" - if member: - sep_server = self.bot.using_multiple_server_setup - separator = ", " if sep_server else " " - - roles = [] - - for role in sorted(member.roles, key=lambda r: r.position): - if role.name == "@everyone": - continue - - fmt = role.name if sep_server else role.mention - roles.append(fmt) - - if len(separator.join(roles)) > 1024: - roles.append("...") - while len(separator.join(roles)) > 1024: - roles.pop(-2) - break - - role_names = separator.join(roles) - - embed = discord.Embed(color=color, description=user.mention, timestamp=time) - - created = str((time - user.created_at).days) - # if not role_names: - # embed.add_field(name='Mention', value=user.mention) - # embed.add_field(name='Registered', value=created + days(created)) - embed.description += f" was created {days(created)}" - - footer = "User ID: " + str(user.id) - embed.set_footer(text=footer) - embed.set_author(name=str(user), icon_url=user.avatar_url, url=log_url) - # embed.set_thumbnail(url=avi) - - if member: - joined = str((time - member.joined_at).days) - # embed.add_field(name='Joined', value=joined + days(joined)) - embed.description += f", joined {days(joined)}" - - if member.nick: - embed.add_field(name="Nickname", value=member.nick, inline=True) - if role_names: - embed.add_field(name="Roles", value=role_names, inline=True) - else: - embed.set_footer(text=f"{footer} • (not in main server)") - - if log_count: - # embed.add_field(name='Past logs', value=f'{log_count}') - thread = "thread" if log_count == 1 else "threads" - embed.description += f" with **{log_count}** past {thread}." - else: - embed.description += "." - - mutual_guilds = [g for g in self.bot.guilds if user in g.members] - if user not in self.bot.guild.members or len(mutual_guilds) > 1: - embed.add_field( - name="Mutual Servers", value=", ".join(g.name for g in mutual_guilds) - ) - - return embed diff --git a/core/time.py b/core/time.py index da9bdafcb4..5e8049567a 100644 --- a/core/time.py +++ b/core/time.py @@ -32,7 +32,7 @@ class ShortTime: def __init__(self, argument): match = self.compiled.fullmatch(argument) if match is None or not match.group(0): - raise BadArgument("invalid time provided") + raise BadArgument("Invalid time provided.") data = {k: int(v) for k, v in match.groupdict(default="0").items()} now = datetime.utcnow() @@ -52,7 +52,7 @@ def __init__(self, argument): now = datetime.utcnow() dt, status = self.calendar.parseDT(argument, sourceTime=now) if not status.hasDateOrTime: - raise BadArgument('invalid time provided, try e.g. "tomorrow" or "3 days"') + raise BadArgument('Invalid time provided, try e.g. "tomorrow" or "3 days".') if not status.hasTime: # replace it with the current time @@ -83,7 +83,7 @@ def __init__(self, argument): super().__init__(argument) if self._past: - raise BadArgument("this time is in the past") + raise BadArgument("The time is in the past.") class UserFriendlyTime(Converter): From 8f7d020021c7217a0f11deb11936bd2986d68fa0 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Sun, 21 Jul 2019 12:15:41 -0700 Subject: [PATCH 24/50] Black/lint --- bot.py | 64 ++++++++++++++++++++++++++++++----------------- cogs/plugins.py | 40 +++++++++++++++++++---------- cogs/utility.py | 2 ++ core/changelog.py | 7 ++++-- core/clients.py | 10 ++++---- core/config.py | 14 +++++++---- core/thread.py | 18 ++++++++----- 7 files changed, 101 insertions(+), 54 deletions(-) diff --git a/bot.py b/bot.py index b6d9d39ebe..132ae4c913 100644 --- a/bot.py +++ b/bot.py @@ -86,7 +86,9 @@ def __init__(self): try: self.db = AsyncIOMotorClient(mongo_uri).modmail_bot except ConfigurationError as e: - logger.critical("Your MONGO_URI is copied wrong, try re-copying from the source again.") + logger.critical( + "Your MONGO_URI is copied wrong, try re-copying from the source again." + ) logger.critical(str(e)) sys.exit(0) @@ -213,7 +215,9 @@ def owner_ids(self): owner_ids = set(map(int, str(owner_ids).split(","))) if self.owner_id is not None: owner_ids.add(self.owner_id) - permissions = self.config["level_permissions"].get(PermissionLevel.OWNER.name, []) + permissions = self.config["level_permissions"].get( + PermissionLevel.OWNER.name, [] + ) for perm in permissions: owner_ids.add(int(perm)) return owner_ids @@ -230,13 +234,16 @@ def log_channel(self) -> typing.Optional[discord.TextChannel]: channel = self.get_channel(int(channel_id)) if channel is not None: return channel - logger.debug('LOG_CHANNEL_ID was invalid, removed.') + logger.debug("LOG_CHANNEL_ID was invalid, removed.") self.config.remove("log_channel_id") if self.main_category is not None: try: channel = self.main_category.channels[0] self.config["log_channel_id"] = channel.id - logger.warning("No log channel set, setting #%s to be the log channel.", channel.name) + logger.warning( + "No log channel set, setting #%s to be the log channel.", + channel.name, + ) return channel except IndexError: pass @@ -268,7 +275,9 @@ def aliases(self) -> typing.Dict[str, str]: def token(self) -> str: token = self.config["token"] if token is None: - logger.critical("TOKEN must be set, set this as bot token found on the Discord Dev Portal.") + logger.critical( + "TOKEN must be set, set this as bot token found on the Discord Dev Portal." + ) sys.exit(0) return token @@ -321,11 +330,13 @@ def main_category(self) -> typing.Optional[discord.CategoryChannel]: if cat is not None: return cat self.config.remove("main_category_id") - logger.debug('MAIN_CATEGORY_ID was invalid, removed.') + logger.debug("MAIN_CATEGORY_ID was invalid, removed.") cat = discord.utils.get(self.modmail_guild.categories, name="Modmail") if cat is not None: self.config["main_category_id"] = cat.id - logger.debug("No main category set explicitly, setting category \"Modmail\" as the main category.") + logger.debug( + 'No main category set explicitly, setting category "Modmail" as the main category.' + ) return cat return None @@ -404,7 +415,7 @@ async def setup_indexes(self): ("key", "text"), ] ) - logger.debug('Successfully set up database indexes.') + logger.debug("Successfully set up database indexes.") async def on_ready(self): """Bot startup, sets uptime.""" @@ -413,7 +424,7 @@ async def on_ready(self): await self.wait_for_connected() if self.guild is None: - logger.debug('Logging out due to invalid GUILD_ID.') + logger.debug("Logging out due to invalid GUILD_ID.") return await self.logout() logger.line() @@ -444,12 +455,12 @@ async def on_ready(self): if not thread: # If the channel is deleted - logger.debug('Failed to close thread for recipient %s.', recipient_id) + logger.debug("Failed to close thread for recipient %s.", recipient_id) self.config["closures"].pop(recipient_id) await self.config.update() continue - logger.debug('Closing thread for recipient %s.', recipient_id) + logger.debug("Closing thread for recipient %s.", recipient_id) await thread.close( closer=self.get_user(items["closer_id"]), @@ -579,7 +590,7 @@ async def _process_blocked(self, message: discord.Message) -> bool: reaction = blocked_emoji changed = False delta = human_timedelta(min_account_age) - logger.debug('Blocked due to account age, user %s.', message.author.name) + logger.debug("Blocked due to account age, user %s.", message.author.name) if str(message.author.id) not in self.blocked_users: new_reason = ( @@ -603,7 +614,7 @@ async def _process_blocked(self, message: discord.Message) -> bool: reaction = blocked_emoji changed = False delta = human_timedelta(min_guild_age) - logger.debug('Blocked due to guild age, user %s.', message.author.name) + logger.debug("Blocked due to guild age, user %s.", message.author.name) if str(message.author.id) not in self.blocked_users: new_reason = ( @@ -628,13 +639,15 @@ async def _process_blocked(self, message: discord.Message) -> bool: ): # Met the age limit already, otherwise it would've been caught by the previous if's reaction = sent_emoji - logger.debug('No longer internally blocked, user %s.', message.author.name) + logger.debug( + "No longer internally blocked, user %s.", message.author.name + ) self.blocked_users.pop(str(message.author.id)) else: reaction = blocked_emoji end_time = re.search(r"%(.+?)%$", reason) if end_time is not None: - logger.debug('No longer blocked, user %s.', message.author.name) + logger.debug("No longer blocked, user %s.", message.author.name) after = ( datetime.fromisoformat(end_time.group(1)) - now ).total_seconds() @@ -643,7 +656,7 @@ async def _process_blocked(self, message: discord.Message) -> bool: reaction = sent_emoji self.blocked_users.pop(str(message.author.id)) else: - logger.debug('User blocked, user %s.', message.author.name) + logger.debug("User blocked, user %s.", message.author.name) else: reaction = sent_emoji @@ -652,7 +665,7 @@ async def _process_blocked(self, message: discord.Message) -> bool: try: await message.add_reaction(reaction) except (discord.HTTPException, discord.InvalidArgument): - logger.warning('Failed to add reaction %s.', reaction, exc_info=True) + logger.warning("Failed to add reaction %s.", reaction, exc_info=True) return str(message.author.id) in self.blocked_users async def process_modmail(self, message: discord.Message) -> None: @@ -839,9 +852,13 @@ async def on_raw_reaction_add(self, payload): if isinstance(channel, discord.DMChannel): if str(reaction) == str(close_emoji): # closing thread try: - recipient_thread_close = strtobool(self.config["recipient_thread_close"]) + recipient_thread_close = strtobool( + self.config["recipient_thread_close"] + ) except ValueError: - recipient_thread_close = self.config.remove("recipient_thread_close") + recipient_thread_close = self.config.remove( + "recipient_thread_close" + ) if not recipient_thread_close: return thread = await self.threads.find(recipient=user) @@ -875,7 +892,7 @@ async def on_guild_channel_delete(self, channel): if isinstance(channel, discord.CategoryChannel): if self.main_category.id == channel.id: - logger.debug('Main category was deleted.') + logger.debug("Main category was deleted.") self.config.remove("main_category_id") await self.config.update() return @@ -884,14 +901,14 @@ async def on_guild_channel_delete(self, channel): return if self.log_channel is None or self.log_channel.id == channel.id: - logger.info('Log channel deleted.') + logger.info("Log channel deleted.") self.config.remove("log_channel_id") await self.config.update() return thread = await self.threads.find(channel=channel) if thread: - logger.debug('Manually closed channel %s.', channel.name) + logger.debug("Manually closed channel %s.", channel.name) await thread.close(closer=mod, silent=True, delete_channel=False) async def on_member_remove(self, member): @@ -1062,7 +1079,8 @@ async def after_post_metadata(self): if __name__ == "__main__": try: import uvloop - logger.debug('Setting up with uvloop.') + + logger.debug("Setting up with uvloop.") uvloop.install() except ImportError: pass diff --git a/cogs/plugins.py b/cogs/plugins.py index 5f58c697f7..4f204ef10e 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -136,7 +136,7 @@ async def load_plugin(self, username, repo, plugin_name, branch): # so there's no terminal output unless there's an error except subprocess.CalledProcessError as exc: err = exc.stderr.decode("utf8").strip() - logger.error('Error.', exc_info=True) + logger.error("Error.", exc_info=True) if err: msg = f"Requirements Download Error: {username}/{repo}@{branch}[{plugin_name}]" logger.error(msg) @@ -174,7 +174,9 @@ async def plugin_add(self, ctx, *, plugin_name: str): if plugin_name in self.registry: details = self.registry[plugin_name] - plugin_name = details["repository"] + "/" + plugin_name + "@" + details["branch"] + plugin_name = ( + details["repository"] + "/" + plugin_name + "@" + details["branch"] + ) required_version = details["bot_version"] @@ -213,7 +215,9 @@ async def plugin_add(self, ctx, *, plugin_name: str): await self.download_plugin_repo(username, repo, branch) except Exception as exc: if not isinstance(exc, DownloadError): - logger.error('Unknown error when adding a plugin:', exc_info=True) + logger.error( + "Unknown error when adding a plugin:", exc_info=True + ) embed = discord.Embed( description=f"Unable to fetch this plugin from Github: `{exc}`.", color=self.bot.main_color, @@ -226,7 +230,9 @@ async def plugin_add(self, ctx, *, plugin_name: str): await self.load_plugin(username, repo, name, branch) except Exception as exc: if not isinstance(exc, DownloadError): - logger.error('Unknown error when adding a plugin:', exc_info=True) + logger.error( + "Unknown error when adding a plugin:", exc_info=True + ) embed = discord.Embed( description=f"Unable to load this plugin: `{exc}`.", color=self.bot.main_color, @@ -249,7 +255,7 @@ async def plugin_add(self, ctx, *, plugin_name: str): else: embed = discord.Embed( description="Invalid plugin name format: use plugin-name or " - "username/repo/plugin or username/repo/plugin@branch.", + "username/repo/plugin or username/repo/plugin@branch.", color=self.bot.main_color, ) await ctx.send(embed=embed) @@ -272,7 +278,7 @@ async def plugin_remove(self, ctx, *, plugin_name: str): f"plugins.{username}-{repo}-{branch}.{name}.{name}" ) except commands.ExtensionNotLoaded: - logger.error('Plugin was never loaded.') + logger.error("Plugin was never loaded.") self.bot.config["plugins"].remove(plugin_name) @@ -291,8 +297,8 @@ def onerror(func, path, exc_info): # pylint: disable=W0613 shutil.rmtree( f"plugins/{username}-{repo}-{branch}", onerror=onerror ) - except Exception as exc: - logger.error('Failed to remove plugin %s.', plugin_name, exc_info=True) + except Exception: + logger.error("Failed to remove plugin %s.", plugin_name, exc_info=True) self.bot.config["plugins"].append(plugin_name) return @@ -316,7 +322,9 @@ async def plugin_update(self, ctx, *, plugin_name: str): if plugin_name in self.registry: details = self.registry[plugin_name] - plugin_name = details["repository"] + "/" + plugin_name + "@" + details["branch"] + plugin_name = ( + details["repository"] + "/" + plugin_name + "@" + details["branch"] + ) if plugin_name not in self.bot.config["plugins"]: embed = discord.Embed( @@ -328,8 +336,10 @@ async def plugin_update(self, ctx, *, plugin_name: str): username, repo, name, branch = self.parse_plugin(plugin_name) try: - cmd = f"cd plugins/{username}-{repo}-{branch} && " \ + cmd = ( + f"cd plugins/{username}-{repo}-{branch} && " f"git reset --hard origin/{branch} && git fetch --all && git pull" + ) cmd = await self.bot.loop.run_in_executor( None, self._asubprocess_run, cmd ) @@ -340,7 +350,7 @@ async def plugin_update(self, ctx, *, plugin_name: str): description=f"An error occurred while updating: {err}.", color=self.bot.main_color, ) - logger.error('An error occurred while updating plugin:', exc_info=True) + logger.error("An error occurred while updating plugin:", exc_info=True) await ctx.send(embed=embed) else: @@ -363,7 +373,9 @@ async def plugin_update(self, ctx, *, plugin_name: str): description=f"Unable to start the plugin: `{exc}`.", color=self.bot.main_color, ) - logger.error('An error occurred while updating plugin:', exc_info=True) + logger.error( + "An error occurred while updating plugin:", exc_info=True + ) await ctx.send(embed=embed) @plugin.command(name="enabled", aliases=["installed"]) @@ -408,7 +420,9 @@ async def plugin_registry(self, ctx, *, plugin_name: typing.Union[int, str] = No if index >= len(registry): index = len(registry) - 1 else: - index = next((i for i, (n, _) in enumerate(registry) if plugin_name == n), 0) + index = next( + (i for i, (n, _) in enumerate(registry) if plugin_name == n), 0 + ) if not index and plugin_name is not None: embed = discord.Embed( diff --git a/cogs/utility.py b/cogs/utility.py index 9f07d68e57..36057ee294 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -246,6 +246,8 @@ async def changelog(self, ctx, version: str.lower = ""): paginator = PaginatorSession(ctx, *changelog.embeds) paginator.current = index await paginator.run() + except asyncio.CancelledError: + pass except Exception: await ctx.send(changelog.CHANGELOG_URL) diff --git a/core/changelog.py b/core/changelog.py index ec12a89838..386baf176f 100644 --- a/core/changelog.py +++ b/core/changelog.py @@ -120,12 +120,15 @@ class Changelog: "https://raw.githubusercontent.com/kyb3r/modmail/master/CHANGELOG.md" ) CHANGELOG_URL = "https://github.com/kyb3r/modmail/blob/master/CHANGELOG.md" - VERSION_REGEX = re.compile(r"# ([vV]\d+\.\d+(?:\.\d+)?)(.*?(?=# (?:[vV]\d+\.\d+(?:\.\d+)?)|$))", flags=re.DOTALL) + VERSION_REGEX = re.compile( + r"# ([vV]\d+\.\d+(?:\.\d+)?)(.*?(?=# (?:[vV]\d+\.\d+(?:\.\d+)?)|$))", + flags=re.DOTALL, + ) def __init__(self, bot, text: str): self.bot = bot self.text = text - logger.debug('Fetching changelog from GitHub.') + logger.debug("Fetching changelog from GitHub.") self.versions = [Version(bot, *m) for m in self.VERSION_REGEX.findall(text)] @property diff --git a/core/clients.py b/core/clients.py index 4b8b578cc4..a4ed847622 100644 --- a/core/clients.py +++ b/core/clients.py @@ -88,17 +88,17 @@ def logs(self): async def get_user_logs(self, user_id: Union[str, int]) -> list: query = {"recipient.id": str(user_id), "guild_id": str(self.bot.guild_id)} projection = {"messages": {"$slice": 5}} - logger.debug('Retrieving user %s logs.', user_id) + logger.debug("Retrieving user %s logs.", user_id) return await self.logs.find(query, projection).to_list(None) async def get_log(self, channel_id: Union[str, int]) -> dict: - logger.debug('Retrieving channel %s logs.', channel_id) + logger.debug("Retrieving channel %s logs.", channel_id) return await self.logs.find_one({"channel_id": str(channel_id)}) async def get_log_link(self, channel_id: Union[str, int]) -> str: doc = await self.get_log(channel_id) - logger.debug('Retrieving log link for channel %s.', channel_id) + logger.debug("Retrieving log link for channel %s.", channel_id) return f"{self.bot.config['log_url'].strip('/')}{self.bot.config['log_url_prefix']}/{doc['key']}" async def create_log_entry( @@ -134,13 +134,13 @@ async def create_log_entry( "messages": [], } ) - logger.debug('Created a log entry, key %s.', key) + logger.debug("Created a log entry, key %s.", key) return f"{self.bot.config['log_url'].strip('/')}{self.bot.config['log_url_prefix']}/{key}" async def get_config(self) -> dict: conf = await self.db.config.find_one({"bot_id": self.bot.user.id}) if conf is None: - logger.debug('Creating a new config entry for bot %s.', self.bot.user.id) + logger.debug("Creating a new config entry for bot %s.", self.bot.user.id) await self.db.config.insert_one({"bot_id": self.bot.user.id}) return {"bot_id": self.bot.user.id} return conf diff --git a/core/config.py b/core/config.py index c54165dc93..301ab69f61 100644 --- a/core/config.py +++ b/core/config.py @@ -129,10 +129,12 @@ def populate_cache(self) -> dict: data.update( {k.lower(): v for k, v in os.environ.items() if k.lower() in self.all_keys} ) - configjson = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") + configjson = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "config.json" + ) if os.path.exists(configjson): - logger.debug('Loading envs from config.json.') - with open(configjson, 'r') as f: + logger.debug("Loading envs from config.json.") + with open(configjson, "r") as f: # Config json should override env vars try: data.update( @@ -143,7 +145,9 @@ def populate_cache(self) -> dict: } ) except json.JSONDecodeError: - logger.critical('Failed to load config.json env values.', exc_info=True) + logger.critical( + "Failed to load config.json env values.", exc_info=True + ) self._cache = data return self._cache @@ -282,7 +286,7 @@ def filter_default( for k, v in data.items(): default = cls.defaults.get(k.lower(), Default) if default is Default: - logger.error('Unexpected configuration detected: %s.', k) + logger.error("Unexpected configuration detected: %s.", k) continue if v != default: filtered[k.lower()] = v diff --git a/core/thread.py b/core/thread.py index 73248d915f..d4213a0c93 100644 --- a/core/thread.py +++ b/core/thread.py @@ -103,7 +103,7 @@ async def setup(self, *, creator=None, category=None): reason="Creating a thread channel.", ) except discord.HTTPException as e: # Failed to create due to 50 channel limit. - logger.critical('An error occurred while creating a thread.', exc_info=True) + logger.critical("An error occurred while creating a thread.", exc_info=True) self.manager.cache.pop(self.id) embed = discord.Embed(color=discord.Color.red()) @@ -125,7 +125,9 @@ async def setup(self, *, creator=None, category=None): log_count = sum(1 for log in log_data if not log["open"]) except Exception: - logger.error('An error occurred while posting logs to the database.', exc_info=True) + logger.error( + "An error occurred while posting logs to the database.", exc_info=True + ) log_url = log_count = None # ensure core functionality still works @@ -213,7 +215,11 @@ def _format_info_embed(self, user, log_url, log_count, color): role_names = separator.join(roles) created = str((time - user.created_at).days) - embed = discord.Embed(color=color, description=f"{user.mention} was created {days(created)}", timestamp=time) + embed = discord.Embed( + color=color, + description=f"{user.mention} was created {days(created)}", + timestamp=time, + ) # if not role_names: # embed.add_field(name='Mention', value=user.mention) @@ -836,13 +842,13 @@ async def find( if recipient is None and channel is not None: thread = self._find_from_channel(channel) if thread is None: - id, thread = next( + user_id, thread = next( ((k, v) for k, v in self.cache.items() if v.channel == channel), (-1, None), ) if thread is not None: logger.debug("Found thread with tempered ID.") - await channel.edit(topic=f"User ID: {id}") + await channel.edit(topic=f"User ID: {user_id}") return thread thread = None @@ -925,6 +931,6 @@ def format_channel_name(self, author): counter = 1 while new_name in [c.name for c in self.bot.modmail_guild.text_channels]: - new_name += f'-{counter}' # two channels with same name + new_name += f"-{counter}" # two channels with same name return new_name From b3e8f62e993f718b98ee32367022f8324c108a1c Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Fri, 26 Jul 2019 23:59:51 -0700 Subject: [PATCH 25/50] Snippets/aliases --- CHANGELOG.md | 4 ++ bot.py | 3 +- cogs/modmail.py | 115 ++++++++++++++++++++++++++++-------------------- cogs/utility.py | 107 ++++++++++++++++++++++++-------------------- core/utils.py | 18 ++++++-- 5 files changed, 149 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf4775ff87..3872d519a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `?plugin enabled` results are now sorted alphabetically. - `?plugin registry` results are now sorted alphabetically, helps user find plugins more easily. - `?plugin registry page-number` plugin registry can specify a page number for quick access. +- A reworked interface for `?snippet` and `?alias`. + - Add an `?snippet raw ` command for viewing the raw content of a snippet (escaped markdown). ### Fixes @@ -37,6 +39,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `mod_typing` and `user_typing` will no longer show when user is blocked. - Better `?block` usage message. - Resolves errors when message was sent by mods after thread is closed somehow. +- Recipient join/leave server messages are limited to only the guild set by `GUILD_ID`. +- When creating snippets and aliases, it now checks if another snippets/aliases with the same name exists. ### Internal diff --git a/bot.py b/bot.py index 132ae4c913..7c9e18528c 100644 --- a/bot.py +++ b/bot.py @@ -87,7 +87,8 @@ def __init__(self): self.db = AsyncIOMotorClient(mongo_uri).modmail_bot except ConfigurationError as e: logger.critical( - "Your MONGO_URI is copied wrong, try re-copying from the source again." + "Your MONGO_URI might be copied wrong, try re-copying from the source again. " + "Otherwise noted in the following message:" ) logger.critical(str(e)) sys.exit(0) diff --git a/cogs/modmail.py b/cogs/modmail.py index 1f396cdc65..d8c8304fbb 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -1,11 +1,13 @@ import asyncio import logging from datetime import datetime +from itertools import zip_longest, takewhile from typing import Optional, Union from types import SimpleNamespace as param import discord from discord.ext import commands +from discord.utils import escape_markdown, escape_mentions from dateutil import parser from natural.date import duration @@ -15,7 +17,7 @@ from core.models import PermissionLevel from core.paginator import PaginatorSession from core.time import UserFriendlyTime, human_timedelta -from core.utils import format_preview, User +from core.utils import format_preview, User, create_not_found_embed logger = logging.getLogger("Modmail") @@ -100,34 +102,36 @@ async def setup(self, ctx): @commands.group(aliases=["snippets"], invoke_without_command=True) @checks.has_permissions(PermissionLevel.SUPPORTER) - async def snippet(self, ctx): + async def snippet(self, ctx, *, name: str.lower = None): """ Create pre-defined messages for use in threads. When `{prefix}snippet` is used by itself, this will retrieve - a list of snippets that are currently set. + a list of snippets that are currently set. `{prefix}snippet-name` will show what the + snippet point to. - To use snippets: - - First create a snippet using: + To create a snippet: - `{prefix}snippet add snippet-name A pre-defined text.` - Afterwards, you can use your snippet in a thread channel + You can use your snippet in a thread channel with `{prefix}snippet-name`, the message "A pre-defined text." will be sent to the recipient. + Currently, there is not a default anonymous snippet command; however, a workaround + is available using `{prefix}alias`. Here is how: + - `{prefix}alias add snippet-name anonreply A pre-defined anonymous text.` + See also `{prefix}alias`. """ - embeds = [] + if name is not None: + val = self.bot.snippets.get(name) + if val is None: + embed = create_not_found_embed(name, self.bot.snippets.keys(), 'Snippet') + return await ctx.send(embed=embed) + return await ctx.send(escape_mentions(val)) - if self.bot.snippets: - embed = discord.Embed( - color=self.bot.main_color, - description="Here is a list of snippets " - "that are currently configured.", - ) - else: + if not self.bot.snippets: embed = discord.Embed( color=discord.Color.red(), description="You dont have any snippets at the moment.", @@ -135,25 +139,37 @@ async def snippet(self, ctx): embed.set_footer( text=f"Do {self.bot.prefix}help snippet for more commands." ) + embed.set_author(name="Snippets", icon_url=ctx.guild.icon_url) + return await ctx.send(embed=embed) - embed.set_author(name="Snippets", icon_url=ctx.guild.icon_url) - embeds.append(embed) + embeds = [] - for name, value in self.bot.snippets.items(): - if len(embed.fields) == 5: - embed = discord.Embed( - color=self.bot.main_color, description=embed.description + for names in zip_longest(*(iter(sorted(self.bot.snippets)),) * 15): + description = "\n".join( + ": ".join((str(a), b)) + for a, b in enumerate( + takewhile(lambda x: x is not None, names), start=1 ) - embed.set_author(name="Snippets", icon_url=ctx.guild.icon_url) - embeds.append(embed) - embed.add_field(name=name, value=value, inline=False) + ) + embed = discord.Embed(color=self.bot.main_color, description=description) + embed.set_author(name="Snippets", icon_url=ctx.guild.icon_url) + embeds.append(embed) session = PaginatorSession(ctx, *embeds) await session.run() + @snippet.command(name="raw") + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def snippet_raw(self, ctx, *, name: str.lower): + val = self.bot.snippets.get(name) + if val is None: + embed = create_not_found_embed(name, self.bot.snippets.keys(), 'Snippet') + return await ctx.send(embed=embed) + return await ctx.send(escape_markdown(escape_mentions(val)).replace('<', '\\<')) + @snippet.command(name="add") @checks.has_permissions(PermissionLevel.SUPPORTER) - async def snippet_add(self, ctx, name: str.lower, *, value): + async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_content): """ Add a snippet. @@ -167,19 +183,35 @@ async def snippet_add(self, ctx, name: str.lower, *, value): color=discord.Color.red(), description=f"Snippet `{name}` already exists.", ) - else: - self.bot.snippets[name] = value - await self.bot.config.update() + return await ctx.send(embed=embed) + if name in self.bot.aliases: embed = discord.Embed( - title="Added snippet", - color=self.bot.main_color, - description=f'`{name}` will now send "{value}".', + title="Error", + color=discord.Color.red(), + description=f"An alias with the same name already exists: `{name}`.", + ) + return await ctx.send(embed=embed) + + if len(name) > 120: + embed = discord.Embed( + title="Error", + color=discord.Color.red(), + description=f"Snippet names cannot be longer than 120 characters.", ) + return await ctx.send(embed=embed) - await ctx.send(embed=embed) + self.bot.snippets[name] = value + await self.bot.config.update() + + embed = discord.Embed( + title="Added snippet", + color=self.bot.main_color, + description=f'Successfully created snippet.', + ) + return await ctx.send(embed=embed) - @snippet.command(name="remove", aliases=["del", "delete", "rm"]) + @snippet.command(name="remove", aliases=["del", "delete"]) @checks.has_permissions(PermissionLevel.SUPPORTER) async def snippet_remove(self, ctx, *, name: str.lower): """Remove a snippet.""" @@ -188,18 +220,12 @@ async def snippet_remove(self, ctx, *, name: str.lower): embed = discord.Embed( title="Removed snippet", color=self.bot.main_color, - description=f"`{name}` no longer exists.", + description=f"Snippet `{name}` is now deleted.", ) self.bot.snippets.pop(name) await self.bot.config.update() - else: - embed = discord.Embed( - title="Error", - color=discord.Color.red(), - description=f"Snippet `{name}` does not exist.", - ) - + embed = create_not_found_embed(name, self.bot.snippets.keys(), 'Snippet') await ctx.send(embed=embed) @snippet.command(name="edit") @@ -221,13 +247,8 @@ async def snippet_edit(self, ctx, name: str.lower, *, value): color=self.bot.main_color, description=f'`{name}` will now send "{value}".', ) - else: - embed = discord.Embed( - title="Error", - color=discord.Color.red(), - description=f"Snippet `{name}` does not exist.", - ) + embed = create_not_found_embed(name, self.bot.snippets.keys(), 'Snippet') await ctx.send(embed=embed) @commands.command() diff --git a/cogs/utility.py b/cogs/utility.py index c1fb34369f..182f836864 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -8,6 +8,7 @@ from datetime import datetime from difflib import get_close_matches from io import StringIO, BytesIO +from itertools import zip_longest, takewhile from json import JSONDecodeError, loads from textwrap import indent from types import SimpleNamespace as param @@ -16,6 +17,7 @@ from discord import Embed, Color, Activity, Role from discord.enums import ActivityType, Status from discord.ext import commands +from discord.utils import escape_mentions from aiohttp import ClientResponseError from pkg_resources import parse_version @@ -25,7 +27,7 @@ from core.decorators import trigger_typing from core.models import InvalidConfigError, PermissionLevel from core.paginator import PaginatorSession, MessagePaginatorSession -from core.utils import cleanup_code, User, get_perm_level +from core.utils import cleanup_code, User, get_perm_level, create_not_found_embed logger = logging.getLogger("Modmail") @@ -811,12 +813,13 @@ async def config_get(self, ctx, key: str.lower = None): @commands.group(aliases=["aliases"], invoke_without_command=True) @checks.has_permissions(PermissionLevel.MODERATOR) - async def alias(self, ctx): + async def alias(self, ctx, *, name: str.lower = None): """ Create shortcuts to bot commands. When `{prefix}alias` is used by itself, this will retrieve - a list of alias that are currently set. + a list of alias that are currently set. `{prefix}alias-name` will show what the + alias point to. To use alias: @@ -830,44 +833,73 @@ async def alias(self, ctx): See also `{prefix}snippet`. """ - embeds = [] - desc = "Here is a list of aliases that are currently configured." + if name is not None: + val = self.bot.aliases.get(name) + if val is None: + embed = create_not_found_embed(name, self.bot.aliases.keys(), 'Alias') + return await ctx.send(embed=embed) + return await ctx.send(escape_mentions(val)) - if self.bot.aliases: - embed = Embed(color=self.bot.main_color, description=desc) - else: + if not self.bot.aliases: embed = Embed( - color=self.bot.main_color, + color=Color.red(), description="You dont have any aliases at the moment.", ) - embed.set_author(name="Command aliases:", icon_url=ctx.guild.icon_url) - embed.set_footer(text=f"Do {self.bot.prefix}help alias for more commands.") - embeds.append(embed) + embed.set_footer( + text=f"Do {self.bot.prefix}help alias for more commands." + ) + embed.set_author(name="Aliases", icon_url=ctx.guild.icon_url) + return await ctx.send(embed=embed) - for name, value in self.bot.aliases.items(): - if len(embed.fields) == 5: - embed = Embed(color=self.bot.main_color, description=desc) - embed.set_author(name="Command aliases", icon_url=ctx.guild.icon_url) - embed.set_footer( - text=f"Do {self.bot.prefix}help alias for more commands." - ) + embeds = [] - embeds.append(embed) - embed.add_field(name=name, value=value, inline=False) + for names in zip_longest(*(iter(sorted(self.bot.aliases)),) * 15): + description = "\n".join( + ": ".join((str(a), b)) + for a, b in enumerate( + takewhile(lambda x: x is not None, names), start=1 + ) + ) + embed = Embed(color=self.bot.main_color, description=description) + embed.set_author(name="Command Aliases", icon_url=ctx.guild.icon_url) + embeds.append(embed) session = PaginatorSession(ctx, *embeds) - return await session.run() + await session.run() @alias.command(name="add") @checks.has_permissions(PermissionLevel.MODERATOR) async def alias_add(self, ctx, name: str.lower, *, value): """Add an alias.""" - if self.bot.get_command(name) or name in self.bot.aliases: + if self.bot.get_command(name): embed = Embed( title="Error", color=Color.red(), - description="A command or alias already exists " - f"with the same name: `{name}`.", + description=f"A command with the same name already exists: `{name}`.", + ) + return await ctx.send(embed=embed) + + if name in self.bot.aliases: + embed = Embed( + title="Error", + color=Color.red(), + description=f"Another alias with the same name already exists: `{name}`.", + ) + return await ctx.send(embed=embed) + + if name in self.bot.snippets: + embed = Embed( + title="Error", + color=Color.red(), + description=f"A snippet with the same name already exists: `{name}`.", + ) + return await ctx.send(embed=embed) + + if len(name) > 120: + embed = Embed( + title="Error", + color=Color.red(), + description=f"Alias names cannot be longer than 120 characters.", ) return await ctx.send(embed=embed) @@ -892,7 +924,7 @@ async def alias_add(self, ctx, name: str.lower, *, value): return await ctx.send(embed=embed) - @alias.command(name="remove", aliases=["del", "delete", "rm"]) + @alias.command(name="remove", aliases=["del", "delete"]) @checks.has_permissions(PermissionLevel.MODERATOR) async def alias_remove(self, ctx, *, name: str.lower): """Remove an alias.""" @@ -906,13 +938,8 @@ async def alias_remove(self, ctx, *, name: str.lower): color=self.bot.main_color, description=f"`{name}` no longer exists.", ) - else: - embed = Embed( - title="Error", - color=Color.red(), - description=f"Alias `{name}` does not exist.", - ) + embed = create_not_found_embed(name, self.bot.aliases.keys(), 'Alias') return await ctx.send(embed=embed) @@ -923,21 +950,7 @@ async def alias_edit(self, ctx, name: str.lower, *, value): Edit an alias. """ if name not in self.bot.aliases: - embed = Embed( - title="Error", - color=Color.red(), - description=f"Alias `{name}` does not exist.", - ) - - return await ctx.send(embed=embed) - - if self.bot.get_command(name): - embed = Embed( - title="Error", - color=Color.red(), - description="A command or alias already exists " - f"with the same name: `{name}`.", - ) + embed = create_not_found_embed(name, self.bot.aliases.keys(), 'Alias') return await ctx.send(embed=embed) linked_command = value.split()[0] diff --git a/core/utils.py b/core/utils.py index 8f5e6c2589..76c1864a27 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,9 +1,10 @@ import re import typing +from difflib import get_close_matches from distutils.util import strtobool as _stb # pylint: disable=E0401 from urllib import parse -from discord import Object +import discord from discord.ext import commands from core.models import PermissionLevel @@ -34,7 +35,7 @@ async def convert(self, ctx, argument): match = self._get_id_match(argument) if match is None: raise commands.BadArgument('User "{}" not found'.format(argument)) - return Object(int(match.group(1))) + return discord.Object(int(match.group(1))) def truncate(text: str, max: int = 50) -> str: # pylint: disable=W0622 @@ -186,7 +187,7 @@ def match_user_id(text: str) -> int: int The user ID if found. Otherwise, -1. """ - match = re.search(r"\bUser ID: (\d+)\b", text) + match = re.search(r"\bUser ID: (\d{17,21})\b", text) if match is not None: return int(match.group(1)) return -1 @@ -208,3 +209,14 @@ async def ignore(coro): await coro except Exception: pass + + +def create_not_found_embed(word, possibilities, name, n=2, cutoff=0.6) -> discord.Embed: + embed = discord.Embed( + color=discord.Color.red(), + description=f"**{name.capitalize()} `{word}` cannot be found.**", + ) + val = get_close_matches(word, possibilities, n=n, cutoff=cutoff) + if val: + embed.description += '\nHowever, perhaps you meant...\n' + '\n'.join(val) + return embed From 1564e1f7e4c9e3d2cdd9a5766aa69027cb0295f4 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Sat, 27 Jul 2019 15:05:15 -0700 Subject: [PATCH 26/50] Multistep alias --- .gitignore | 4 ++ CHANGELOG.md | 1 + bot.py | 131 +++++++++++++++++++++++++------------ cogs/modmail.py | 14 ++-- cogs/utility.py | 170 ++++++++++++++++++++++++++++++++++++++---------- core/utils.py | 35 +++++++++- 6 files changed, 273 insertions(+), 82 deletions(-) diff --git a/.gitignore b/.gitignore index 0c953c5e8e..8e80af7c26 100644 --- a/.gitignore +++ b/.gitignore @@ -124,6 +124,10 @@ dmypy.json # VS Code .vscode/ +# Node +package-lock.json +node_modules/ + # Modmail config.json plugins/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3872d519a6..707fbd1207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New configuration variable `thread_self_closable_creation_footer` — the footer when `recipient_thread_close` is enabled. - Added a minimalistic version of requirements.txt (named requirements.min.txt) that contains only the absolute minimum of Modmail. - For users having trouble with pipenv or any other reason. +- Multi-step alias, see `?help alias add`. Public beta testing, might be unstable. ### Changes diff --git a/bot.py b/bot.py index 7c9e18528c..f92630faec 100644 --- a/bot.py +++ b/bot.py @@ -6,8 +6,8 @@ import re import sys import typing - from datetime import datetime +from itertools import zip_longest from types import SimpleNamespace import discord @@ -30,7 +30,7 @@ from core.clients import ApiClient, PluginDatabaseClient from core.config import ConfigManager -from core.utils import human_join, strtobool +from core.utils import human_join, strtobool, parse_alias from core.models import PermissionLevel, ModmailLogger from core.thread import ThreadManager from core.time import human_timedelta @@ -669,24 +669,76 @@ async def _process_blocked(self, message: discord.Message) -> bool: logger.warning("Failed to add reaction %s.", reaction, exc_info=True) return str(message.author.id) in self.blocked_users - async def process_modmail(self, message: discord.Message) -> None: + async def process_dm_modmail(self, message: discord.Message) -> None: """Processes messages sent to the bot.""" - await self.wait_for_connected() - blocked = await self._process_blocked(message) if not blocked: thread = await self.threads.find_or_create(message.author) await thread.send(message) + async def get_contexts(self, message, *, cls=commands.Context): + """ + Returns all invocation contexts from the message. + Supports getting the prefix from database as well as command aliases. + """ + + view = StringView(message.content) + ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) + ctx.thread = await self.threads.find(channel=ctx.channel) + + if self._skip_check(message.author.id, self.user.id): + return [ctx] + + prefixes = await self.get_prefix() + + invoked_prefix = discord.utils.find(view.skip_string, prefixes) + if invoked_prefix is None: + return [ctx] + + invoker = view.get_word().lower() + + # Check if there is any aliases being called. + alias = self.aliases.get(invoker) + if alias is not None: + aliases = parse_alias(alias) + if not aliases: + logger.warning("Alias %s is invalid, removing.", invoker) + self.aliases.pop(invoker) + else: + len_ = len(f"{invoked_prefix}{invoker}") + contents = parse_alias(message.content[len_:]) + if not contents: + contents = [message.content[len_:]] + + ctxs = [] + for alias, content in zip_longest(aliases, contents): + if alias is None: + break + ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) + ctx.thread = await self.threads.find(channel=ctx.channel) + + if content is not None: + view = StringView(f"{alias} {content.strip()}") + else: + view = StringView(alias) + ctx.view = view + ctx.invoked_with = view.get_word() + ctx.command = self.all_commands.get(ctx.invoked_with) + ctxs += [ctx] + return ctxs + + ctx.invoked_with = invoker + ctx.command = self.all_commands.get(invoker) + return [ctx] + async def get_context(self, message, *, cls=commands.Context): """ Returns the invocation context from the message. - Supports getting the prefix from database as well as command aliases. + Supports getting the prefix from database. """ - await self.wait_for_connected() view = StringView(message.content) - ctx = cls(prefix=None, view=view, bot=self, message=message) + ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) if self._skip_check(message.author.id, self.user.id): return ctx @@ -701,17 +753,7 @@ async def get_context(self, message, *, cls=commands.Context): invoker = view.get_word().lower() - # Check if there is any aliases being called. - alias = self.aliases.get(invoker) - if alias is not None: - ctx._alias_invoked = True # pylint: disable=W0212 - len_ = len(f"{invoked_prefix}{invoker}") - view = StringView(f"{alias}{ctx.message.content[len_:]}") - ctx.view = view - invoker = view.get_word() - ctx.invoked_with = invoker - ctx.prefix = self.prefix # Sane prefix (No mentions) ctx.command = self.all_commands.get(invoker) return ctx @@ -739,47 +781,52 @@ async def update_perms( async def on_message(self, message): await self.wait_for_connected() - if message.type == discord.MessageType.pins_add and message.author == self.user: await message.delete() + await self.process_commands(message) + async def process_commands(self, message): if message.author.bot: return if isinstance(message.channel, discord.DMChannel): - return await self.process_modmail(message) + return await self.process_dm_modmail(message) - prefix = self.prefix + if message.content.startswith(self.prefix): + cmd = message.content[len(self.prefix) :].strip() - if message.content.startswith(prefix): - cmd = message.content[len(prefix) :].strip() + # Process snippets if cmd in self.snippets: thread = await self.threads.find(channel=message.channel) snippet = self.snippets[cmd] if thread: snippet = snippet.format(recipient=thread.recipient) - message.content = f"{prefix}reply {snippet}" + message.content = f"{self.prefix}reply {snippet}" - ctx = await self.get_context(message) - if ctx.command: - return await self.invoke(ctx) + ctxs = await self.get_contexts(message) + for ctx in ctxs: + if ctx.command: + await self.invoke(ctx) + continue - thread = await self.threads.find(channel=ctx.channel) - if thread is not None: - try: - reply_without_command = strtobool(self.config["reply_without_command"]) - except ValueError: - reply_without_command = self.config.remove("reply_without_command") + thread = await self.threads.find(channel=ctx.channel) + if thread is not None: + try: + reply_without_command = strtobool( + self.config["reply_without_command"] + ) + except ValueError: + reply_without_command = self.config.remove("reply_without_command") - if reply_without_command: - await thread.reply(message) - else: - await self.api.append_log(message, type_="internal") - elif ctx.invoked_with: - exc = commands.CommandNotFound( - 'Command "{}" is not found'.format(ctx.invoked_with) - ) - self.dispatch("command_error", ctx, exc) + if reply_without_command: + await thread.reply(message) + else: + await self.api.append_log(message, type_="internal") + elif ctx.invoked_with: + exc = commands.CommandNotFound( + 'Command "{}" is not found'.format(ctx.invoked_with) + ) + self.dispatch("command_error", ctx, exc) async def on_typing(self, channel, user, _): await self.wait_for_connected() diff --git a/cogs/modmail.py b/cogs/modmail.py index d8c8304fbb..4eae4037e3 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -127,7 +127,9 @@ async def snippet(self, ctx, *, name: str.lower = None): if name is not None: val = self.bot.snippets.get(name) if val is None: - embed = create_not_found_embed(name, self.bot.snippets.keys(), 'Snippet') + embed = create_not_found_embed( + name, self.bot.snippets.keys(), "Snippet" + ) return await ctx.send(embed=embed) return await ctx.send(escape_mentions(val)) @@ -163,9 +165,9 @@ async def snippet(self, ctx, *, name: str.lower = None): async def snippet_raw(self, ctx, *, name: str.lower): val = self.bot.snippets.get(name) if val is None: - embed = create_not_found_embed(name, self.bot.snippets.keys(), 'Snippet') + embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") return await ctx.send(embed=embed) - return await ctx.send(escape_markdown(escape_mentions(val)).replace('<', '\\<')) + return await ctx.send(escape_markdown(escape_mentions(val)).replace("<", "\\<")) @snippet.command(name="add") @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -207,7 +209,7 @@ async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_conte embed = discord.Embed( title="Added snippet", color=self.bot.main_color, - description=f'Successfully created snippet.', + description=f"Successfully created snippet.", ) return await ctx.send(embed=embed) @@ -225,7 +227,7 @@ async def snippet_remove(self, ctx, *, name: str.lower): self.bot.snippets.pop(name) await self.bot.config.update() else: - embed = create_not_found_embed(name, self.bot.snippets.keys(), 'Snippet') + embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") await ctx.send(embed=embed) @snippet.command(name="edit") @@ -248,7 +250,7 @@ async def snippet_edit(self, ctx, name: str.lower, *, value): description=f'`{name}` will now send "{value}".', ) else: - embed = create_not_found_embed(name, self.bot.snippets.keys(), 'Snippet') + embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") await ctx.send(embed=embed) @commands.command() diff --git a/cogs/utility.py b/cogs/utility.py index 182f836864..a5adde8345 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -17,7 +17,7 @@ from discord import Embed, Color, Activity, Role from discord.enums import ActivityType, Status from discord.ext import commands -from discord.utils import escape_mentions +from discord.utils import escape_markdown from aiohttp import ClientResponseError from pkg_resources import parse_version @@ -27,7 +27,13 @@ from core.decorators import trigger_typing from core.models import InvalidConfigError, PermissionLevel from core.paginator import PaginatorSession, MessagePaginatorSession -from core.utils import cleanup_code, User, get_perm_level, create_not_found_embed +from core.utils import ( + cleanup_code, + User, + get_perm_level, + create_not_found_embed, + parse_alias, +) logger = logging.getLogger("Modmail") @@ -836,18 +842,43 @@ async def alias(self, ctx, *, name: str.lower = None): if name is not None: val = self.bot.aliases.get(name) if val is None: - embed = create_not_found_embed(name, self.bot.aliases.keys(), 'Alias') + embed = create_not_found_embed(name, self.bot.aliases.keys(), "Alias") return await ctx.send(embed=embed) - return await ctx.send(escape_mentions(val)) + + values = parse_alias(val) + + if not values: + embed = Embed( + title="Error", + color=Color.red(), + description=f"Alias `{name}` is invalid, it used to be `{escape_markdown(val)}`. " + f"This alias will now be deleted.", + ) + self.bot.aliases.pop(name) + await self.bot.config.update() + return await ctx.send(embed=embed) + + if len(values) == 1: + embed = Embed( + color=self.bot.main_color, + description=f"`{name}` points to `{escape_markdown(values[0])}`.", + ) + else: + embed = Embed( + color=self.bot.main_color, + description=f"`{name}` points to the following steps:", + ) + for i, val in enumerate(values, start=1): + embed.description += f"\n{i}: {escape_markdown(val)}" + + return await ctx.send(embed=embed) if not self.bot.aliases: embed = Embed( color=Color.red(), description="You dont have any aliases at the moment.", ) - embed.set_footer( - text=f"Do {self.bot.prefix}help alias for more commands." - ) + embed.set_footer(text=f"Do {self.bot.prefix}help alias for more commands.") embed.set_author(name="Aliases", icon_url=ctx.guild.icon_url) return await ctx.send(embed=embed) @@ -870,7 +901,19 @@ async def alias(self, ctx, *, name: str.lower = None): @alias.command(name="add") @checks.has_permissions(PermissionLevel.MODERATOR) async def alias_add(self, ctx, name: str.lower, *, value): - """Add an alias.""" + """ + Add an alias. + + Alias also supports multi-step aliases, to create a multi-step alias use quotes + to wrap each step and separate each step with `&&`. For example: + + - `{prefix}alias add movenreply "move admin-category" && "reply Thanks for reaching out to the admins"` + + However, if you run into problems, try wrapping the command with quotes. For example: + + - This will fail: `{prefix}alias add reply You'll need to type && to work` + - Correct method: `{prefix}alias add reply "You'll need to type && to work"` + """ if self.bot.get_command(name): embed = Embed( title="Error", @@ -903,24 +946,55 @@ async def alias_add(self, ctx, name: str.lower, *, value): ) return await ctx.send(embed=embed) - linked_command = value.split()[0] - if not self.bot.get_command(linked_command): + values = parse_alias(value) + + if not values: embed = Embed( title="Error", color=Color.red(), - description="The command you are attempting to point " - f"to does not exist: `{linked_command}`.", + description="Invalid multi-step alias, try wrapping each steps in quotes.", ) + embed.set_footer(text=f'See "{self.bot.prefix}alias add" for more details.') return await ctx.send(embed=embed) - self.bot.aliases[name] = value - await self.bot.config.update() + if len(values) == 1: + linked_command = values[0].split()[0] + if not self.bot.get_command(linked_command): + embed = Embed( + title="Error", + color=Color.red(), + description="The command you are attempting to point " + f"to does not exist: `{linked_command}`.", + ) + return await ctx.send(embed=embed) - embed = Embed( - title="Added alias", - color=self.bot.main_color, - description=f'`{name}` points to: "{value}".', - ) + embed = Embed( + title="Added alias", + color=self.bot.main_color, + description=f'`{name}` points to: "{values[0]}".', + ) + + else: + embed = Embed( + title="Added alias", + color=self.bot.main_color, + description=f"`{name}` now points to the following steps:", + ) + + for i, val in enumerate(values, start=1): + linked_command = val.split()[0] + if not self.bot.get_command(linked_command): + embed = Embed( + title="Error", + color=Color.red(), + description="The command you are attempting to point " + f"to on step {i} does not exist: `{linked_command}`.", + ) + return await ctx.send(embed=embed) + embed.description += f"\n{i}: {val}" + + self.bot.aliases[name] = "&&".join(values) + await self.bot.config.update() return await ctx.send(embed=embed) @@ -936,10 +1010,10 @@ async def alias_remove(self, ctx, *, name: str.lower): embed = Embed( title="Removed alias", color=self.bot.main_color, - description=f"`{name}` no longer exists.", + description=f"Successfully deleted `{name}`.", ) else: - embed = create_not_found_embed(name, self.bot.aliases.keys(), 'Alias') + embed = create_not_found_embed(name, self.bot.aliases.keys(), "Alias") return await ctx.send(embed=embed) @@ -950,27 +1024,57 @@ async def alias_edit(self, ctx, name: str.lower, *, value): Edit an alias. """ if name not in self.bot.aliases: - embed = create_not_found_embed(name, self.bot.aliases.keys(), 'Alias') + embed = create_not_found_embed(name, self.bot.aliases.keys(), "Alias") return await ctx.send(embed=embed) - linked_command = value.split()[0] - if not self.bot.get_command(linked_command): + values = parse_alias(value) + + if not values: embed = Embed( title="Error", color=Color.red(), - description="The command you are attempting to point " - f"to does not exist: `{linked_command}`.", + description="Invalid multi-step alias, try wrapping each steps in quotes.", ) + embed.set_footer(text=f'See "{self.bot.prefix}alias add" for more details.') return await ctx.send(embed=embed) - self.bot.aliases[name] = value - await self.bot.config.update() + if len(values) == 1: + linked_command = values[0].split()[0] + if not self.bot.get_command(linked_command): + embed = Embed( + title="Error", + color=Color.red(), + description="The command you are attempting to point " + f"to does not exist: `{linked_command}`.", + ) + return await ctx.send(embed=embed) + embed = Embed( + title="Edited alias", + color=self.bot.main_color, + description=f'`{name}` now points to: "{values[0]}".', + ) - embed = Embed( - title="Edited alias", - color=self.bot.main_color, - description=f'`{name}` now points to: "{value}".', - ) + else: + embed = Embed( + title="Edited alias", + color=self.bot.main_color, + description=f"`{name}` now points to the following steps:", + ) + + for i, val in enumerate(values, start=1): + linked_command = val.split()[0] + if not self.bot.get_command(linked_command): + embed = Embed( + title="Error", + color=Color.red(), + description="The command you are attempting to point " + f"to on step {i} does not exist: `{linked_command}`.", + ) + return await ctx.send(embed=embed) + embed.description += f"\n{i}: {val}" + + self.bot.aliases[name] = "&&".join(values) + await self.bot.config.update() return await ctx.send(embed=embed) @commands.group(aliases=["perms"], invoke_without_command=True) diff --git a/core/utils.py b/core/utils.py index 76c1864a27..16fe1675b0 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,4 +1,5 @@ import re +import shlex import typing from difflib import get_close_matches from distutils.util import strtobool as _stb # pylint: disable=E0401 @@ -218,5 +219,37 @@ def create_not_found_embed(word, possibilities, name, n=2, cutoff=0.6) -> discor ) val = get_close_matches(word, possibilities, n=n, cutoff=cutoff) if val: - embed.description += '\nHowever, perhaps you meant...\n' + '\n'.join(val) + embed.description += "\nHowever, perhaps you meant...\n" + "\n".join(val) return embed + + +def parse_alias(alias): + if "&&" not in alias: + if alias.startswith('"') and alias.endswith('"'): + return [alias[1:-1]] + return [alias] + + buffer = "" + cmd = [] + try: + for token in shlex.shlex(alias, punctuation_chars="&"): + if token != "&&": + buffer += " " + token + continue + + buffer = buffer.strip() + if buffer.startswith('"') and buffer.endswith('"'): + buffer = buffer[1:-1] + cmd += [buffer] + buffer = "" + except ValueError: + return [] + + buffer = buffer.strip() + if buffer.startswith('"') and buffer.endswith('"'): + buffer = buffer[1:-1] + cmd += [buffer] + + if not all(cmd): + return [] + return cmd From 60b5eb13412885b8cbb20e1580a550383ba46f06 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Sat, 27 Jul 2019 15:25:59 -0700 Subject: [PATCH 27/50] Address semvar stuff --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 707fbd1207..c17564c901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,9 @@ # Changelog All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +This project mostly adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html); +however, insignificant breaking changes does not guarantee a major version bump, see the reasoning [here](https://github.com/kyb3r/modmail/issues/319). # v3.1.0 From d22093721f68519cf819c20c0305ffa05d21db40 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Sat, 27 Jul 2019 18:54:57 -0700 Subject: [PATCH 28/50] misc help and return user_typing --- CHANGELOG.md | 3 ++- bot.py | 7 ------- cogs/utility.py | 18 +++++++++++------- core/config.py | 5 ++--- core/utils.py | 6 +++--- 5 files changed, 18 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c17564c901..66c4d7cf5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ however, insignificant breaking changes does not guarantee a major version bump, - Added a minimalistic version of requirements.txt (named requirements.min.txt) that contains only the absolute minimum of Modmail. - For users having trouble with pipenv or any other reason. - Multi-step alias, see `?help alias add`. Public beta testing, might be unstable. +- Misc commands without cogs are now displayed in `?help`. ### Changes @@ -38,7 +39,7 @@ however, insignificant breaking changes does not guarantee a major version bump, - `?notify` no longer carries over to the next thread. - `discord.NotFound` errors for `on_raw_reaction_add`. -- `mod_typing` and `user_typing` will no longer show when user is blocked. +- `mod_typing` ~~and `user_typing`~~ (`user_typing` is by-design to show now) will no longer show when user is blocked. - Better `?block` usage message. - Resolves errors when message was sent by mods after thread is closed somehow. - Recipient join/leave server messages are limited to only the guild set by `GUILD_ID`. diff --git a/bot.py b/bot.py index f92630faec..6031fd3574 100644 --- a/bot.py +++ b/bot.py @@ -838,13 +838,6 @@ async def _void(*_args, **_kwargs): pass if isinstance(channel, discord.DMChannel): - if await self._process_blocked( - SimpleNamespace( - author=user, channel=SimpleNamespace(send=_void), add_reaction=_void - ) - ): - return - try: user_typing = strtobool(self.config["user_typing"]) except ValueError: diff --git a/cogs/utility.py b/cogs/utility.py index a5adde8345..51d5baa1a5 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -39,13 +39,13 @@ class ModmailHelpCommand(commands.HelpCommand): - async def format_cog_help(self, cog): + async def format_cog_help(self, cog, *, no_cog=False): bot = self.context.bot prefix = self.clean_prefix formats = [""] for cmd in await self.filter_commands( - cog.get_commands(), sort=True, key=get_perm_level + cog.get_commands() if not no_cog else cog, sort=True, key=get_perm_level ): perm_level = get_perm_level(cmd) if perm_level is PermissionLevel.INVALID: @@ -63,16 +63,19 @@ async def format_cog_help(self, cog): embeds = [] for format_ in formats: + description = cog.description or "No description." \ + if not no_cog else "Miscellaneous commands without a category." embed = Embed( - description=f'*{cog.description or "No description."}*', + description=f'*{description}*', color=bot.main_color, ) embed.add_field(name="Commands", value=format_ or "No commands.") continued = " (Continued)" if embeds else "" + name = cog.qualified_name + " - Help" if not no_cog else "Miscellaneous Commands" embed.set_author( - name=cog.qualified_name + " - Help" + continued, + name=name + continued, icon_url=bot.user.avatar_url, ) @@ -88,9 +91,8 @@ def process_help_msg(self, help_: str): async def send_bot_help(self, mapping): embeds = [] - # TODO: Implement for no cog commands - - cogs = list(filter(None, mapping)) + no_cog_commands = sorted(mapping.pop(None), key=lambda c: c.qualified_name) + cogs = sorted(mapping, key=lambda c: c.qualified_name) bot = self.context.bot @@ -105,6 +107,8 @@ async def send_bot_help(self, mapping): for cog in default_cogs: embeds.extend(await self.format_cog_help(cog)) + if no_cog_commands: + embeds.extend(await self.format_cog_help(no_cog_commands, no_cog=True)) p_session = PaginatorSession( self.context, *embeds, destination=self.get_destination() diff --git a/core/config.py b/core/config.py index 301ab69f61..d99d762307 100644 --- a/core/config.py +++ b/core/config.py @@ -115,7 +115,6 @@ class ConfigManager: def __init__(self, bot): self.bot = bot - self.api = self.bot.api self._cache = {} self.ready_event = asyncio.Event() @@ -207,11 +206,11 @@ async def clean_data(self, key: str, val: typing.Any) -> typing.Tuple[str, str]: async def update(self): """Updates the config with data from the cache""" - await self.api.update_config(self.filter_default(self._cache)) + await self.bot.api.update_config(self.filter_default(self._cache)) async def refresh(self) -> dict: """Refreshes internal cache with data from database""" - for k, v in (await self.api.get_config()).items(): + for k, v in (await self.bot.api.get_config()).items(): k = k.lower() if k in self.all_keys: self._cache[k] = v diff --git a/core/utils.py b/core/utils.py index 16fe1675b0..024c6e0f03 100644 --- a/core/utils.py +++ b/core/utils.py @@ -8,8 +8,6 @@ import discord from discord.ext import commands -from core.models import PermissionLevel - def strtobool(val): if isinstance(val, bool): @@ -194,7 +192,9 @@ def match_user_id(text: str) -> int: return -1 -def get_perm_level(cmd) -> PermissionLevel: +def get_perm_level(cmd): + from core.models import PermissionLevel + for check in cmd.checks: perm = getattr(check, "permission_level", None) if perm is not None: From 1f8d2d0a7a1cde45f0c25e0ef34148908e288876 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Sat, 27 Jul 2019 19:25:37 -0700 Subject: [PATCH 29/50] Snippet/alias help msg --- CHANGELOG.md | 3 ++- cogs/utility.py | 40 ++++++++++++++++++++++++++++++++++------ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66c4d7cf5f..b58143ce87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ however, insignificant breaking changes does not guarantee a major version bump, - For users having trouble with pipenv or any other reason. - Multi-step alias, see `?help alias add`. Public beta testing, might be unstable. - Misc commands without cogs are now displayed in `?help`. +- `?help` works for alias and snippets. ### Changes @@ -39,7 +40,7 @@ however, insignificant breaking changes does not guarantee a major version bump, - `?notify` no longer carries over to the next thread. - `discord.NotFound` errors for `on_raw_reaction_add`. -- `mod_typing` ~~and `user_typing`~~ (`user_typing` is by-design to show now) will no longer show when user is blocked. +- `mod_typing` ~~and `user_typing`~~ (`user_typing` is now by-design to show) will no longer show when user is blocked. - Better `?block` usage message. - Resolves errors when message was sent by mods after thread is closed somehow. - Recipient join/leave server messages are limited to only the guild set by `GUILD_ID`. diff --git a/cogs/utility.py b/cogs/utility.py index 51d5baa1a5..a929fc02a2 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -17,7 +17,7 @@ from discord import Embed, Color, Activity, Role from discord.enums import ActivityType, Status from discord.ext import commands -from discord.utils import escape_markdown +from discord.utils import escape_markdown, escape_mentions from aiohttp import ClientResponseError from pkg_resources import parse_version @@ -180,12 +180,40 @@ async def send_group_help(self, group): await self.get_destination().send(embed=embed) - async def send_error_message(self, msg): # pylint: disable=W0221 - logger.warning("CommandNotFound: %s", str(msg)) + async def send_error_message(self, error): + command = self.context.kwargs.get("command") + val = self.context.bot.snippets.get(command) + if val is not None: + return await self.get_destination().send(escape_mentions(f'**`{command}` is a snippet, ' + f'content:**\n\n{val}')) + + val = self.context.bot.aliases.get(command) + if val is not None: + values = parse_alias(val) + + if len(values) == 1: + embed = Embed( + title=f"{command} is an alias.", + color=self.context.bot.main_color, + description=f"`{command}` points to `{escape_markdown(values[0])}`.", + ) + else: + embed = Embed( + title=f"{command} is an alias.", + color=self.context.bot.main_color, + description=f"**`{command}` points to the following steps:**", + ) + for i, val in enumerate(values, start=1): + embed.description += f"\n{i}: {escape_markdown(val)}" + embed.set_footer(text=f'Type "{self.clean_prefix}{self.command_attrs["name"]} alias" for more ' + 'details on aliases.') + return await self.get_destination().send(embed=embed) + + logger.warning("CommandNotFound: %s", str(error)) embed = Embed(color=Color.red()) embed.set_footer( - text=f'Command/Category "{self.context.kwargs.get("command")}" not found.' + text=f'Command/Category "{command}" not found.' ) choices = set() @@ -193,7 +221,7 @@ async def send_error_message(self, msg): # pylint: disable=W0221 for name, cmd in self.context.bot.all_commands.items(): if not cmd.hidden: choices.add(name) - command = self.context.kwargs.get("command") + closest = get_close_matches(command, choices) if closest: embed.add_field( @@ -870,7 +898,7 @@ async def alias(self, ctx, *, name: str.lower = None): else: embed = Embed( color=self.bot.main_color, - description=f"`{name}` points to the following steps:", + description=f"**`{name}` points to the following steps:**", ) for i, val in enumerate(values, start=1): embed.description += f"\n{i}: {escape_markdown(val)}" From 310f65210a211c4b94a350d0b01c9ecd5b5ec88e Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Sun, 28 Jul 2019 12:55:10 -0700 Subject: [PATCH 30/50] Config help --- CHANGELOG.md | 3 + cogs/utility.py | 53 +++++- core/config.py | 14 +- core/config_help.json | 374 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 438 insertions(+), 6 deletions(-) create mode 100644 core/config_help.json diff --git a/CHANGELOG.md b/CHANGELOG.md index b58143ce87..c473256b57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ however, insignificant breaking changes does not guarantee a major version bump, - Multi-step alias, see `?help alias add`. Public beta testing, might be unstable. - Misc commands without cogs are now displayed in `?help`. - `?help` works for alias and snippets. +- `?config help ` shows a help embed for the configuration. ### Changes @@ -35,6 +36,7 @@ however, insignificant breaking changes does not guarantee a major version bump, - `?plugin registry page-number` plugin registry can specify a page number for quick access. - A reworked interface for `?snippet` and `?alias`. - Add an `?snippet raw ` command for viewing the raw content of a snippet (escaped markdown). +- The placeholder channel for the streaming status changed to https://www.twitch.tv/discordmodmail/. ### Fixes @@ -45,6 +47,7 @@ however, insignificant breaking changes does not guarantee a major version bump, - Resolves errors when message was sent by mods after thread is closed somehow. - Recipient join/leave server messages are limited to only the guild set by `GUILD_ID`. - When creating snippets and aliases, it now checks if another snippets/aliases with the same name exists. +- Was looking for `config.json` in the wrong directory. ### Internal diff --git a/cogs/utility.py b/cogs/utility.py index a929fc02a2..4914663b2b 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -733,11 +733,14 @@ async def config(self, ctx): Type `{prefix}config options` to view a list of valid configuration variables. + Type `{prefix}config help config-name` for info + on a config. + To set a configuration variable: - - `{prefix}config set varname value here` + - `{prefix}config set config-name value here` To remove a configuration variable: - - `{prefix}config remove varname` + - `{prefix}config remove config-name` """ await ctx.send_help(ctx.command) @@ -849,6 +852,52 @@ async def config_get(self, ctx, key: str.lower = None): return await ctx.send(embed=embed) + @config.command(name="help", aliases=["info"]) + @checks.has_permissions(PermissionLevel.OWNER) + async def config_help(self, ctx, key: str.lower): + """ + Show information on a specified configuration. + """ + if key not in self.bot.config.public_keys: + embed = Embed( + title="Error", + color=Color.red(), + description=f"`{key}` is an invalid key.", + ) + return await ctx.send(embed=embed) + + config_help = self.bot.config.config_help + info = config_help.get(key) + + if info is None: + embed = Embed( + title="Error", + color=Color.red(), + description=f"No help details found for `{key}`.", + ) + return await ctx.send(embed=embed) + + def fmt(val): + return val.format(prefix=self.bot.prefix, bot=self.bot) + + embed = Embed( + title=f"Configuration description on {key}:", + color=self.bot.main_color + ) + embed.add_field(name='Default:', value=fmt(info['default']), inline=False) + embed.add_field(name='Information:', value=fmt(info['description']), inline=False) + example_text = '' + for example in info['examples']: + example_text += f'- {fmt(example)}\n' + embed.add_field(name='Example(s):', value=example_text, inline=False) + + note_text = '' + for note in info['notes']: + note_text += f'- {fmt(note)}\n' + if note_text: + embed.add_field(name='Notes(s):', value=note_text, inline=False) + return await ctx.send(embed=embed) + @commands.group(aliases=["aliases"], invoke_without_command=True) @checks.has_permissions(PermissionLevel.MODERATOR) async def alias(self, ctx, *, name: str.lower = None): diff --git a/core/config.py b/core/config.py index d99d762307..33e493eb0b 100644 --- a/core/config.py +++ b/core/config.py @@ -24,7 +24,7 @@ class ConfigManager: public_keys = { # activity - "twitch_url": "https://www.twitch.tv/discord-modmail/", + "twitch_url": "https://www.twitch.tv/discordmodmail/", # bot settings "main_category_id": None, "prefix": "?", @@ -55,8 +55,8 @@ class ConfigManager: "thread_self_close_response": "You have closed this Modmail thread.", # moderation "recipient_color": str(discord.Color.gold()), - "mod_tag": None, "mod_color": str(discord.Color.green()), + "mod_tag": None, # anonymous message "anon_username": None, "anon_avatar_url": None, @@ -117,6 +117,7 @@ def __init__(self, bot): self.bot = bot self._cache = {} self.ready_event = asyncio.Event() + self.config_help = {} def __repr__(self): return repr(self._cache) @@ -129,7 +130,7 @@ def populate_cache(self) -> dict: {k.lower(): v for k, v in os.environ.items() if k.lower() in self.all_keys} ) configjson = os.path.join( - os.path.dirname(os.path.abspath(__file__)), "config.json" + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json" ) if os.path.exists(configjson): logger.debug("Loading envs from config.json.") @@ -147,8 +148,13 @@ def populate_cache(self) -> dict: logger.critical( "Failed to load config.json env values.", exc_info=True ) - self._cache = data + + confighelpjson = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "config_help.json" + ) + with open(confighelpjson, "r") as f: + self.config_help = json.load(f) return self._cache async def clean_data(self, key: str, val: typing.Any) -> typing.Tuple[str, str]: diff --git a/core/config_help.json b/core/config_help.json new file mode 100644 index 0000000000..a5b385b431 --- /dev/null +++ b/core/config_help.json @@ -0,0 +1,374 @@ +{ + "twitch_url": { + "default": "`https://www.twitch.tv/discordmodmail/`", + "description": "This channel dictates the linked Twitch channel when the activity is set to \"Streaming\".", + "examples": [ + "`{prefix}config set twitch_url https://www.twitch.tv/yourchannelname/`" + ], + "notes": [ + "This has no effect when the activity is not set to \"Streaming\".", + "See also: `{prefix}help activity`." + ] + }, + "main_category_id": { + "default": "`Modmail` (created with `{prefix}setup`)", + "description": "This is the category where all new threads will be created.\n\nTo change the Modmail category, you will need to find the [category’s ID](https://support.discordapp.com/hc/en-us/articles/206346498).", + "examples": [ + "`{prefix}config set main_category_id 9234932582312` (`9234932582312` is the category ID)" + ], + "notes": [ + "If the Modmail category ended up being non-existent/invalid, Modmail will break. To fix this, run `{prefix}setup` again or set `main_category_id` to a valid category." + ] + }, + "prefix": { + "default": "`?`", + "description": "The prefix of the bot.", + "examples": [ + "`{prefix}prefix !`", + "`{prefix}config set prefix !`" + ], + "notes": [ + "If you forgot the bot prefix, Modmail will always respond to its mention (ping)." + ] + }, + "mention": { + "default": "@here", + "description": "This is the message above user information for when a new thread is created in the channel.", + "examples": [ + "`{prefix}config set mention Yo~ Here's a new thread for ya!`", + "`{prefix}mention Yo~ Here's a new thread for ya!`" + ], + "notes": [ + "Unfortunately, its not currently possible to disable mention." + ] + }, + "main_color": { + "default": "Discord Blurple ([#7289DA](https://placehold.it/100/7289da?text=+))", + "description": "This is the main color for Modmail (help/about/ping embed messages, subscribe, move, etc.).", + "examples": [ + "`{prefix}config set main_color olive green`", + "`{prefix}config set main_color 12de3a`", + "`{prefix}config set main_color #12de3a`", + "`{prefix}config set main_color fff`" + ], + "notes": [ + "Available color names can be found on [Taki's Blog](https://taaku18.github.io/modmail/colors/).", + "See also: `mod_color`, `recipient_color`." + ] + }, + "user_typing": { + "default": "No", + "description": "When this is set to `yes`, whenever the recipient user starts to type in their DM channel, the moderator will see “{bot.user.display_name} is typing…” in the thread channel.", + "examples": [ + "`{prefix}config set user_typing yes`", + "`{prefix}config set user_typing no`" + ], + "notes": [ + "See also: `mod_typing`." + ] + }, + "mod_typing": { + "default": "No", + "description": "When this is set to `yes`, whenever a moderator starts to type in the thread channel, the recipient user will see \"{bot.user.display_name} is typing…\" in their DM channel.", + "examples": [ + "`{prefix}config set mod_typing yes`", + "`{prefix}config set mod_typing no`" + ], + "notes": [ + "See also: `mod_typing`." + ] + }, + "account_age": { + "default": "No age threshold", + "description": "The creation date of the recipient user account must be greater than the number of days, hours, minutes or any time-interval specified by this configuration.", + "examples": [ + "`{prefix}config set account_age P12DT3H` (stands for 12 days and 3 hours in [ISO-8601 Duration Format](https://en.wikipedia.org/wiki/ISO_8601#Durations))", + "`{prefix}config set account_age 3 days and 5 hours` (accepted readable time)" + ], + "notes": [ + "To remove this restriction, do `{prefix}config del account_age`.", + "See also: `guild_age`." + ] + }, + "guild_age": { + "default": "No age threshold", + "description": "The join date of the recipient user into this server must be greater than the number of days, hours, minutes or any time-interval specified by this configuration.", + "examples": [ + "`{prefix}config set guild_age P12DT3H` (stands for 12 days and 3 hours in [ISO-8601 Duration Format](https://en.wikipedia.org/wiki/ISO_8601#Durations))", + "`{prefix}config set guild_age 3 days and 5 hours` (accepted readable time)" + ], + "notes": [ + "To remove this restriction, do `{prefix}config del guild_age`.", + "See also: `account_age`." + ] + }, + "reply_without_command": { + "default": "Disabled", + "description": "Setting this configuration will make all non-command messages sent in the thread channel to be forwarded to the recipient without the need of `{prefix}reply`.", + "examples": [ + "`{prefix}config set reply_without_command yes`", + "`{prefix}config set reply_without_command no`" + ], + "notes": [ + "Unfortunately, anonymous `reply_without_command` is currently not possible." + ] + }, + "log_channel_id": { + "default": "`#bot-logs` (created with `{prefix}setup`)", + "description": "This is the channel where all log messages will be sent (ie. thread close message, update message, etc.).\n\nTo change the log channel, you will need to find the [channel’s ID](https://support.discordapp.com/hc/en-us/articles/206346498). The channel doesn’t necessary have to be under the `main_category`.", + "examples": [ + "`{prefix}config set log_channel_id 9234932582312` (9234932582312 is the channel ID)" + ], + "notes": [ + "If the Modmail logging channel ended up being non-existent/invalid, no logs will be sent." + ] + }, + "sent_emoji": { + "default": "✅", + "description": "This is the emoji added to the message when when a Modmail action is invoked successfully (ie. DM Modmail, edit message, etc.).", + "examples": [ + "`{prefix}config set sent_emoji ✨`" + ], + "notes": [ + "You can disable `sent_emoji` with `{prefix}config set sent_emoji disable`.", + "Custom/animated emojis are also supported, however, the emoji must be added to the server.", + "See also: `blocked_emoji`." + ] + }, + "blocked_emoji": { + "default": "🚫", + "description": "This is the emoji added to the message when when a Modmail action is invoked unsuccessfully (ie. DM Modmail when blocked, failed to reply, etc.).", + "examples": [ + "`{prefix}config set blocked_emoji 🙅‍`" + ], + "notes": [ + "You can disable `blocked_emoji` with `{prefix}config set blocked_emoji disable`.", + "Custom/animated emojis are also supported, however, the emoji must be added to the server.", + "See also: `sent_emoji`." + ] + }, + "close_emoji": { + "default": "🔒", + "description": "This is the emoji the recipient can click to close a thread themselves. The emoji is automatically added to the `thread_creation_response` embed.", + "examples": [ + "`{prefix}config set close_emoji 👍‍`" + ], + "notes": [ + "This will only have an effect when `recipient_thread_close` is enabled.", + "See also: `recipient_thread_close`." + ] + }, + "recipient_thread_close": { + "default": "No", + "description": "Setting this configuration will allow recipients to use the `close_emoji` to close the thread themselves.", + "examples": [ + "`{prefix}config set reply_without_command yes`", + "`{prefix}config set reply_without_command no`" + ], + "notes": [ + "The close emoji is dictated by the configuration `close_emoji`.", + "See also: `close_emoji`." + ] + }, + "thread_auto_close_silently": { + "default": "No", + "description": "Setting this configuration will close silently when the thread auto-closes.", + "examples": [ + "`{prefix}config set thread_auto_close_silently yes`", + "`{prefix}config set thread_auto_close_silently no`" + ], + "notes": [ + "This will only have an effect when `thread_auto_close` is set.", + "See also: `thread_auto_close`." + ] + }, + "thread_auto_close": { + "default": "Never", + "description": "Setting this configuration will close threads automatically after the number of days, hours, minutes or any time-interval specified by this configuration.", + "examples": [ + "`{prefix}config set thread_auto_close P12DT3H` (stands for 12 days and 3 hours in [ISO-8601 Duration Format](https://en.wikipedia.org/wiki/ISO_8601#Durations))", + "`{prefix}config set thread_auto_close 3 days and 5 hours` (accepted readable time)" + ], + "notes": [ + "To disable auto close, do `{prefix}config del thread_auto_close`.", + "To prevent a thread from auto-closing, do `{prefix}close cancel`.", + "See also: `thread_auto_close_silently`, `thread_auto_close_response`." + ] + }, + "thread_auto_close_response": { + "default": "\"This thread has been closed automatically due to inactivity after {{timeout}}.\"", + "description": "This is the message to display when the thread when the thread auto-closes.", + "examples": [ + "`{prefix}config set thread_auto_close_response Your close message here.`" + ], + "notes": [ + "Its possible to use `{{timeout}}` as a placeholder for a formatted timeout text.", + "This will not have an effect when `thread_auto_close_silently` is enabled.", + "Discord flavoured markdown is fully supported in `thread_auto_close_response`.", + "See also: `thread_auto_close`, `thread_auto_close_silently`." + ] + }, + "thread_creation_response": { + "default": "\"The staff team will get back to you as soon as possible.\"", + "description": "This is the message embed content sent to the recipient upon the creation of a new thread.", + "examples": [ + "`{prefix}config set thread_creation_response You will be contacted shortly.`" + ], + "notes": [ + "Discord flavoured markdown is fully supported in `thread_creation_response`.", + "See also: `thread_creation_title`, `thread_creation_footer`, `thread_close_response`." + ] + }, + "thread_creation_footer": { + "default": "\"Your message has been sent\"", + "description": "This is the message embed footer sent to the recipient upon the creation of a new thread.", + "examples": [ + "`{prefix}config set thread_creation_footer Please Hold...`" + ], + "notes": [ + "This is used in place of `thread_self_closable_creation_footer` when `recipient_thread_close` is enabled.", + "See also: `thread_creation_title`, `thread_creation_response`, `thread_self_closable_creation_footer`, `thread_close_footer`." + ] + }, + "thread_self_closable_creation_footer": { + "default": "\"Click the lock to close the thread\"", + "description": "This is the message embed footer sent to the recipient upon the creation of a new thread.", + "examples": [ + "`{prefix}config set thread_self_closable_creation_footer Please Hold...`" + ], + "notes": [ + "This is used in place of `thread_creation_footer` when `recipient_thread_close` is disabled.", + "See also: `thread_creation_title`, `thread_creation_response`, `thread_creation_footer`." + ] + }, + "thread_creation_title": { + "default": "\"Thread Created\"", + "description": "This is the message embed title sent to the recipient upon the creation of a new thread.", + "examples": [ + "`{prefix}config set thread_creation_title Hello!`" + ], + "notes": [ + "See also: `thread_creation_response`, `thread_creation_footer`, `thread_close_title`." + ] + }, + "thread_close_footer": { + "default": "\"Replying will create a new thread\"", + "description": "This is the message embed footer sent to the recipient upon the closure of a thread.", + "examples": [ + "`{prefix}config set thread_close_footer Bye!`" + ], + "notes": [ + "See also: `thread_close_title`, `thread_close_response`, `thread_creation_footer`." + ] + }, + "thread_close_title": { + "default": "\"Thread Closed\"", + "description": "This is the message embed title sent to the recipient upon the closure of a thread.", + "examples": [ + "`{prefix}config set thread_close_title Farewell!`" + ], + "notes": [ + "See also: `thread_close_response`, `thread_close_footer`, `thread_creation_title`." + ] + }, + "thread_close_response": { + "default": "\"{{closer.mention}} has closed this Modmail thread\"", + "description": "This is the message embed content sent to the recipient upon the closure of a thread.", + "examples": [ + "`{prefix}config set thread_close_response Your message is appreciated!`" + ], + "notes": [ + "When `recipient_thread_close` is enabled and the recipient closed their own thread, `thread_self_close_response` is used instead of this configuration.", + "You may use the `{{closer}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that closed the thread.", + "`{{loglink}}` can be used as a placeholder substitute for the full URL linked to the thread in the log viewer and `{{loglink}}` for the unique key (ie. 3ifdm9204) of the log.", + "Discord flavoured markdown is fully supported in `thread_close_response`.", + "See also: `thread_close_title`, `thread_close_footer`, `thread_self_close_response`, `thread_creation_response`." + ] + }, + "thread_self_close_response": { + "default": "\"You have closed this Modmail thread.\"", + "description": "This is the message embed content sent to the recipient upon the closure of a their own thread.", + "examples": [ + "`{prefix}config set thread_self_close_response You have closed your own thread...`" + ], + "notes": [ + "When `recipient_thread_close` is disabled or the thread wasn't closed by the recipient, `thread_close_response` is used instead of this configuration.", + "You may use the `{{closer}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that closed the thread.", + "`{{loglink}}` can be used as a placeholder substitute for the full URL linked to the thread in the log viewer and `{{loglink}}` for the unique key (ie. 3ifdm9204) of the log.", + "Discord flavoured markdown is fully supported in `thread_self_close_response`.", + "See also: `thread_close_title`, `thread_close_footer`, `thread_close_response`." + ] + }, + "recipient_color": { + "default": "Discord Gold ([#F1C40F](https://placehold.it/100/f1c40f?text=+))", + "description": "This is the color of the messages sent by the recipient, this applies to messages received in the thread channel.", + "examples": [ + "`{prefix}config set recipient_color dark beige`", + "`{prefix}config set recipient_color cb7723`", + "`{prefix}config set recipient_color #cb7723`", + "`{prefix}config set recipient_color c4k`" + ], + "notes": [ + "Available color names can be found on [Taki's Blog](https://taaku18.github.io/modmail/colors/).", + "See also: `main_color`, `mod_color`." + ] + }, + "mod_color": { + "default": "Discord Green ([#2ECC71](https://placehold.it/100/2ecc71?text=+))", + "description": "This is the color of the messages sent by the moderators, this applies to messages within in the thread channel and the DM thread messages received by the recipient.", + "examples": [ + "`{prefix}config set mod_color dark beige`", + "`{prefix}config set mod_color cb7723`", + "`{prefix}config set mod_color #cb7723`", + "`{prefix}config set mod_color c4k`" + ], + "notes": [ + "Available color names can be found on [Taki's Blog](https://taaku18.github.io/modmail/colors/).", + "See also: `main_color`, `recipient_color`." + ] + }, + "mod_tag": { + "default": "The moderator's highest role", + "description": "This is the name tag in the “footer” section of the embeds sent by moderators in the recipient DM and thread channel.", + "examples": [ + "`{prefix}config set mod_tag Moderator`" + ], + "notes": [ + "When the message is sent anonymously, `anon_tag` is used instead.", + "See also: `anon_tag`." + ] + }, + "anon_username": { + "default": "Fallback on `mod_tag`", + "description": "This is the name in the “author” section of the embeds sent by anonymous moderators in the recipient DM.", + "examples": [ + "`{prefix}config set anon_username Incognito Mod`" + ], + "notes": [ + "A detailed labeling of anonymous message configurations: https://i.imgur.com/SKOC42Z.png.", + "See also: `anon_avatar_url`, `anon_tag`." + ] + }, + "anon_avatar_url": { + "default": "Server avatar", + "description": "This is the avatar of the embeds sent by anonymous moderators in the recipient DM.", + "examples": [ + "`{prefix}config set anon_avatar_url https://path.to/your/avatar.png` (you will need to upload the avatar to somewhere)" + ], + "notes": [ + "A detailed labeling of anonymous message configurations: https://i.imgur.com/SKOC42Z.png.", + "See also: `anon_username`, `anon_tag`." + ] + }, + "anon_tag": { + "default": "\"Response\"", + "description": "This is the name tag in the “footer” section of the embeds sent by anonymous moderators in the recipient DM.", + "examples": [ + "`{prefix}config set anon_tag Support Agent`" + ], + "notes": [ + "A detailed labeling of anonymous message configurations: https://i.imgur.com/SKOC42Z.png.", + "See also: `anon_avatar_url`, `anon_username`, `mod_tag`." + ] + } +} \ No newline at end of file From afa3c9ad3bcea4ea480cf1943fe60b2c5316661b Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Sun, 28 Jul 2019 14:23:41 -0700 Subject: [PATCH 31/50] I'm dumb --- core/checks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/checks.py b/core/checks.py index 42da8cf4b8..3c7f8b701f 100644 --- a/core/checks.py +++ b/core/checks.py @@ -81,8 +81,8 @@ async def check_permissions( # pylint: disable=R0911 role.id in level_permissions[level.name] for role in author_roles ) has_perm_id = ctx.author.id in level_permissions[level.name] - return has_perm_role or has_perm_id - + if has_perm_role or has_perm_id: + return True return False From 3afa23e9e53c773cfba75806db8b2e6fa394a3a2 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Sun, 28 Jul 2019 14:27:22 -0700 Subject: [PATCH 32/50] More fluent English --- core/config_help.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/config_help.json b/core/config_help.json index a5b385b431..1cf4776c8f 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -57,7 +57,7 @@ ] }, "user_typing": { - "default": "No", + "default": "Disabled", "description": "When this is set to `yes`, whenever the recipient user starts to type in their DM channel, the moderator will see “{bot.user.display_name} is typing…” in the thread channel.", "examples": [ "`{prefix}config set user_typing yes`", @@ -68,7 +68,7 @@ ] }, "mod_typing": { - "default": "No", + "default": "Disabled", "description": "When this is set to `yes`, whenever a moderator starts to type in the thread channel, the recipient user will see \"{bot.user.display_name} is typing…\" in their DM channel.", "examples": [ "`{prefix}config set mod_typing yes`", @@ -159,7 +159,7 @@ ] }, "recipient_thread_close": { - "default": "No", + "default": "Disabled", "description": "Setting this configuration will allow recipients to use the `close_emoji` to close the thread themselves.", "examples": [ "`{prefix}config set reply_without_command yes`", From 894ad2fc0272935515b8706fd09facfa4541a5d8 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Sun, 28 Jul 2019 14:39:00 -0700 Subject: [PATCH 33/50] Numbers carries over in snippet/alias now --- cogs/modmail.py | 4 ++-- cogs/utility.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cogs/modmail.py b/cogs/modmail.py index 4eae4037e3..1e8f3f3f95 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -146,9 +146,9 @@ async def snippet(self, ctx, *, name: str.lower = None): embeds = [] - for names in zip_longest(*(iter(sorted(self.bot.snippets)),) * 15): + for i, names in enumerate(zip_longest(*(iter(sorted(self.bot.snippets)),) * 15)): description = "\n".join( - ": ".join((str(a), b)) + ": ".join((str(a + i * 15), b)) for a, b in enumerate( takewhile(lambda x: x is not None, names), start=1 ) diff --git a/cogs/utility.py b/cogs/utility.py index 4914663b2b..2f9d0f60d9 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -965,9 +965,9 @@ async def alias(self, ctx, *, name: str.lower = None): embeds = [] - for names in zip_longest(*(iter(sorted(self.bot.aliases)),) * 15): + for i, names in enumerate(zip_longest(*(iter(sorted(self.bot.aliases)),) * 15)): description = "\n".join( - ": ".join((str(a), b)) + ": ".join((str(a + i * 15), b)) for a, b in enumerate( takewhile(lambda x: x is not None, names), start=1 ) From 7f6992f70c7259543ec5948f22cbc2e48eef9551 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Sun, 28 Jul 2019 15:41:42 -0700 Subject: [PATCH 34/50] Displays image/thumbnail for config help --- cogs/utility.py | 9 ++++++++- core/config_help.json | 29 ++++++++++++++++------------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/cogs/utility.py b/cogs/utility.py index 2f9d0f60d9..86f1866796 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -895,7 +895,14 @@ def fmt(val): for note in info['notes']: note_text += f'- {fmt(note)}\n' if note_text: - embed.add_field(name='Notes(s):', value=note_text, inline=False) + embed.add_field(name='Note(s):', value=note_text, inline=False) + + if info.get('image') is not None: + embed.set_image(url=fmt(info['image'])) + + if info.get('thumbnail') is not None: + embed.set_thumbnail(url=fmt(info['thumbnail'])) + return await ctx.send(embed=embed) @commands.group(aliases=["aliases"], invoke_without_command=True) diff --git a/core/config_help.json b/core/config_help.json index 1cf4776c8f..37106f286a 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -43,7 +43,7 @@ ] }, "main_color": { - "default": "Discord Blurple ([#7289DA](https://placehold.it/100/7289da?text=+))", + "default": "Discord Blurple [#7289DA](https://placehold.it/100/7289da?text=+)", "description": "This is the main color for Modmail (help/about/ping embed messages, subscribe, move, etc.).", "examples": [ "`{prefix}config set main_color olive green`", @@ -54,7 +54,8 @@ "notes": [ "Available color names can be found on [Taki's Blog](https://taaku18.github.io/modmail/colors/).", "See also: `mod_color`, `recipient_color`." - ] + ], + "thumbnail": "https://placehold.it/100/7289da?text=+" }, "user_typing": { "default": "Disabled", @@ -285,7 +286,7 @@ "See also: `thread_close_title`, `thread_close_footer`, `thread_self_close_response`, `thread_creation_response`." ] }, - "thread_self_close_response": { + "thread_self_close_response": { "default": "\"You have closed this Modmail thread.\"", "description": "This is the message embed content sent to the recipient upon the closure of a their own thread.", "examples": [ @@ -300,7 +301,7 @@ ] }, "recipient_color": { - "default": "Discord Gold ([#F1C40F](https://placehold.it/100/f1c40f?text=+))", + "default": "Discord Gold [#F1C40F](https://placehold.it/100/f1c40f?text=+)", "description": "This is the color of the messages sent by the recipient, this applies to messages received in the thread channel.", "examples": [ "`{prefix}config set recipient_color dark beige`", @@ -311,10 +312,11 @@ "notes": [ "Available color names can be found on [Taki's Blog](https://taaku18.github.io/modmail/colors/).", "See also: `main_color`, `mod_color`." - ] + ], + "thumbnail": "https://placehold.it/100/f1c40f?text=+" }, "mod_color": { - "default": "Discord Green ([#2ECC71](https://placehold.it/100/2ecc71?text=+))", + "default": "Discord Green [#2ECC71](https://placehold.it/100/2ecc71?text=+)", "description": "This is the color of the messages sent by the moderators, this applies to messages within in the thread channel and the DM thread messages received by the recipient.", "examples": [ "`{prefix}config set mod_color dark beige`", @@ -325,7 +327,8 @@ "notes": [ "Available color names can be found on [Taki's Blog](https://taaku18.github.io/modmail/colors/).", "See also: `main_color`, `recipient_color`." - ] + ], + "thumbnail": "https://placehold.it/100/2ecc71?text=+" }, "mod_tag": { "default": "The moderator's highest role", @@ -345,9 +348,9 @@ "`{prefix}config set anon_username Incognito Mod`" ], "notes": [ - "A detailed labeling of anonymous message configurations: https://i.imgur.com/SKOC42Z.png.", "See also: `anon_avatar_url`, `anon_tag`." - ] + ], + "image": "https://i.imgur.com/SKOC42Z.png" }, "anon_avatar_url": { "default": "Server avatar", @@ -356,9 +359,9 @@ "`{prefix}config set anon_avatar_url https://path.to/your/avatar.png` (you will need to upload the avatar to somewhere)" ], "notes": [ - "A detailed labeling of anonymous message configurations: https://i.imgur.com/SKOC42Z.png.", "See also: `anon_username`, `anon_tag`." - ] + ], + "image": "https://i.imgur.com/SKOC42Z.png" }, "anon_tag": { "default": "\"Response\"", @@ -367,8 +370,8 @@ "`{prefix}config set anon_tag Support Agent`" ], "notes": [ - "A detailed labeling of anonymous message configurations: https://i.imgur.com/SKOC42Z.png.", "See also: `anon_avatar_url`, `anon_username`, `mod_tag`." - ] + ], + "image": "https://i.imgur.com/SKOC42Z.png" } } \ No newline at end of file From bdb2bcf71da23176f16b65e42b0410e1054bc3c4 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Sun, 28 Jul 2019 21:49:09 -0700 Subject: [PATCH 35/50] Paginate config options and patched a thread open bug --- CHANGELOG.md | 2 + cogs/modmail.py | 4 +- cogs/plugins.py | 2 +- cogs/utility.py | 111 ++++++++++++++++++++++++++++-------------------- core/config.py | 15 ++++--- core/thread.py | 2 +- 6 files changed, 81 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c473256b57..b0c5110ce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,8 @@ however, insignificant breaking changes does not guarantee a major version bump, - A reworked interface for `?snippet` and `?alias`. - Add an `?snippet raw ` command for viewing the raw content of a snippet (escaped markdown). - The placeholder channel for the streaming status changed to https://www.twitch.tv/discordmodmail/. +- Removed unclear `rm` alias for some `remove` commands. +- Paginate `?config options`. ### Fixes diff --git a/cogs/modmail.py b/cogs/modmail.py index 1e8f3f3f95..7949688577 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -146,7 +146,9 @@ async def snippet(self, ctx, *, name: str.lower = None): embeds = [] - for i, names in enumerate(zip_longest(*(iter(sorted(self.bot.snippets)),) * 15)): + for i, names in enumerate( + zip_longest(*(iter(sorted(self.bot.snippets)),) * 15) + ): description = "\n".join( ": ".join((str(a + i * 15), b)) for a, b in enumerate( diff --git a/cogs/plugins.py b/cogs/plugins.py index 4f204ef10e..6ed1dccf1b 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -260,7 +260,7 @@ async def plugin_add(self, ctx, *, plugin_name: str): ) await ctx.send(embed=embed) - @plugin.command(name="remove", aliases=["del", "delete", "rm"]) + @plugin.command(name="remove", aliases=["del", "delete"]) @checks.has_permissions(PermissionLevel.OWNER) async def plugin_remove(self, ctx, *, plugin_name: str): """Remove a plugin.""" diff --git a/cogs/utility.py b/cogs/utility.py index 86f1866796..d90bd390b5 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -63,21 +63,22 @@ async def format_cog_help(self, cog, *, no_cog=False): embeds = [] for format_ in formats: - description = cog.description or "No description." \ - if not no_cog else "Miscellaneous commands without a category." - embed = Embed( - description=f'*{description}*', - color=bot.main_color, + description = ( + cog.description or "No description." + if not no_cog + else "Miscellaneous commands without a category." ) + embed = Embed(description=f"*{description}*", color=bot.main_color) embed.add_field(name="Commands", value=format_ or "No commands.") continued = " (Continued)" if embeds else "" - name = cog.qualified_name + " - Help" if not no_cog else "Miscellaneous Commands" - embed.set_author( - name=name + continued, - icon_url=bot.user.avatar_url, + name = ( + cog.qualified_name + " - Help" + if not no_cog + else "Miscellaneous Commands" ) + embed.set_author(name=name + continued, icon_url=bot.user.avatar_url) embed.set_footer( text=f'Type "{prefix}{self.command_attrs["name"]} command" ' @@ -184,8 +185,9 @@ async def send_error_message(self, error): command = self.context.kwargs.get("command") val = self.context.bot.snippets.get(command) if val is not None: - return await self.get_destination().send(escape_mentions(f'**`{command}` is a snippet, ' - f'content:**\n\n{val}')) + return await self.get_destination().send( + escape_mentions(f"**`{command}` is a snippet, " f"content:**\n\n{val}") + ) val = self.context.bot.aliases.get(command) if val is not None: @@ -205,16 +207,16 @@ async def send_error_message(self, error): ) for i, val in enumerate(values, start=1): embed.description += f"\n{i}: {escape_markdown(val)}" - embed.set_footer(text=f'Type "{self.clean_prefix}{self.command_attrs["name"]} alias" for more ' - 'details on aliases.') + embed.set_footer( + text=f'Type "{self.clean_prefix}{self.command_attrs["name"]} alias" for more ' + "details on aliases." + ) return await self.get_destination().send(embed=embed) logger.warning("CommandNotFound: %s", str(error)) embed = Embed(color=Color.red()) - embed.set_footer( - text=f'Command/Category "{command}" not found.' - ) + embed.set_footer(text=f'Command/Category "{command}" not found.') choices = set() @@ -748,10 +750,20 @@ async def config(self, ctx): @checks.has_permissions(PermissionLevel.OWNER) async def config_options(self, ctx): """Return a list of valid configuration names you can change.""" - allowed = self.bot.config.public_keys - valid = ", ".join(f"`{k}`" for k in allowed) - embed = Embed(title="Valid Keys", description=valid, color=self.bot.main_color) - return await ctx.send(embed=embed) + embeds = [] + for names in zip_longest(*(iter(sorted(self.bot.config.public_keys)),) * 15): + description = "\n".join( + f"`{name}`" for name in takewhile(lambda x: x is not None, names) + ) + embed = Embed( + title="Available configuration keys:", + color=self.bot.main_color, + description=description, + ) + embeds.append(embed) + + session = PaginatorSession(ctx, *embeds) + await session.run() @config.command(name="set", aliases=["add"]) @checks.has_permissions(PermissionLevel.OWNER) @@ -784,7 +796,7 @@ async def config_set(self, ctx, key: str.lower, *, value: str): return await ctx.send(embed=embed) - @config.command(name="remove", aliases=["del", "delete", "rm"]) + @config.command(name="remove", aliases=["del", "delete"]) @checks.has_permissions(PermissionLevel.OWNER) async def config_remove(self, ctx, key: str.lower): """Delete a set configuration variable.""" @@ -867,9 +879,8 @@ async def config_help(self, ctx, key: str.lower): return await ctx.send(embed=embed) config_help = self.bot.config.config_help - info = config_help.get(key) - if info is None: + if key not in config_help: embed = Embed( title="Error", color=Color.red(), @@ -880,30 +891,40 @@ async def config_help(self, ctx, key: str.lower): def fmt(val): return val.format(prefix=self.bot.prefix, bot=self.bot) - embed = Embed( - title=f"Configuration description on {key}:", - color=self.bot.main_color - ) - embed.add_field(name='Default:', value=fmt(info['default']), inline=False) - embed.add_field(name='Information:', value=fmt(info['description']), inline=False) - example_text = '' - for example in info['examples']: - example_text += f'- {fmt(example)}\n' - embed.add_field(name='Example(s):', value=example_text, inline=False) + index = 0 + embeds = [] + for i, (current_key, info) in enumerate(config_help.items()): + if current_key == key: + index = i + embed = Embed( + title=f"Configuration description on {current_key}:", + color=self.bot.main_color, + ) + embed.add_field(name="Default:", value=fmt(info["default"]), inline=False) + embed.add_field( + name="Information:", value=fmt(info["description"]), inline=False + ) + example_text = "" + for example in info["examples"]: + example_text += f"- {fmt(example)}\n" + embed.add_field(name="Example(s):", value=example_text, inline=False) - note_text = '' - for note in info['notes']: - note_text += f'- {fmt(note)}\n' - if note_text: - embed.add_field(name='Note(s):', value=note_text, inline=False) + note_text = "" + for note in info["notes"]: + note_text += f"- {fmt(note)}\n" + if note_text: + embed.add_field(name="Note(s):", value=note_text, inline=False) - if info.get('image') is not None: - embed.set_image(url=fmt(info['image'])) + if info.get("image") is not None: + embed.set_image(url=fmt(info["image"])) - if info.get('thumbnail') is not None: - embed.set_thumbnail(url=fmt(info['thumbnail'])) + if info.get("thumbnail") is not None: + embed.set_thumbnail(url=fmt(info["thumbnail"])) + embeds += [embed] - return await ctx.send(embed=embed) + paginator = PaginatorSession(ctx, *embeds) + paginator.current = index + await paginator.run() @commands.group(aliases=["aliases"], invoke_without_command=True) @checks.has_permissions(PermissionLevel.MODERATOR) @@ -1265,9 +1286,7 @@ async def permissions_add_level( return await ctx.send(embed=embed) @permissions.group( - name="remove", - aliases=["del", "delete", "rm", "revoke"], - invoke_without_command=True, + name="remove", aliases=["del", "delete", "revoke"], invoke_without_command=True ) @checks.has_permissions(PermissionLevel.OWNER) async def permissions_remove(self, ctx): diff --git a/core/config.py b/core/config.py index 33e493eb0b..ae0a1f0a3e 100644 --- a/core/config.py +++ b/core/config.py @@ -3,6 +3,7 @@ import logging import os import typing +from collections import namedtuple from copy import deepcopy from dotenv import load_dotenv @@ -129,12 +130,12 @@ def populate_cache(self) -> dict: data.update( {k.lower(): v for k, v in os.environ.items() if k.lower() in self.all_keys} ) - configjson = os.path.join( + config_json = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json" ) - if os.path.exists(configjson): + if os.path.exists(config_json): logger.debug("Loading envs from config.json.") - with open(configjson, "r") as f: + with open(config_json, "r") as f: # Config json should override env vars try: data.update( @@ -150,11 +151,13 @@ def populate_cache(self) -> dict: ) self._cache = data - confighelpjson = os.path.join( + config_help_json = os.path.join( os.path.dirname(os.path.abspath(__file__)), "config_help.json" ) - with open(confighelpjson, "r") as f: - self.config_help = json.load(f) + with open(config_help_json, "r") as f: + Entry = namedtuple("Entry", ["index", "embed"]) + self.config_help = dict(sorted(json.load(f).items())) + return self._cache async def clean_data(self, key: str, val: typing.Any) -> typing.Tuple[str, str]: diff --git a/core/thread.py b/core/thread.py index d4213a0c93..bb2f244926 100644 --- a/core/thread.py +++ b/core/thread.py @@ -229,7 +229,7 @@ def _format_info_embed(self, user, log_url, log_count, color): embed.set_author(name=str(user), icon_url=user.avatar_url, url=log_url) # embed.set_thumbnail(url=avi) - if member is None: + if member is not None: joined = str((time - member.joined_at).days) # embed.add_field(name='Joined', value=joined + days(joined)) embed.description += f", joined {days(joined)}" From c7dd2fe3590f7688143d7761ca3d664eb329f93c Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Sun, 28 Jul 2019 21:55:42 -0700 Subject: [PATCH 36/50] Another bug... --- core/thread.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/thread.py b/core/thread.py index bb2f244926..12bb71ab3c 100644 --- a/core/thread.py +++ b/core/thread.py @@ -193,7 +193,7 @@ def _format_info_embed(self, user, log_url, log_count, color): # key = log_url.split('/')[-1] role_names = "" - if member is None: + if member is not None: sep_server = self.bot.using_multiple_server_setup separator = ", " if sep_server else " " From 0ce91d036b2bd1af40e0ce2f9d0d5edbc81f1b82 Mon Sep 17 00:00:00 2001 From: Stephen <48072084+StephenDaDev@users.noreply.github.com> Date: Sun, 28 Jul 2019 22:06:53 -0400 Subject: [PATCH 37/50] Fix Expired Invite (#334) --- SPONSORS.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SPONSORS.json b/SPONSORS.json index 989f1dfdfd..5f79ff80ea 100644 --- a/SPONSORS.json +++ b/SPONSORS.json @@ -21,7 +21,7 @@ }, { "name": "Discord Server!", - "value": "[**Click Here**](https://discord.gg/s8Ddphx)" + "value": "[**Click Here**](https://discord.gg/V8ErqHb)" } ] } From fb257d4810f9c0fdb5a3f77509625682ca072c8b Mon Sep 17 00:00:00 2001 From: Kyber Date: Mon, 29 Jul 2019 13:39:08 +1000 Subject: [PATCH 38/50] Add dockerfile --- Dockerfile | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..d33f146c9e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM kennethreitz/pipenv + +COPY . /app + +CMD python3 bot.py From 31c116489c1a9d63e99e2041c52200c4592a2461 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Mon, 29 Jul 2019 02:19:35 -0700 Subject: [PATCH 39/50] perms stuff --- CHANGELOG.md | 4 +- cogs/utility.py | 458 +++++++++++++++++++++--------------------------- 2 files changed, 198 insertions(+), 264 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0c5110ce5..47934e28ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ however, insignificant breaking changes does not guarantee a major version bump, - Misc commands without cogs are now displayed in `?help`. - `?help` works for alias and snippets. - `?config help ` shows a help embed for the configuration. +- Support setting permissions for sub commands. ### Changes @@ -61,7 +62,8 @@ however, insignificant breaking changes does not guarantee a major version bump, - Bumped discord.py version to 1.2.3. - Use discord tasks for metadata loop. - More debug based logging. - +- Reduce redundancies in `?perms` sub commands. + # v3.0.3 ### Added diff --git a/cogs/utility.py b/cogs/utility.py index d90bd390b5..1252341d89 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -1209,331 +1209,263 @@ async def permissions(self, ctx): """ await ctx.send_help(ctx.command) - @permissions.group(name="add", invoke_without_command=True) - @checks.has_permissions(PermissionLevel.OWNER) - async def permissions_add(self, ctx): - """Add a permission to a command or a permission level.""" - await ctx.send_help(ctx.command) - - @permissions_add.command(name="command") - @checks.has_permissions(PermissionLevel.OWNER) - async def permissions_add_command( - self, ctx, command: str, *, user_or_role: Union[User, Role, str] - ): - """ - Add a user, role, or everyone permission to use a command. - - Do not ping `@everyone` for granting permission to everyone, use "everyone" or "all" instead, - `user_or_role` may be a role ID, name, mention, user ID, name, mention, "all", or "everyone". - """ - if command not in self.bot.all_commands: - embed = Embed( - title="Error", - color=Color.red(), - description="The command you are attempting to point " - f"to does not exist: `{command}`.", - ) - return await ctx.send(embed=embed) - + @staticmethod + def _verify_user_or_role(user_or_role): if hasattr(user_or_role, "id"): - value = user_or_role.id + return user_or_role.id elif user_or_role in {"everyone", "all"}: - value = -1 + return -1 else: raise commands.BadArgument(f'User or Role "{user_or_role}" not found') - await self.bot.update_perms(self.bot.all_commands[command].name, value) - embed = Embed( - title="Success", - color=self.bot.main_color, - description=f"Permission for {command} was successfully updated.", - ) - return await ctx.send(embed=embed) - - @permissions_add.command(name="level", aliases=["group"]) + @permissions.command(name="add", usage="[command/level] [name] [user_or_role]") @checks.has_permissions(PermissionLevel.OWNER) - async def permissions_add_level( - self, ctx, level: str, *, user_or_role: Union[User, Role, str] + async def permissions_add( + self, ctx, type_: str.lower, name: str, *, user_or_role: Union[User, Role, str] ): """ - Add a user, role, or everyone permission to use commands of a permission level. + Add a permission to a command or a permission level. + + For sub commands, wrap the complete command name with quotes. + To find a list of permission levels, see `{prefix}help perms`. + + Examples: + - `{prefix}perms add level REGULAR everyone` + - `{prefix}perms add command reply @user` + - `{prefix}perms add command "plugin enabled" @role` + - `{prefix}perms add command help 984301093849028` Do not ping `@everyone` for granting permission to everyone, use "everyone" or "all" instead, - `user_or_role` may be a role ID, name, mention, user ID, name, mention, "all", or "everyone". """ - if level.upper() not in PermissionLevel.__members__: + + if type_ not in {"command", "level"}: + return await ctx.send_help(ctx.command) + + command = level = None + if type_ == "command": + command = self.bot.get_command(name.lower()) + check = command is not None + else: + check = name.upper() in PermissionLevel.__members__ + level = PermissionLevel[name.upper()] if check else None + + if not check: embed = Embed( title="Error", color=Color.red(), - description="The permission level you are attempting to point " - f"to does not exist: `{level}`.", + description=f"The referenced {type_} does not exist: `{name}`.", ) return await ctx.send(embed=embed) - if hasattr(user_or_role, "id"): - value = user_or_role.id - elif user_or_role in {"everyone", "all"}: - value = -1 + value = self._verify_user_or_role(user_or_role) + if type_ == "command": + await self.bot.update_perms(command.qualified_name, value) + name = command.qualified_name else: - raise commands.BadArgument(f'User or Role "{user_or_role}" not found') + await self.bot.update_perms(level, value) + name = level.name - await self.bot.update_perms(PermissionLevel[level.upper()], value) embed = Embed( title="Success", color=self.bot.main_color, - description=f"Permission for {level} was successfully updated.", + description=f"Permission for `{name}` was successfully updated.", ) return await ctx.send(embed=embed) - @permissions.group( - name="remove", aliases=["del", "delete", "revoke"], invoke_without_command=True + @permissions.command( + name="remove", + aliases=["del", "delete", "revoke"], + usage="[command/level] [name] [user_or_role]", ) @checks.has_permissions(PermissionLevel.OWNER) - async def permissions_remove(self, ctx): - """Remove permission to use a command or permission level.""" - await ctx.send_help(ctx.command) - - @permissions_remove.command(name="command") - @checks.has_permissions(PermissionLevel.OWNER) - async def permissions_remove_command( - self, ctx, command: str, *, user_or_role: Union[User, Role, str] + async def permissions_remove( + self, ctx, type_: str.lower, name: str, *, user_or_role: Union[User, Role, str] ): """ - Remove a user, role, or everyone permission to use a command. + Remove permission to use a command or permission level. + + For sub commands, wrap the complete command name with quotes. + To find a list of permission levels, see `{prefix}help perms`. + + Examples: + - `{prefix}perms remove level REGULAR everyone` + - `{prefix}perms remove command reply @user` + - `{prefix}perms remove command "plugin enabled" @role` + - `{prefix}perms remove command help 984301093849028` Do not ping `@everyone` for granting permission to everyone, use "everyone" or "all" instead, - `user_or_role` may be a role ID, name, mention, user ID, name, mention, "all", or "everyone". """ - if command not in self.bot.all_commands: - embed = Embed( - title="Error", - color=Color.red(), - description="The command you are attempting to point " - f"to does not exist: `{command}`.", - ) - return await ctx.send(embed=embed) + if type_ not in {"command", "level"}: + return await ctx.send_help(ctx.command) - if hasattr(user_or_role, "id"): - value = user_or_role.id - elif user_or_role in {"everyone", "all"}: - value = -1 + level = None + if type_ == "command": + command = self.bot.get_command(name.lower()) + name = command.qualified_name if command is not None else name else: - raise commands.BadArgument(f'User or Role "{user_or_role}" not found') + if name.upper() not in PermissionLevel.__members__: + embed = Embed( + title="Error", + color=Color.red(), + description=f"The referenced {type_} does not exist: `{name}`.", + ) + return await ctx.send(embed=embed) + level = PermissionLevel[name.upper()] + name = level.name + + value = self._verify_user_or_role(user_or_role) + await self.bot.update_perms(level or name, value, add=False) - await self.bot.update_perms( - self.bot.all_commands[command].name, value, add=False - ) embed = Embed( title="Success", color=self.bot.main_color, - description=f"Permission for {command} was successfully updated.", + description=f"Permission for `{name}` was successfully updated.", ) return await ctx.send(embed=embed) - @permissions_remove.command(name="level", aliases=["group"]) - @checks.has_permissions(PermissionLevel.OWNER) - async def permissions_remove_level( - self, ctx, level: str, *, user_or_role: Union[User, Role, str] - ): - """ - Remove a user, role, or everyone permission to use commands of a permission level. - - Do not ping `@everyone` for granting permission to everyone, use "everyone" or "all" instead, - `user_or_role` may be a role ID, name, mention, user ID, name, mention, "all", or "everyone". - """ - if level.upper() not in PermissionLevel.__members__: + def _get_perm(self, ctx, name, type_): + if type_ == "command": + permissions = self.bot.config["command_permissions"].get(name, []) + else: + permissions = self.bot.config["level_permissions"].get(name, []) + if not permissions: embed = Embed( - title="Error", - color=Color.red(), - description="The permission level you are attempting to point " - f"to does not exist: `{level}`.", + title=f"Permission entries for {type_} `{name}`:", + description="No permission entries found.", + color=self.bot.main_color, ) - return await ctx.send(embed=embed) - - if hasattr(user_or_role, "id"): - value = user_or_role.id - elif user_or_role in {"everyone", "all"}: - value = -1 else: - raise commands.BadArgument(f'User or Role "{user_or_role}" not found') + values = [] + for perm in permissions: + if perm == -1: + values.insert(0, "**everyone**") + continue + member = ctx.guild.get_member(perm) + if member is not None: + values.append(member.mention) + continue + user = self.bot.get_user(perm) + if user is not None: + values.append(user.mention) + continue + role = ctx.guild.get_role(perm) + if role is not None: + values.append(role.mention) + else: + values.append(str(perm)) - await self.bot.update_perms(PermissionLevel[level.upper()], value, add=False) - embed = Embed( - title="Success", - color=self.bot.main_color, - description=f"Permission for {level} was successfully updated.", - ) - return await ctx.send(embed=embed) + embed = Embed( + title=f"Permission entries for {type_} `{name}`:", + description=", ".join(values), + color=self.bot.main_color, + ) + return embed - @permissions.group(name="get", invoke_without_command=True) + @permissions.command(name="get", usage="[@user] or [command/level] [name]") @checks.has_permissions(PermissionLevel.OWNER) - async def permissions_get(self, ctx, *, user_or_role: Union[User, Role, str]): + async def permissions_get( + self, ctx, user_or_role: Union[User, Role, str], *, name: str = None + ): """ View the currently-set permissions. - You can specify `user_or_role` as an alternative to get-by-command or get-by-level. + To find a list of permission levels, see `{prefix}help perms`. + + Examples: + - `{prefix}perms get @user` + - `{prefix}perms get 984301093849028` + - `{prefix}perms get command reply` + - `{prefix}perms get command plugin remove` + - `{prefix}perms get level SUPPORTER` Do not ping `@everyone` for granting permission to everyone, use "everyone" or "all" instead, - `user_or_role` may be a role ID, name, mention, user ID, name, mention, "all", or "everyone". """ - if hasattr(user_or_role, "id"): - value = user_or_role.id - elif user_or_role in {"everyone", "all"}: - value = -1 - else: - raise commands.BadArgument(f'User or Role "{user_or_role}" not found') + if name is None and user_or_role not in {"command", "level"}: + value = self._verify_user_or_role(user_or_role) - cmds = [] - levels = [] - for cmd in self.bot.commands: - permissions = self.bot.config["command_permissions"].get(cmd.name, []) - if value in permissions: - cmds.append(cmd.name) - for level in PermissionLevel: - permissions = self.bot.config["level_permissions"].get(level.name, []) - if value in permissions: - levels.append(level.name) - mention = user_or_role.name if hasattr(user_or_role, "name") else user_or_role - desc_cmd = ( - ", ".join(map(lambda x: f"`{x}`", cmds)) - if cmds - else "No permission entries found." - ) - desc_level = ( - ", ".join(map(lambda x: f"`{x}`", levels)) - if levels - else "No permission entries found." - ) + cmds = [] + levels = [] - embeds = [ - Embed( - title=f"{mention} has permission with the following commands:", - description=desc_cmd, - color=self.bot.main_color, - ), - Embed( - title=f"{mention} has permission with the following permission groups:", - description=desc_level, - color=self.bot.main_color, - ), - ] - p_session = PaginatorSession(ctx, *embeds) - return await p_session.run() - - @permissions_get.command(name="command") - @checks.has_permissions(PermissionLevel.OWNER) - async def permissions_get_command(self, ctx, *, command: str = None): - """View currently-set permissions for a command.""" + done = set() + for _, command in self.bot.all_commands.items(): + if command not in done: + done.add(command) + permissions = self.bot.config["command_permissions"].get( + command.qualified_name, [] + ) + if value in permissions: + cmds.append(command.qualified_name) + + for level in PermissionLevel: + permissions = self.bot.config["level_permissions"].get(level.name, []) + if value in permissions: + levels.append(level.name) + + mention = getattr(user_or_role, "name", user_or_role) + desc_cmd = ( + ", ".join(map(lambda x: f"`{x}`", cmds)) + if cmds + else "No permission entries found." + ) + desc_level = ( + ", ".join(map(lambda x: f"`{x}`", levels)) + if levels + else "No permission entries found." + ) - def get_command(cmd): - permissions = self.bot.config["command_permissions"].get(cmd.name, []) - if not permissions: - embed = Embed( - title=f"Permission entries for command `{cmd.name}`:", - description="No permission entries found.", + embeds = [ + Embed( + title=f"{mention} has permission with the following commands:", + description=desc_cmd, color=self.bot.main_color, - ) - else: - values = [] - for perm in permissions: - if perm == -1: - values.insert(0, "**everyone**") - continue - member = ctx.guild.get_member(perm) - if member is not None: - values.append(member.mention) - continue - user = self.bot.get_user(perm) - if user is not None: - values.append(user.mention) - continue - role = ctx.guild.get_role(perm) - if role is not None: - values.append(role.mention) - else: - values.append(str(perm)) - - embed = Embed( - title=f"Permission entries for command `{cmd.name}`:", - description=", ".join(values), + ), + Embed( + title=f"{mention} has permission with the following permission groups:", + description=desc_level, color=self.bot.main_color, - ) - return embed - - embeds = [] - if command is not None: - if command not in self.bot.all_commands: - embed = Embed( - title="Error", - color=Color.red(), - description="The command you are attempting to point " - f"to does not exist: `{command}`.", - ) - return await ctx.send(embed=embed) - embeds.append(get_command(self.bot.all_commands[command])) + ), + ] else: - for cmd in self.bot.commands: - embeds.append(get_command(cmd)) - - p_session = PaginatorSession(ctx, *embeds) - return await p_session.run() - - @permissions_get.command(name="level", aliases=["group"]) - @checks.has_permissions(PermissionLevel.OWNER) - async def permissions_get_level(self, ctx, *, level: str = None): - """View currently-set permissions for commands of a permission level.""" + if user_or_role not in {"command", "level"}: + return await ctx.send_help(ctx.command) + embeds = [] + if name is not None: + name = name.strip('"') + command = level = None + if user_or_role == "command": + command = self.bot.get_command(name.lower()) + check = command is not None + else: + check = name.upper() in PermissionLevel.__members__ + level = PermissionLevel[name.upper()] if check else None + + if not check: + embed = Embed( + title="Error", + color=Color.red(), + description=f"The referenced {user_or_role} does not exist: `{name}`.", + ) + return await ctx.send(embed=embed) - def get_level(perm_level): - permissions = self.bot.config["level_permissions"].get(perm_level.name, []) - if not permissions: - embed = Embed( - title="Permission entries for permission " - f"level `{perm_level.name}`:", - description="No permission entries found.", - color=self.bot.main_color, - ) + if user_or_role == "command": + embeds.append( + self._get_perm(ctx, command.qualified_name, "command") + ) + else: + embeds.append(self._get_perm(ctx, level.name, "level")) else: - values = [] - for perm in permissions: - if perm == -1: - values.insert(0, "**everyone**") - continue - member = ctx.guild.get_member(perm) - if member is not None: - values.append(member.mention) - continue - user = self.bot.get_user(perm) - if user is not None: - values.append(user.mention) - continue - role = ctx.guild.get_role(perm) - if role is not None: - values.append(role.mention) - else: - values.append(str(perm)) - - embed = Embed( - title=f"Permission entries for permission level `{perm_level.name}`:", - description=", ".join(values), - color=self.bot.main_color, - ) - return embed - - embeds = [] - if level is not None: - if level.upper() not in PermissionLevel.__members__: - embed = Embed( - title="Error", - color=Color.red(), - description="The permission level you are attempting to point " - f"to does not exist: `{level}`.", - ) - return await ctx.send(embed=embed) - embeds.append(get_level(PermissionLevel[level.upper()])) - else: - for perm_level in PermissionLevel: - embeds.append(get_level(perm_level)) + if user_or_role == "command": + done = set() + for _, command in self.bot.all_commands.items(): + if command not in done: + done.add(command) + embeds.append( + self._get_perm(ctx, command.qualified_name, "command") + ) + else: + for perm_level in PermissionLevel: + embeds.append(self._get_perm(ctx, perm_level.name, "level")) p_session = PaginatorSession(ctx, *embeds) return await p_session.run() From af904218d129beb5167b3ed0f3ac0669c2cf5814 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Mon, 29 Jul 2019 12:09:27 -0700 Subject: [PATCH 40/50] Fix stuff with viewing perm --- CHANGELOG.md | 4 ++-- cogs/modmail.py | 2 +- cogs/utility.py | 19 +++++++++---------- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47934e28ff..8d57ffdc9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -225,7 +225,7 @@ Un-deprecated the `OWNERS` config variable to support discord developer team acc ### New Permissions System - A brand new permission system! Replacing the old guild-based permissions (ie. manage channels, manage messages), the new system enables you to customize your desired permission level specific to a command or a group of commands for a role or user. -- There are five permission groups/levels: +- There are five permission levels: - Owner [5] - Administrator [4] - Moderator [3] @@ -247,7 +247,7 @@ The same applies to individual commands permissions: To revoke permission, use `remove` instead of `add`. -To view all roles and users with permission for a permission group or command do: +To view all roles and users with permission for a permission level or command do: - `?permissions get command command-name` - `?permissions get level owner` diff --git a/cogs/modmail.py b/cogs/modmail.py index 7949688577..c29cfec308 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -87,7 +87,7 @@ async def setup(self, ctx): await self.bot.config.update() await ctx.send( "Successfully set up server.\n" - "Consider setting permission groups to give access " + "Consider setting permission levels to give access " "to roles or users the ability to use Modmail.\n" f"Type `{self.bot.prefix}permissions` for more info." ) diff --git a/cogs/utility.py b/cogs/utility.py index 1252341d89..4f7d17a502 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -220,9 +220,9 @@ async def send_error_message(self, error): choices = set() - for name, cmd in self.context.bot.all_commands.items(): + for cmd in self.context.bot.walk_commands(): if not cmd.hidden: - choices.add(name) + choices.add(cmd.name) closest = get_close_matches(command, choices) if closest: @@ -1221,7 +1221,7 @@ def _verify_user_or_role(user_or_role): @permissions.command(name="add", usage="[command/level] [name] [user_or_role]") @checks.has_permissions(PermissionLevel.OWNER) async def permissions_add( - self, ctx, type_: str.lower, name: str, *, user_or_role: Union[User, Role, str] + self, ctx, type_: str.lower, name: str, *, user_or_role: Union[Role, User, str] ): """ Add a permission to a command or a permission level. @@ -1279,7 +1279,7 @@ async def permissions_add( ) @checks.has_permissions(PermissionLevel.OWNER) async def permissions_remove( - self, ctx, type_: str.lower, name: str, *, user_or_role: Union[User, Role, str] + self, ctx, type_: str.lower, name: str, *, user_or_role: Union[Role, User, str] ): """ Remove permission to use a command or permission level. @@ -1300,8 +1300,7 @@ async def permissions_remove( level = None if type_ == "command": - command = self.bot.get_command(name.lower()) - name = command.qualified_name if command is not None else name + name = getattr(self.bot.get_command(name.lower()), "qualified_name", name) else: if name.upper() not in PermissionLevel.__members__: embed = Embed( @@ -1364,7 +1363,7 @@ def _get_perm(self, ctx, name, type_): @permissions.command(name="get", usage="[@user] or [command/level] [name]") @checks.has_permissions(PermissionLevel.OWNER) async def permissions_get( - self, ctx, user_or_role: Union[User, Role, str], *, name: str = None + self, ctx, user_or_role: Union[Role, User, str], *, name: str = None ): """ View the currently-set permissions. @@ -1388,7 +1387,7 @@ async def permissions_get( levels = [] done = set() - for _, command in self.bot.all_commands.items(): + for command in self.bot.walk_commands(): if command not in done: done.add(command) permissions = self.bot.config["command_permissions"].get( @@ -1421,7 +1420,7 @@ async def permissions_get( color=self.bot.main_color, ), Embed( - title=f"{mention} has permission with the following permission groups:", + title=f"{mention} has permission with the following permission levels:", description=desc_level, color=self.bot.main_color, ), @@ -1457,7 +1456,7 @@ async def permissions_get( else: if user_or_role == "command": done = set() - for _, command in self.bot.all_commands.items(): + for command in self.bot.walk_commands(): if command not in done: done.add(command) embeds.append( From c596f1c48b8c6f21e61c64b4446d9625fd54deb7 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Mon, 29 Jul 2019 12:50:22 -0700 Subject: [PATCH 41/50] 1-5 as level names --- CHANGELOG.md | 1 + cogs/utility.py | 38 +++++++++++++++++++++++++++++++------- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d57ffdc9d..ee45406fb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ however, insignificant breaking changes does not guarantee a major version bump, - `?help` works for alias and snippets. - `?config help ` shows a help embed for the configuration. - Support setting permissions for sub commands. +- Support numbers (1-5) as substitutes for Permission Level REGULAR - OWNER in `?perms` sub commands. ### Changes diff --git a/cogs/utility.py b/cogs/utility.py index 4f7d17a502..62d02ebeba 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -1218,6 +1218,22 @@ def _verify_user_or_role(user_or_role): else: raise commands.BadArgument(f'User or Role "{user_or_role}" not found') + @staticmethod + def _parse_level(name): + name = name.upper() + try: + return PermissionLevel[name] + except KeyError: + pass + transform = { + "1": PermissionLevel.REGULAR, + "2": PermissionLevel.SUPPORTER, + "3": PermissionLevel.MODERATOR, + "4": PermissionLevel.ADMINISTRATOR, + "5": PermissionLevel.OWNER, + } + return transform.get(name) + @permissions.command(name="add", usage="[command/level] [name] [user_or_role]") @checks.has_permissions(PermissionLevel.OWNER) async def permissions_add( @@ -1246,8 +1262,8 @@ async def permissions_add( command = self.bot.get_command(name.lower()) check = command is not None else: - check = name.upper() in PermissionLevel.__members__ - level = PermissionLevel[name.upper()] if check else None + level = self._parse_level(name) + check = level is not None if not check: embed = Embed( @@ -1302,14 +1318,14 @@ async def permissions_remove( if type_ == "command": name = getattr(self.bot.get_command(name.lower()), "qualified_name", name) else: - if name.upper() not in PermissionLevel.__members__: + level = self._parse_level(name) + if level is None: embed = Embed( title="Error", color=Color.red(), description=f"The referenced {type_} does not exist: `{name}`.", ) return await ctx.send(embed=embed) - level = PermissionLevel[name.upper()] name = level.name value = self._verify_user_or_role(user_or_role) @@ -1370,9 +1386,15 @@ async def permissions_get( To find a list of permission levels, see `{prefix}help perms`. + To view all command and level permissions: + Examples: - `{prefix}perms get @user` - `{prefix}perms get 984301093849028` + + To view all users and roles of a command or level permission: + + Examples: - `{prefix}perms get command reply` - `{prefix}perms get command plugin remove` - `{prefix}perms get level SUPPORTER` @@ -1401,7 +1423,9 @@ async def permissions_get( if value in permissions: levels.append(level.name) - mention = getattr(user_or_role, "name", user_or_role) + mention = getattr( + user_or_role, "name", getattr(user_or_role, "id", user_or_role) + ) desc_cmd = ( ", ".join(map(lambda x: f"`{x}`", cmds)) if cmds @@ -1436,8 +1460,8 @@ async def permissions_get( command = self.bot.get_command(name.lower()) check = command is not None else: - check = name.upper() in PermissionLevel.__members__ - level = PermissionLevel[name.upper()] if check else None + level = self._parse_level(name) + check = level is not None if not check: embed = Embed( From 3a07329b79941832bb1370b21dc0233f478ffe7c Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Tue, 30 Jul 2019 17:25:19 -0700 Subject: [PATCH 42/50] Add doc for snippet raw --- cogs/modmail.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cogs/modmail.py b/cogs/modmail.py index c29cfec308..32d716ec89 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -117,7 +117,7 @@ async def snippet(self, ctx, *, name: str.lower = None): with `{prefix}snippet-name`, the message "A pre-defined text." will be sent to the recipient. - Currently, there is not a default anonymous snippet command; however, a workaround + Currently, there is not a built-in anonymous snippet command; however, a workaround is available using `{prefix}alias`. Here is how: - `{prefix}alias add snippet-name anonreply A pre-defined anonymous text.` @@ -165,6 +165,9 @@ async def snippet(self, ctx, *, name: str.lower = None): @snippet.command(name="raw") @checks.has_permissions(PermissionLevel.SUPPORTER) async def snippet_raw(self, ctx, *, name: str.lower): + """ + View the raw content of a snippet. + """ val = self.bot.snippets.get(name) if val is None: embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") From 92fad07af76c6d89c2b8a81ec0b6d078df04cb38 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Tue, 30 Jul 2019 17:39:27 -0700 Subject: [PATCH 43/50] Fix log url prefix Signed-off-by: Taaku18 <45324516+Taaku18@users.noreply.github.com> --- cogs/modmail.py | 12 ++++-------- core/clients.py | 10 ++++++++-- core/thread.py | 10 ++++------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/cogs/modmail.py b/cogs/modmail.py index 32d716ec89..dc4368160c 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -570,16 +570,12 @@ def format_log_embeds(self, logs, avatar_url): title = f"Total Results Found ({len(logs)})" for entry in logs: - - key = entry["key"] - created_at = parser.parse(entry["created_at"]) - prefix = self.bot.config["log_url_prefix"] - if prefix == "NONE": - prefix = "" - - log_url = self.bot.config["log_url"].strip("/") + f"{prefix}/{key}" + prefix = self.bot.config['log_url_prefix'].strip('/') + if prefix == 'NONE': + prefix = '' + log_url = f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{entry['key']}" username = entry["recipient"]["name"] + "#" username += entry["recipient"]["discriminator"] diff --git a/core/clients.py b/core/clients.py index a4ed847622..31a73be390 100644 --- a/core/clients.py +++ b/core/clients.py @@ -99,7 +99,10 @@ async def get_log(self, channel_id: Union[str, int]) -> dict: async def get_log_link(self, channel_id: Union[str, int]) -> str: doc = await self.get_log(channel_id) logger.debug("Retrieving log link for channel %s.", channel_id) - return f"{self.bot.config['log_url'].strip('/')}{self.bot.config['log_url_prefix']}/{doc['key']}" + prefix = self.bot.config['log_url_prefix'].strip('/') + if prefix == 'NONE': + prefix = '' + return f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{doc['key']}" async def create_log_entry( self, recipient: Member, channel: TextChannel, creator: Member @@ -135,7 +138,10 @@ async def create_log_entry( } ) logger.debug("Created a log entry, key %s.", key) - return f"{self.bot.config['log_url'].strip('/')}{self.bot.config['log_url_prefix']}/{key}" + prefix = self.bot.config['log_url_prefix'].strip('/') + if prefix == 'NONE': + prefix = '' + return f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{key}" async def get_config(self) -> dict: conf = await self.db.config.find_one({"bot_id": self.bot.user.id}) diff --git a/core/thread.py b/core/thread.py index 12bb71ab3c..7e8a2574db 100644 --- a/core/thread.py +++ b/core/thread.py @@ -338,12 +338,10 @@ async def _close( ) if isinstance(log_data, dict): - prefix = self.bot.config["log_url_prefix"] - if prefix == "NONE": - prefix = "" - log_url = ( - f"{self.bot.config['log_url'].strip('/')}{prefix}/{log_data['key']}" - ) + prefix = self.bot.config['log_url_prefix'].strip('/') + if prefix == 'NONE': + prefix = '' + log_url = f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{log_data['key']}" if log_data["messages"]: content = str(log_data["messages"][0]["content"]) From 5a26c28900616ca2958376c4f0e7bbf723957472 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Tue, 30 Jul 2019 22:07:52 -0700 Subject: [PATCH 44/50] Config help on protected keys --- CHANGELOG.md | 1 + cogs/modmail.py | 6 ++-- cogs/utility.py | 18 ++++++----- core/config_help.json | 72 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee45406fb2..49d41d382f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ however, insignificant breaking changes does not guarantee a major version bump, - `disable_recipient_thread_close` is removed, a new configuration variable `recipient_thread_close` replaces it which defaults to False. - Truthy and falsy values for binary configuration variables are now interpreted respectfully. +- `LOG_URL_PREFIX` cannot be set to "NONE" to specify no additional path in the future, "/" is the new method. ### Added diff --git a/cogs/modmail.py b/cogs/modmail.py index dc4368160c..693adafb18 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -572,9 +572,9 @@ def format_log_embeds(self, logs, avatar_url): for entry in logs: created_at = parser.parse(entry["created_at"]) - prefix = self.bot.config['log_url_prefix'].strip('/') - if prefix == 'NONE': - prefix = '' + prefix = self.bot.config["log_url_prefix"].strip("/") + if prefix == "NONE": + prefix = "" log_url = f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{entry['key']}" username = entry["recipient"]["name"] + "#" diff --git a/cogs/utility.py b/cogs/utility.py index 62d02ebeba..672d8f3448 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -844,8 +844,9 @@ async def config_get(self, ctx, key: str.lower = None): color=Color.red(), description=f"`{key}` is an invalid key.", ) - valid_keys = [f"`{k}`" for k in keys] - embed.add_field(name="Valid keys", value=", ".join(valid_keys)) + embed.set_footer( + text=f'Type "{self.bot.prefix}config options" for a list of config variables.' + ) else: embed = Embed( @@ -870,7 +871,9 @@ async def config_help(self, ctx, key: str.lower): """ Show information on a specified configuration. """ - if key not in self.bot.config.public_keys: + if not ( + key in self.bot.config.public_keys or key in self.bot.config.protected_keys + ): embed = Embed( title="Error", color=Color.red(), @@ -904,10 +907,11 @@ def fmt(val): embed.add_field( name="Information:", value=fmt(info["description"]), inline=False ) - example_text = "" - for example in info["examples"]: - example_text += f"- {fmt(example)}\n" - embed.add_field(name="Example(s):", value=example_text, inline=False) + if info["examples"]: + example_text = "" + for example in info["examples"]: + example_text += f"- {fmt(example)}\n" + embed.add_field(name="Example(s):", value=example_text, inline=False) note_text = "" for note in info["notes"]: diff --git a/core/config_help.json b/core/config_help.json index 37106f286a..f4e8eae36f 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -373,5 +373,77 @@ "See also: `anon_avatar_url`, `anon_username`, `mod_tag`." ], "image": "https://i.imgur.com/SKOC42Z.png" + }, + "modmail_guild_id": { + "default": "Fallback on `GUILD_ID`", + "description": "The ID of the discord server where the threads channels should be created (receiving server).", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "guild_id": { + "default": "None, required", + "description": "The ID of the discord server where recipient users reside (users server).", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "log_url": { + "default": "https://example.com/", + "description": "The base log viewer URL link, leave this as-is to not configure a log viewer.", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "log_url_prefix": { + "default": "`/logs`", + "description": "The path to your log viewer extending from your `LOG_URL`, set this to `/` to specify no extra path to the log viewer.", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "mongo_uri": { + "default": "None, required", + "description": "A MongoDB SRV connection string.", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "owners": { + "default": "None, required", + "description": "A list of definite bot owners, use `{prefix}perms add level OWNER @user` to set flexible bot owners.", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file, ~~`config.json` file~~ (removed), or environment (config) variables." + ] + }, + "token": { + "default": "None, required", + "description": "Your bot token as found in the Discord Developer Portal.", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file, ~~`config.json` file~~ (removed), or environment (config) variables." + ] + }, + "log_level": { + "default": "INFO", + "description": "The logging level for logging to stdout.", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file, ~~`config.json` file~~ (removed), or environment (config) variables." + ] } } \ No newline at end of file From 30a6c7f6bc6547f25450ec09209df60e2251f53c Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Wed, 31 Jul 2019 01:51:03 -0700 Subject: [PATCH 45/50] Added raw alias --- .pylintrc | 2 +- CHANGELOG.md | 1 + app.json | 4 ---- cogs/modmail.py | 11 +++-------- cogs/plugins.py | 2 +- cogs/utility.py | 44 +++++++++++++++++++++++++------------------- core/checks.py | 4 ++-- core/config.py | 2 -- core/thread.py | 19 +++++++++---------- core/utils.py | 12 ++++++++++-- 10 files changed, 52 insertions(+), 49 deletions(-) diff --git a/.pylintrc b/.pylintrc index 1bed8185c7..a45837fa82 100644 --- a/.pylintrc +++ b/.pylintrc @@ -19,7 +19,7 @@ ignore-patterns= # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the # number of processors available to use. -jobs=1 +jobs=0 # Control the amount of potential inferred values when inferring a single # object. This can help the performance when dealing with large functions or diff --git a/CHANGELOG.md b/CHANGELOG.md index 49d41d382f..f904043075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ however, insignificant breaking changes does not guarantee a major version bump, - `?plugin registry page-number` plugin registry can specify a page number for quick access. - A reworked interface for `?snippet` and `?alias`. - Add an `?snippet raw ` command for viewing the raw content of a snippet (escaped markdown). + - Add an `?alias raw ` command for viewing the raw content of a alias (escaped markdown). - The placeholder channel for the streaming status changed to https://www.twitch.tv/discordmodmail/. - Removed unclear `rm` alias for some `remove` commands. - Paginate `?config options`. diff --git a/app.json b/app.json index 8d16cd75c2..e4f5032e81 100644 --- a/app.json +++ b/app.json @@ -15,10 +15,6 @@ "description": "Comma separated user IDs of people that are allowed to use owner only commands. (eval and update).", "required": true }, - "GITHUB_ACCESS_TOKEN": { - "description": "Your personal access token for GitHub, adding this gives you the ability to use the 'update' command, which will sync your fork with the main repo.", - "required": false - }, "MONGO_URI": { "description": "Mongo DB connection URI for self-hosting your data.", "required": true diff --git a/cogs/modmail.py b/cogs/modmail.py index 693adafb18..081385d202 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -1,7 +1,7 @@ import asyncio import logging from datetime import datetime -from itertools import zip_longest, takewhile +from itertools import zip_longest from typing import Optional, Union from types import SimpleNamespace as param @@ -17,7 +17,7 @@ from core.models import PermissionLevel from core.paginator import PaginatorSession from core.time import UserFriendlyTime, human_timedelta -from core.utils import format_preview, User, create_not_found_embed +from core.utils import format_preview, User, create_not_found_embed, format_description logger = logging.getLogger("Modmail") @@ -149,12 +149,7 @@ async def snippet(self, ctx, *, name: str.lower = None): for i, names in enumerate( zip_longest(*(iter(sorted(self.bot.snippets)),) * 15) ): - description = "\n".join( - ": ".join((str(a + i * 15), b)) - for a, b in enumerate( - takewhile(lambda x: x is not None, names), start=1 - ) - ) + description = format_description(i, names) embed = discord.Embed(color=self.bot.main_color, description=description) embed.set_author(name="Snippets", icon_url=ctx.guild.icon_url) embeds.append(embed) diff --git a/cogs/plugins.py b/cogs/plugins.py index 6ed1dccf1b..ebb9a72241 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -288,7 +288,7 @@ async def plugin_remove(self, ctx, *, plugin_name: str): for i in self.bot.config["plugins"] ): # if there are no more of such repos, delete the folder - def onerror(func, path, exc_info): # pylint: disable=W0613 + def onerror(func, path, _): if not os.access(path, os.W_OK): # Is the error an access error? os.chmod(path, stat.S_IWUSR) diff --git a/cogs/utility.py b/cogs/utility.py index 672d8f3448..b1fe7aa7b3 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -33,6 +33,7 @@ get_perm_level, create_not_found_embed, parse_alias, + format_description, ) logger = logging.getLogger("Modmail") @@ -248,11 +249,10 @@ def __init__(self, bot): verify_checks=False, command_attrs={"help": "Shows this help message."} ) # Looks a bit ugly - # noinspection PyProtectedMember - self.bot.help_command._command_impl = checks.has_permissions( # pylint: disable=W0212 + self.bot.help_command._command_impl = checks.has_permissions( # pylint: disable=protected-access PermissionLevel.REGULAR )( - self.bot.help_command._command_impl # pylint: disable=W0212 + self.bot.help_command._command_impl # pylint: disable=protected-access ) self.bot.help_command.cog = self @@ -998,12 +998,7 @@ async def alias(self, ctx, *, name: str.lower = None): embeds = [] for i, names in enumerate(zip_longest(*(iter(sorted(self.bot.aliases)),) * 15)): - description = "\n".join( - ": ".join((str(a + i * 15), b)) - for a, b in enumerate( - takewhile(lambda x: x is not None, names), start=1 - ) - ) + description = format_description(i, names) embed = Embed(color=self.bot.main_color, description=description) embed.set_author(name="Command Aliases", icon_url=ctx.guild.icon_url) embeds.append(embed) @@ -1011,6 +1006,18 @@ async def alias(self, ctx, *, name: str.lower = None): session = PaginatorSession(ctx, *embeds) await session.run() + @alias.command(name="raw") + @checks.has_permissions(PermissionLevel.MODERATOR) + async def alias_raw(self, ctx, *, name: str.lower): + """ + View the raw content of an alias. + """ + val = self.bot.aliases.get(name) + if val is None: + embed = create_not_found_embed(name, self.bot.aliases.keys(), "Alias") + return await ctx.send(embed=embed) + return await ctx.send(escape_markdown(escape_mentions(val)).replace("<", "\\<")) + @alias.command(name="add") @checks.has_permissions(PermissionLevel.MODERATOR) async def alias_add(self, ctx, name: str.lower, *, value): @@ -1027,36 +1034,36 @@ async def alias_add(self, ctx, name: str.lower, *, value): - This will fail: `{prefix}alias add reply You'll need to type && to work` - Correct method: `{prefix}alias add reply "You'll need to type && to work"` """ + embed = None if self.bot.get_command(name): embed = Embed( title="Error", color=Color.red(), description=f"A command with the same name already exists: `{name}`.", ) - return await ctx.send(embed=embed) - if name in self.bot.aliases: + elif name in self.bot.aliases: embed = Embed( title="Error", color=Color.red(), description=f"Another alias with the same name already exists: `{name}`.", ) - return await ctx.send(embed=embed) - if name in self.bot.snippets: + elif name in self.bot.snippets: embed = Embed( title="Error", color=Color.red(), description=f"A snippet with the same name already exists: `{name}`.", ) - return await ctx.send(embed=embed) - if len(name) > 120: + elif len(name) > 120: embed = Embed( title="Error", color=Color.red(), description=f"Alias names cannot be longer than 120 characters.", ) + + if embed is not None: return await ctx.send(embed=embed) values = parse_alias(value) @@ -1106,7 +1113,7 @@ async def alias_add(self, ctx, name: str.lower, *, value): return await ctx.send(embed=embed) embed.description += f"\n{i}: {val}" - self.bot.aliases[name] = "&&".join(values) + self.bot.aliases[name] = " && ".join(values) await self.bot.config.update() return await ctx.send(embed=embed) @@ -1217,10 +1224,9 @@ async def permissions(self, ctx): def _verify_user_or_role(user_or_role): if hasattr(user_or_role, "id"): return user_or_role.id - elif user_or_role in {"everyone", "all"}: + if user_or_role in {"everyone", "all"}: return -1 - else: - raise commands.BadArgument(f'User or Role "{user_or_role}" not found') + raise commands.BadArgument(f'User or Role "{user_or_role}" not found') @staticmethod def _parse_level(name): diff --git a/core/checks.py b/core/checks.py index 3c7f8b701f..503877b3a7 100644 --- a/core/checks.py +++ b/core/checks.py @@ -33,7 +33,7 @@ async def predicate(ctx): if not has_perm and ctx.command.qualified_name != "help": logger.error( - "You does not have permission to use this command: `%s` (%s).", + "You do not have permission to use this command: `%s` (%s).", str(ctx.command.qualified_name), str(permission_level.name), ) @@ -43,7 +43,7 @@ async def predicate(ctx): return commands.check(predicate) -async def check_permissions( # pylint: disable=R0911 +async def check_permissions( # pylint: disable=too-many-return-statements ctx, command_name, permission_level ) -> bool: """Logic for checking permissions for a command for a user""" diff --git a/core/config.py b/core/config.py index ae0a1f0a3e..e7776cb39d 100644 --- a/core/config.py +++ b/core/config.py @@ -3,7 +3,6 @@ import logging import os import typing -from collections import namedtuple from copy import deepcopy from dotenv import load_dotenv @@ -155,7 +154,6 @@ def populate_cache(self) -> dict: os.path.dirname(os.path.abspath(__file__)), "config_help.json" ) with open(config_help_json, "r") as f: - Entry = namedtuple("Entry", ["index", "embed"]) self.config_help = dict(sorted(json.load(f).items())) return self._cache diff --git a/core/thread.py b/core/thread.py index 7e8a2574db..95eeadc3dd 100644 --- a/core/thread.py +++ b/core/thread.py @@ -338,9 +338,9 @@ async def _close( ) if isinstance(log_data, dict): - prefix = self.bot.config['log_url_prefix'].strip('/') - if prefix == 'NONE': - prefix = '' + prefix = self.bot.config["log_url_prefix"].strip("/") + if prefix == "NONE": + prefix = "" log_url = f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{log_data['key']}" if log_data["messages"]: @@ -410,7 +410,9 @@ async def _close( await asyncio.gather(*tasks) async def cancel_closure( - self, auto_close: bool = False, all: bool = False # pylint: disable=W0622 + self, + auto_close: bool = False, + all: bool = False, # pylint: disable=redefined-builtin ) -> None: if self.close_task is not None and (not auto_close or all): self.close_task.cancel() @@ -746,8 +748,7 @@ async def send( file_upload_count += 1 if from_mod: - # noinspection PyUnresolvedReferences,PyDunderSlots - embed.color = self.bot.mod_color # pylint: disable=E0237 + embed.colour = self.bot.mod_color # Anonymous reply sent in thread channel if anonymous and isinstance(destination, discord.TextChannel): embed.set_footer(text="Anonymous Reply") @@ -760,12 +761,10 @@ async def send( else: embed.set_footer(text=self.bot.config["anon_tag"]) elif note: - # noinspection PyUnresolvedReferences,PyDunderSlots - embed.color = discord.Color.blurple() # pylint: disable=E0237 + embed.colour = discord.Color.blurple() else: embed.set_footer(text=f"Recipient") - # noinspection PyUnresolvedReferences,PyDunderSlots - embed.color = self.bot.recipient_color # pylint: disable=E0237 + embed.colour = self.bot.recipient_color try: await destination.trigger_typing() diff --git a/core/utils.py b/core/utils.py index 024c6e0f03..89f9b55f81 100644 --- a/core/utils.py +++ b/core/utils.py @@ -2,7 +2,8 @@ import shlex import typing from difflib import get_close_matches -from distutils.util import strtobool as _stb # pylint: disable=E0401 +from distutils.util import strtobool as _stb +from itertools import takewhile from urllib import parse import discord @@ -37,7 +38,7 @@ async def convert(self, ctx, argument): return discord.Object(int(match.group(1))) -def truncate(text: str, max: int = 50) -> str: # pylint: disable=W0622 +def truncate(text: str, max: int = 50) -> str: # pylint: disable=redefined-builtin """ Reduces the string to `max` length, by trimming the message into "...". @@ -253,3 +254,10 @@ def parse_alias(alias): if not all(cmd): return [] return cmd + + +def format_description(i, names): + return "\n".join( + ": ".join((str(a + i * 15), b)) + for a, b in enumerate(takewhile(lambda x: x is not None, names), start=1) + ) From c89ce1495f2e7ce5bdae2167347722f11c7afb7d Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Wed, 31 Jul 2019 23:48:00 -0600 Subject: [PATCH 46/50] Emoji wasn't showing cuz im dumb --- core/paginator.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/core/paginator.py b/core/paginator.py index 2fb72da8ff..a19db604b7 100644 --- a/core/paginator.py +++ b/core/paginator.py @@ -194,6 +194,12 @@ async def close(self, delete: bool = True) -> typing.Optional[Message]: """ self.running = False + sent_emoji, _ = await self.ctx.bot.retrieve_emoji() + try: + await self.ctx.message.add_reaction(sent_emoji) + except (HTTPException, InvalidArgument): + pass + if delete: return await self.base.delete() @@ -202,12 +208,6 @@ async def close(self, delete: bool = True) -> typing.Optional[Message]: except HTTPException: pass - sent_emoji, _ = await self.ctx.bot.retrieve_emoji() - try: - await self.ctx.message.add_reaction(sent_emoji) - except (HTTPException, InvalidArgument): - pass - async def first_page(self) -> None: """ Go to the first page. @@ -391,7 +391,11 @@ async def close(self, delete: bool = True) -> typing.Optional[Message]: """ self.running = False - self.ctx.bot.loop.create_task(self.ctx.message.add_reaction("✅")) + sent_emoji, _ = await self.ctx.bot.retrieve_emoji() + try: + await self.ctx.message.add_reaction(sent_emoji) + except (HTTPException, InvalidArgument): + pass if delete: return await self.base.delete() From bef1437930140dd1944e46bd1fee65abde05829c Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Fri, 2 Aug 2019 21:41:34 -0600 Subject: [PATCH 47/50] Paginator change --- CHANGELOG.md | 3 +- cogs/modmail.py | 13 ++- cogs/plugins.py | 6 +- cogs/utility.py | 24 ++-- core/paginator.py | 274 ++++++++++------------------------------------ 5 files changed, 81 insertions(+), 239 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f904043075..da6784d12c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,7 +66,8 @@ however, insignificant breaking changes does not guarantee a major version bump, - Use discord tasks for metadata loop. - More debug based logging. - Reduce redundancies in `?perms` sub commands. - +- paginator been split into `EmbedPaginatorSession` and `MessagePaginatorSession`, both subclassing `PaginatorSession`. + # v3.0.3 ### Added diff --git a/cogs/modmail.py b/cogs/modmail.py index 081385d202..c3750d7900 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -15,7 +15,7 @@ from core import checks from core.decorators import trigger_typing from core.models import PermissionLevel -from core.paginator import PaginatorSession +from core.paginator import EmbedPaginatorSession from core.time import UserFriendlyTime, human_timedelta from core.utils import format_preview, User, create_not_found_embed, format_description @@ -154,7 +154,7 @@ async def snippet(self, ctx, *, name: str.lower = None): embed.set_author(name="Snippets", icon_url=ctx.guild.icon_url) embeds.append(embed) - session = PaginatorSession(ctx, *embeds) + session = EmbedPaginatorSession(ctx, *embeds) await session.run() @snippet.command(name="raw") @@ -631,7 +631,7 @@ async def logs(self, ctx, *, user: User = None): embeds = self.format_log_embeds(logs, avatar_url=icon_url) - session = PaginatorSession(ctx, *embeds) + session = EmbedPaginatorSession(ctx, *embeds) await session.run() @logs.command(name="closed-by", aliases=["closeby"]) @@ -664,7 +664,7 @@ async def logs_closed_by(self, ctx, *, user: User = None): ) return await ctx.send(embed=embed) - session = PaginatorSession(ctx, *embeds) + session = EmbedPaginatorSession(ctx, *embeds) await session.run() @logs.command(name="search", aliases=["find"]) @@ -697,7 +697,7 @@ async def logs_search(self, ctx, limit: Optional[int] = None, *, query): ) return await ctx.send(embed=embed) - session = PaginatorSession(ctx, *embeds) + session = EmbedPaginatorSession(ctx, *embeds) await session.run() @commands.command() @@ -893,7 +893,8 @@ async def blocked(self, ctx): else: embeds[-1].description = "Currently there are no blocked users." - await PaginatorSession(ctx, *embeds).run() + session = EmbedPaginatorSession(ctx, *embeds) + await session.run() @blocked.command(name="whitelist") @checks.has_permissions(PermissionLevel.MODERATOR) diff --git a/cogs/plugins.py b/cogs/plugins.py index ebb9a72241..f871209bb6 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -17,7 +17,7 @@ from core import checks from core.models import PermissionLevel -from core.paginator import PaginatorSession +from core.paginator import EmbedPaginatorSession logger = logging.getLogger("Modmail") @@ -465,7 +465,7 @@ async def plugin_registry(self, ctx, *, plugin_name: typing.Union[int, str] = No embeds.append(embed) - paginator = PaginatorSession(ctx, *embeds) + paginator = EmbedPaginatorSession(ctx, *embeds) paginator.current = index await paginator.run() @@ -499,7 +499,7 @@ async def plugin_registry_compact(self, ctx): embed.set_author(name="Plugin Registry", icon_url=self.bot.user.avatar_url) embeds.append(embed) - paginator = PaginatorSession(ctx, *embeds) + paginator = EmbedPaginatorSession(ctx, *embeds) await paginator.run() diff --git a/cogs/utility.py b/cogs/utility.py index b1fe7aa7b3..f661cecb40 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -26,7 +26,7 @@ from core.changelog import Changelog from core.decorators import trigger_typing from core.models import InvalidConfigError, PermissionLevel -from core.paginator import PaginatorSession, MessagePaginatorSession +from core.paginator import EmbedPaginatorSession, MessagePaginatorSession from core.utils import ( cleanup_code, User, @@ -112,17 +112,17 @@ async def send_bot_help(self, mapping): if no_cog_commands: embeds.extend(await self.format_cog_help(no_cog_commands, no_cog=True)) - p_session = PaginatorSession( + session = EmbedPaginatorSession( self.context, *embeds, destination=self.get_destination() ) - return await p_session.run() + return await session.run() async def send_cog_help(self, cog): embeds = await self.format_cog_help(cog) - p_session = PaginatorSession( + session = EmbedPaginatorSession( self.context, *embeds, destination=self.get_destination() ) - return await p_session.run() + return await session.run() async def send_command_help(self, command): if not await self.filter_commands([command]): @@ -285,7 +285,7 @@ async def changelog(self, ctx, version: str.lower = ""): ) try: - paginator = PaginatorSession(ctx, *changelog.embeds) + paginator = EmbedPaginatorSession(ctx, *changelog.embeds) paginator.current = index await paginator.run() except asyncio.CancelledError: @@ -358,7 +358,7 @@ async def sponsors(self, ctx): random.shuffle(embeds) - session = PaginatorSession(ctx, *embeds) + session = EmbedPaginatorSession(ctx, *embeds) await session.run() @commands.group(invoke_without_command=True) @@ -762,7 +762,7 @@ async def config_options(self, ctx): ) embeds.append(embed) - session = PaginatorSession(ctx, *embeds) + session = EmbedPaginatorSession(ctx, *embeds) await session.run() @config.command(name="set", aliases=["add"]) @@ -926,7 +926,7 @@ def fmt(val): embed.set_thumbnail(url=fmt(info["thumbnail"])) embeds += [embed] - paginator = PaginatorSession(ctx, *embeds) + paginator = EmbedPaginatorSession(ctx, *embeds) paginator.current = index await paginator.run() @@ -1003,7 +1003,7 @@ async def alias(self, ctx, *, name: str.lower = None): embed.set_author(name="Command Aliases", icon_url=ctx.guild.icon_url) embeds.append(embed) - session = PaginatorSession(ctx, *embeds) + session = EmbedPaginatorSession(ctx, *embeds) await session.run() @alias.command(name="raw") @@ -1500,8 +1500,8 @@ async def permissions_get( for perm_level in PermissionLevel: embeds.append(self._get_perm(ctx, perm_level.name, "level")) - p_session = PaginatorSession(ctx, *embeds) - return await p_session.run() + session = EmbedPaginatorSession(ctx, *embeds) + return await session.run() @commands.group(invoke_without_command=True) @checks.has_permissions(PermissionLevel.OWNER) diff --git a/core/paginator.py b/core/paginator.py index a19db604b7..0a8aa8b814 100644 --- a/core/paginator.py +++ b/core/paginator.py @@ -8,7 +8,7 @@ class PaginatorSession: """ - Class that interactively paginates a list of `Embed`. + Class that interactively paginates something. Parameters ---------- @@ -16,11 +16,8 @@ class PaginatorSession: The context of the command. timeout : float How long to wait for before the session closes. - embeds : List[Embed] + pages : List[Any] A list of entries to paginate. - edit_footer : bool, optional - Whether to set the footer. - Defaults to `True`. Attributes ---------- @@ -28,7 +25,7 @@ class PaginatorSession: The context of the command. timeout : float How long to wait for before the session closes. - embeds : List[Embed] + pages : List[Any] A list of entries to paginate. running : bool Whether the paginate session is running. @@ -36,18 +33,17 @@ class PaginatorSession: The `Message` of the `Embed`. current : int The current page number. - reaction_map : Dict[str, meth] + reaction_map : Dict[str, method] A mapping for reaction to method. - """ - def __init__(self, ctx: commands.Context, *embeds, **options): + def __init__(self, ctx: commands.Context, *pages, **options): self.ctx = ctx self.timeout: int = options.get("timeout", 210) - self.embeds: typing.List[Embed] = list(embeds) self.running = False self.base: Message = None self.current = 0 + self.pages = list(pages) self.destination = options.get("destination", ctx) self.reaction_map = { "⏮": self.first_page, @@ -57,48 +53,31 @@ def __init__(self, ctx: commands.Context, *embeds, **options): "🛑": self.close, } - if options.get("edit_footer", True) and len(self.embeds) > 1: - for i, embed in enumerate(self.embeds): - footer_text = f"Page {i + 1} of {len(self.embeds)}" - if embed.footer.text: - footer_text = footer_text + " • " + embed.footer.text - embed.set_footer(text=footer_text, icon_url=embed.footer.icon_url) - - def add_page(self, embed: Embed) -> None: + def add_page(self, item) -> None: """ - Add a `Embed` page. - - Parameters - ---------- - embed : Embed - The `Embed` to add. + Add a page. """ - if isinstance(embed, Embed): - self.embeds.append(embed) - else: - raise TypeError("Page must be an Embed object.") + raise NotImplementedError - async def create_base(self, embed: Embed) -> None: + async def create_base(self, item) -> None: """ Create a base `Message`. - - Parameters - ---------- - embed : Embed - The `Embed` to fill the base `Message`. """ - self.base = await self.destination.send(embed=embed) + await self._create_base(item) - if len(self.embeds) == 1: + if len(self.pages) == 1: self.running = False return self.running = True for reaction in self.reaction_map: - if len(self.embeds) == 2 and reaction in "⏮⏭": + if len(self.pages) == 2 and reaction in "⏮⏭": continue await self.base.add_reaction(reaction) + async def _create_base(self, item) -> None: + raise NotImplementedError + async def show_page(self, index: int) -> None: """ Show a page by page number. @@ -108,17 +87,20 @@ async def show_page(self, index: int) -> None: index : int The index of the page. """ - if not 0 <= index < len(self.embeds): + if not 0 <= index < len(self.pages): return self.current = index - page = self.embeds[index] + page = self.pages[index] if self.running: - await self.base.edit(embed=page) + return await self._show_page(page) else: await self.create_base(page) + async def _show_page(self, page): + raise NotImplementedError + def react_check(self, reaction: Reaction, user: User) -> bool: """ @@ -218,201 +200,59 @@ async def last_page(self) -> None: """ Go to the last page. """ - await self.show_page(len(self.embeds) - 1) + await self.show_page(len(self.pages) - 1) -class MessagePaginatorSession: +class EmbedPaginatorSession(PaginatorSession): + def __init__(self, ctx: commands.Context, *embeds, **options): + super().__init__(ctx, *embeds, **options) - # TODO: Subclass MessagePaginatorSession from PaginatorSession + if len(self.pages) > 1: + for i, embed in enumerate(self.pages): + footer_text = f"Page {i + 1} of {len(self.pages)}" + if embed.footer.text: + footer_text = footer_text + " • " + embed.footer.text + embed.set_footer(text=footer_text, icon_url=embed.footer.icon_url) + + def add_page(self, embed: Embed) -> None: + if isinstance(embed, Embed): + self.pages.append(embed) + else: + raise TypeError("Page must be an Embed object.") + + async def _create_base(self, embed: Embed) -> None: + self.base = await self.destination.send(embed=embed) + + async def _show_page(self, page): + await self.base.edit(embed=page) + + +class MessagePaginatorSession(PaginatorSession): def __init__( self, ctx: commands.Context, *messages, embed: Embed = None, **options ): - self.ctx = ctx - self.timeout: int = options.get("timeout", 180) - self.messages: typing.List[str] = list(messages) - - self.running = False - self.base: Message = None self.embed = embed - if embed is not None: - self.footer_text = self.embed.footer.text - else: - self.footer_text = None - - self.current = 0 - self.reaction_map = { - "⏮": self.first_page, - "◀": self.previous_page, - "▶": self.next_page, - "⏭": self.last_page, - "🛑": self.close, - } + self.footer_text = self.embed.footer.text if embed is not None else None + super().__init__(ctx, *messages, **options) def add_page(self, msg: str) -> None: - """ - Add a message page. - - Parameters - ---------- - msg : str - The message to add. - """ if isinstance(msg, str): - self.messages.append(msg) + self.pages.append(msg) else: raise TypeError("Page must be a str object.") - async def create_base(self, msg: str) -> None: - """ - Create a base `Message`. - - Parameters - ---------- - msg : str - The message content to fill the base `Message`. - """ + def _set_footer(self): if self.embed is not None: - footer_text = f"Page {self.current+1} of {len(self.messages)}" + footer_text = f"Page {self.current+1} of {len(self.pages)}" if self.footer_text: footer_text = footer_text + " • " + self.footer_text self.embed.set_footer(text=footer_text, icon_url=self.embed.footer.icon_url) + async def _create_base(self, msg: str) -> None: + self._set_footer() self.base = await self.ctx.send(content=msg, embed=self.embed) - if len(self.messages) == 1: - self.running = False - return - - self.running = True - for reaction in self.reaction_map: - if len(self.messages) == 2 and reaction in "⏮⏭": - continue - await self.base.add_reaction(reaction) - - async def show_page(self, index: int) -> None: - """ - Show a page by page number. - - Parameters - ---------- - index : int - The index of the page. - """ - if not 0 <= index < len(self.messages): - return - - self.current = index - page = self.messages[index] - - if self.embed is not None: - footer_text = f"Page {self.current + 1} of {len(self.messages)}" - if self.footer_text: - footer_text = footer_text + " • " + self.footer_text - self.embed.set_footer(text=footer_text, icon_url=self.embed.footer.icon_url) - - if self.running: - await self.base.edit(content=page, embed=self.embed) - else: - await self.create_base(page) - - def react_check(self, reaction: Reaction, user: User) -> bool: - """ - - Parameters - ---------- - reaction : Reaction - The `Reaction` object of the reaction. - user : User - The `User` or `Member` object of who sent the reaction. - - Returns - ------- - bool - """ - return ( - reaction.message.id == self.base.id - and user.id == self.ctx.author.id - and reaction.emoji in self.reaction_map.keys() - ) - - async def run(self) -> typing.Optional[Message]: - """ - Starts the pagination session. - - Returns - ------- - Optional[Message] - If it's closed before running ends. - """ - if not self.running: - await self.show_page(self.current) - while self.running: - try: - reaction, user = await self.ctx.bot.wait_for( - "reaction_add", check=self.react_check, timeout=self.timeout - ) - except asyncio.TimeoutError: - return await self.close(delete=False) - else: - action = self.reaction_map.get(reaction.emoji) - await action() - try: - await self.base.remove_reaction(reaction, user) - except (HTTPException, InvalidArgument): - pass - - async def previous_page(self) -> None: - """ - Go to the previous page. - """ - await self.show_page(self.current - 1) - - async def next_page(self) -> None: - """ - Go to the next page. - """ - await self.show_page(self.current + 1) - - async def close(self, delete: bool = True) -> typing.Optional[Message]: - """ - Closes the pagination session. - - Parameters - ---------- - delete : bool, optional - Whether or delete the message upon closure. - Defaults to `True`. - - Returns - ------- - Optional[Message] - If `delete` is `True`. - """ - self.running = False - - sent_emoji, _ = await self.ctx.bot.retrieve_emoji() - try: - await self.ctx.message.add_reaction(sent_emoji) - except (HTTPException, InvalidArgument): - pass - - if delete: - return await self.base.delete() - - try: - await self.base.clear_reactions() - except HTTPException: - pass - - async def first_page(self) -> None: - """ - Go to the first page. - """ - await self.show_page(0) - - async def last_page(self) -> None: - """ - Go to the last page. - """ - await self.show_page(len(self.messages) - 1) + async def _show_page(self, page) -> None: + self._set_footer() + await self.base.edit(content=page, embed=self.embed) From 56cef8dd7b16d5f1d7e8b542aeb036a780516640 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Tue, 6 Aug 2019 00:35:17 -0700 Subject: [PATCH 48/50] Formatting and changelog change --- CHANGELOG.md | 3 + bot.py | 111 ++++++++++++++--------------- cogs/modmail.py | 48 ++++++++++--- cogs/plugins.py | 6 +- cogs/utility.py | 161 ++++++++++++++++++++++++++---------------- core/checks.py | 10 +++ core/clients.py | 12 ++-- core/config_help.json | 4 +- core/paginator.py | 1 - core/thread.py | 10 +-- 10 files changed, 220 insertions(+), 146 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da6784d12c..22dc37c980 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,9 @@ however, insignificant breaking changes does not guarantee a major version bump, - The placeholder channel for the streaming status changed to https://www.twitch.tv/discordmodmail/. - Removed unclear `rm` alias for some `remove` commands. - Paginate `?config options`. +- All users configured with a permission level greater than REGULAR has access to the main Modmail category. + - Category overrides also changes when a level is removed or added to a user or role. +- `@everyone` is now accepted for `?perms add`. ### Fixes diff --git a/bot.py b/bot.py index 6031fd3574..46f1081e3b 100644 --- a/bot.py +++ b/bot.py @@ -22,6 +22,7 @@ from pymongo.errors import ConfigurationError try: + # noinspection PyUnresolvedReferences from colorama import init init() @@ -94,7 +95,17 @@ def __init__(self): sys.exit(0) self.plugin_db = PluginDatabaseClient(self) + + logger.line() + logger.info("┌┬┐┌─┐┌┬┐┌┬┐┌─┐┬┬") + logger.info("││││ │ │││││├─┤││") + logger.info("┴ ┴└─┘─┴┘┴ ┴┴ ┴┴┴─┘") + logger.info("v%s", __version__) + logger.info("Authors: kyb3r, fourjr, Taaku18") + logger.line() + self._load_extensions() + logger.line() @property def uptime(self) -> str: @@ -168,14 +179,6 @@ async def get_prefix(self, message=None): def _load_extensions(self): """Adds commands automatically""" - logger.line() - logger.info("┌┬┐┌─┐┌┬┐┌┬┐┌─┐┬┬") - logger.info("││││ │ │││││├─┤││") - logger.info("┴ ┴└─┘─┴┘┴ ┴┴ ┴┴┴─┘") - logger.info("v%s", __version__) - logger.info("Authors: kyb3r, fourjr, Taaku18") - logger.line() - for file in os.listdir("cogs"): if not file.endswith(".py"): continue @@ -232,9 +235,12 @@ async def is_owner(self, user: discord.User) -> bool: def log_channel(self) -> typing.Optional[discord.TextChannel]: channel_id = self.config["log_channel_id"] if channel_id is not None: - channel = self.get_channel(int(channel_id)) - if channel is not None: - return channel + try: + channel = self.get_channel(int(channel_id)) + if channel is not None: + return channel + except ValueError: + pass logger.debug("LOG_CHANNEL_ID was invalid, removed.") self.config.remove("log_channel_id") if self.main_category is not None: @@ -255,10 +261,6 @@ def log_channel(self) -> typing.Optional[discord.TextChannel]: ) return None - @property - def is_connected(self) -> bool: - return self._connected.is_set() - async def wait_for_connected(self) -> None: await self.wait_until_ready() await self._connected.wait() @@ -277,7 +279,7 @@ def token(self) -> str: token = self.config["token"] if token is None: logger.critical( - "TOKEN must be set, set this as bot token found on the Discord Dev Portal." + "TOKEN must be set, set this as bot token found on the Discord Developer Portal." ) sys.exit(0) return token @@ -289,6 +291,7 @@ def guild_id(self) -> typing.Optional[int]: try: return int(str(guild_id)) except ValueError: + self.config.remove("guild_id") logger.critical("Invalid GUILD_ID set.") return None @@ -309,9 +312,12 @@ def modmail_guild(self) -> typing.Optional[discord.Guild]: modmail_guild_id = self.config["modmail_guild_id"] if modmail_guild_id is None: return self.guild - guild = discord.utils.get(self.guilds, id=int(modmail_guild_id)) - if guild is not None: - return guild + try: + guild = discord.utils.get(self.guilds, id=int(modmail_guild_id)) + if guild is not None: + return guild + except ValueError: + pass self.config.remove("modmail_guild_id") logger.critical("Invalid MODMAIL_GUILD_ID set.") return self.guild @@ -325,11 +331,14 @@ def main_category(self) -> typing.Optional[discord.CategoryChannel]: if self.modmail_guild is not None: category_id = self.config["main_category_id"] if category_id is not None: - cat = discord.utils.get( - self.modmail_guild.categories, id=int(category_id) - ) - if cat is not None: - return cat + try: + cat = discord.utils.get( + self.modmail_guild.categories, id=int(category_id) + ) + if cat is not None: + return cat + except ValueError: + pass self.config.remove("main_category_id") logger.debug("MAIN_CATEGORY_ID was invalid, removed.") cat = discord.utils.get(self.modmail_guild.categories, name="Modmail") @@ -353,41 +362,34 @@ def blocked_whitelisted_users(self) -> typing.List[str]: def prefix(self) -> str: return str(self.config["prefix"]) - @property - def mod_color(self) -> int: - color = self.config["mod_color"] + def _parse_color(self, conf_name): + color = self.config[conf_name] try: return int(color.lstrip("#"), base=16) except ValueError: - logger.error("Invalid mod_color provided.") - return int(self.config.remove("mod_color").lstrip("#"), base=16) + logger.error("Invalid %s provided.", conf_name) + return int(self.config.remove(conf_name).lstrip("#"), base=16) + + @property + def mod_color(self) -> int: + return self._parse_color("mod_color") @property def recipient_color(self) -> int: - color = self.config["recipient_color"] - try: - return int(color.lstrip("#"), base=16) - except ValueError: - logger.error("Invalid recipient_color provided.") - return int(self.config.remove("recipient_color").lstrip("#"), base=16) + return self._parse_color("recipient_color") @property def main_color(self) -> int: - color = self.config["main_color"] - try: - return int(color.lstrip("#"), base=16) - except ValueError: - logger.error("Invalid main_color provided.") - return int(self.config.remove("main_color").lstrip("#"), base=16) + return self._parse_color("main_color") async def on_connect(self): logger.line() try: await self.validate_database_connection() except Exception: + logger.debug("Logging out due to failed database connection.") return await self.logout() - logger.line() logger.info("Connected to gateway.") await self.config.refresh() await self.setup_indexes() @@ -436,6 +438,8 @@ async def on_ready(self): logger.info("Prefix: %s", self.prefix) logger.info("Guild Name: %s", self.guild.name) logger.info("Guild ID: %s", self.guild.id) + if self.using_multiple_server_setup: + logger.info("Receiving guild ID: %s", self.modmail_guild.id) logger.line() await self.threads.populate_cache() @@ -508,6 +512,7 @@ async def retrieve_emoji(self) -> typing.Tuple[str, str]: except commands.BadArgument: logger.warning("Removed sent emoji (%s).", sent_emoji) sent_emoji = self.config.remove("sent_emoji") + await self.config.update() if blocked_emoji != "disable": try: @@ -515,8 +520,8 @@ async def retrieve_emoji(self) -> typing.Tuple[str, str]: except commands.BadArgument: logger.warning("Removed blocked emoji (%s).", blocked_emoji) blocked_emoji = self.config.remove("blocked_emoji") + await self.config.update() - await self.config.update() return sent_emoji, blocked_emoji async def _process_blocked(self, message: discord.Message) -> bool: @@ -531,7 +536,7 @@ async def _process_blocked(self, message: discord.Message) -> bool: try: await message.add_reaction(sent_emoji) except (discord.HTTPException, discord.InvalidArgument): - pass + logger.warning("Failed to add sent_emoji.", exc_info=True) return False @@ -761,6 +766,7 @@ async def get_context(self, message, *, cls=commands.Context): async def update_perms( self, name: typing.Union[PermissionLevel, str], value: int, add: bool = True ) -> None: + value = int(value) if isinstance(name, PermissionLevel): permissions = self.config["level_permissions"] name = name.name @@ -1043,19 +1049,6 @@ async def on_command_error(self, context, exception): else: logger.error("Unexpected exception:", exc_info=exception) - @staticmethod - def overwrites(ctx: commands.Context) -> dict: - """Permission overwrites for the guild.""" - overwrites = { - ctx.guild.default_role: discord.PermissionOverwrite(read_messages=False), - ctx.guild.me: discord.PermissionOverwrite(read_messages=True), - } - - for role in ctx.guild.roles: - if role.permissions.administrator: - overwrites[role] = discord.PermissionOverwrite(read_messages=True) - return overwrites - async def validate_database_connection(self): try: await self.db.command("buildinfo") @@ -1113,12 +1106,14 @@ async def before_post_metadata(self): if not self.guild: self.metadata_loop.cancel() - async def after_post_metadata(self): + @staticmethod + async def after_post_metadata(): logger.info("Metadata loop has been cancelled.") if __name__ == "__main__": try: + # noinspection PyUnresolvedReferences import uvloop logger.debug("Setting up with uvloop.") diff --git a/cogs/modmail.py b/cogs/modmail.py index c3750d7900..486ddb303b 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -3,7 +3,7 @@ from datetime import datetime from itertools import zip_longest from typing import Optional, Union -from types import SimpleNamespace as param +from types import SimpleNamespace import discord from discord.ext import commands @@ -39,6 +39,11 @@ async def setup(self, ctx): once after configuring Modmail. """ + if ctx.guild != self.bot.modmail_guild: + return await ctx.send( + f"You can only setup in the Modmail guild: {self.bot.modmail_guild}." + ) + if self.bot.main_category is not None: logger.debug("Can't re-setup server, main_category is found.") return await ctx.send(f"{self.bot.modmail_guild} is already set up.") @@ -51,8 +56,31 @@ async def setup(self, ctx): ) return await ctx.send(embed=embed) + overwrites = { + self.bot.modmail_guild.default_role: discord.PermissionOverwrite( + read_messages=False + ), + self.bot.modmail_guild.me: discord.PermissionOverwrite(read_messages=True), + } + + for level in PermissionLevel: + if level <= PermissionLevel.REGULAR: + continue + permissions = self.bot.config["level_permissions"].get(level.name, []) + for perm in permissions: + perm = int(perm) + if perm == -1: + key = self.bot.modmail_guild.default_role + else: + key = self.bot.modmail_guild.get_member(perm) + if key is None: + key = self.bot.modmail_guild.get_role(perm) + if key is not None: + logger.info("Granting %s access to Modmail category.", key.name) + overwrites[key] = discord.PermissionOverwrite(read_messages=True) + category = await self.bot.modmail_guild.create_category( - name="Modmail", overwrites=self.bot.overwrites(ctx) + name="Modmail", overwrites=overwrites ) await category.edit(position=0) @@ -86,10 +114,12 @@ async def setup(self, ctx): await self.bot.config.update() await ctx.send( - "Successfully set up server.\n" - "Consider setting permission levels to give access " - "to roles or users the ability to use Modmail.\n" - f"Type `{self.bot.prefix}permissions` for more info." + "**Successfully set up server.**\n" + "Consider setting permission levels " + "to give access to roles or users the ability to use Modmail.\n\n" + f"Type:\n- `{self.bot.prefix}permissions` and `{self.bot.prefix}permissions add` " + "for more info on setting permissions.\n" + f"- `{self.bot.prefix}config help` for a list of available customizations." ) if ( @@ -612,7 +642,7 @@ async def logs(self, ctx, *, user: User = None): if not user: thread = ctx.thread if not thread: - raise commands.MissingRequiredArgument(param(name="member")) + raise commands.MissingRequiredArgument(SimpleNamespace(name="member")) user = thread.recipient default_avatar = "https://cdn.discordapp.com/embed/avatars/0.png" @@ -977,7 +1007,7 @@ async def block( if thread: user = thread.recipient elif after is None: - raise commands.MissingRequiredArgument(param(name="user")) + raise commands.MissingRequiredArgument(SimpleNamespace(name="user")) else: raise commands.BadArgument(f'User "{after.arg}" not found') @@ -1058,7 +1088,7 @@ async def unblock(self, ctx, *, user: User = None): if thread: user = thread.recipient else: - raise commands.MissingRequiredArgument(param(name="user")) + raise commands.MissingRequiredArgument(SimpleNamespace(name="user")) mention = getattr(user, "mention", f"`{user.id}`") name = getattr(user, "name", f"`{user.id}`") diff --git a/cogs/plugins.py b/cogs/plugins.py index f871209bb6..4ea76503be 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -62,9 +62,9 @@ def parse_plugin(name): if "@" in result[2]: # branch is specified # for example, fourjr/modmail-plugins/welcomer@develop is a valid name - branchsplit_result = result[2].split("@") - result.append(branchsplit_result[-1]) - result[2] = "@".join(branchsplit_result[:-1]) + branch_split_result = result[2].split("@") + result.append(branch_split_result[-1]) + result[2] = "@".join(branch_split_result[:-1]) else: result.append("master") diff --git a/cogs/utility.py b/cogs/utility.py index f661cecb40..188da898bf 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -11,12 +11,12 @@ from itertools import zip_longest, takewhile from json import JSONDecodeError, loads from textwrap import indent -from types import SimpleNamespace as param +from types import SimpleNamespace from typing import Union from discord import Embed, Color, Activity, Role from discord.enums import ActivityType, Status -from discord.ext import commands +from discord.ext import commands, tasks from discord.utils import escape_markdown, escape_mentions from aiohttp import ClientResponseError @@ -27,14 +27,7 @@ from core.decorators import trigger_typing from core.models import InvalidConfigError, PermissionLevel from core.paginator import EmbedPaginatorSession, MessagePaginatorSession -from core.utils import ( - cleanup_code, - User, - get_perm_level, - create_not_found_embed, - parse_alias, - format_description, -) +from core import utils logger = logging.getLogger("Modmail") @@ -46,9 +39,11 @@ async def format_cog_help(self, cog, *, no_cog=False): formats = [""] for cmd in await self.filter_commands( - cog.get_commands() if not no_cog else cog, sort=True, key=get_perm_level + cog.get_commands() if not no_cog else cog, + sort=True, + key=utils.get_perm_level, ): - perm_level = get_perm_level(cmd) + perm_level = utils.get_perm_level(cmd) if perm_level is PermissionLevel.INVALID: format_ = f"`{prefix + cmd.qualified_name}` " else: @@ -127,7 +122,7 @@ async def send_cog_help(self, cog): async def send_command_help(self, command): if not await self.filter_commands([command]): return - perm_level = get_perm_level(command) + perm_level = utils.get_perm_level(command) if perm_level is not PermissionLevel.INVALID: perm_level = f"{perm_level.name} [{perm_level}]" else: @@ -145,7 +140,7 @@ async def send_group_help(self, group): if not await self.filter_commands([group]): return - perm_level = get_perm_level(group) + perm_level = utils.get_perm_level(group) if perm_level is not PermissionLevel.INVALID: perm_level = f"{perm_level.name} [{perm_level}]" else: @@ -192,7 +187,7 @@ async def send_error_message(self, error): val = self.context.bot.aliases.get(command) if val is not None: - values = parse_alias(val) + values = utils.parse_alias(val) if len(values) == 1: embed = Embed( @@ -246,22 +241,14 @@ def __init__(self, bot): self.bot = bot self._original_help_command = bot.help_command self.bot.help_command = ModmailHelpCommand( - verify_checks=False, command_attrs={"help": "Shows this help message."} + verify_checks=False, + command_attrs={ + "help": "Shows this help message.", + "checks": [checks.has_permissions_help(PermissionLevel.REGULAR)], + }, ) - # Looks a bit ugly - self.bot.help_command._command_impl = checks.has_permissions( # pylint: disable=protected-access - PermissionLevel.REGULAR - )( - self.bot.help_command._command_impl # pylint: disable=protected-access - ) - self.bot.help_command.cog = self - - # Class Variables - self.presence = None - - # Tasks - self.presence_task = self.bot.loop.create_task(self.loop_presence()) + self.loop_presence.start() def cog_unload(self): self.bot.help_command = self._original_help_command @@ -512,7 +499,7 @@ async def activity(self, ctx, activity_type: str.lower, *, message: str = ""): return await ctx.send(embed=embed) if not message: - raise commands.MissingRequiredArgument(param(name="message")) + raise commands.MissingRequiredArgument(SimpleNamespace(name="message")) activity, msg = ( await self.set_presence( @@ -522,7 +509,7 @@ async def activity(self, ctx, activity_type: str.lower, *, message: str = ""): ) )["activity"] if activity is None: - raise commands.MissingRequiredArgument(param(name="activity")) + raise commands.MissingRequiredArgument(SimpleNamespace(name="activity")) self.bot.config["activity_type"] = activity.type.value self.bot.config["activity_message"] = message @@ -561,7 +548,7 @@ async def status(self, ctx, *, status_type: str.lower): await self.set_presence(status_identifier=status_type, status_by_key=True) )["status"] if status is None: - raise commands.MissingRequiredArgument(param(name="status")) + raise commands.MissingRequiredArgument(SimpleNamespace(name="status")) self.bot.config["status"] = status.value await self.bot.config.update() @@ -631,7 +618,7 @@ async def set_presence( activity = Activity(type=activity_type, name=activity_message, url=url) else: msg = "You must supply an activity message to use custom activity." - logger.warning(msg) + logger.debug(msg) await self.bot.change_presence(activity=activity, status=status) @@ -649,19 +636,18 @@ async def set_presence( presence["status"] = (status, msg) return presence - @commands.Cog.listener() - async def on_ready(self): - # Wait until config cache is populated with stuff from db - await self.bot.wait_for_connected() - logger.info(self.presence["activity"][1]) - logger.info(self.presence["status"][1]) - + @tasks.loop(minutes=45) async def loop_presence(self): - """Set presence to the configured value every hour.""" + """Set presence to the configured value every 45 minutes.""" + presence = await self.set_presence() + logger.line() + logger.info(presence["activity"][1]) + logger.info(presence["status"][1]) + + @loop_presence.before_loop + async def before_loop_presence(self): + logger.info("Starting metadata loop.") await self.bot.wait_for_connected() - while not self.bot.is_closed(): - self.presence = await self.set_presence() - await asyncio.sleep(600) @commands.command() @checks.has_permissions(PermissionLevel.ADMINISTRATOR) @@ -867,11 +853,11 @@ async def config_get(self, ctx, key: str.lower = None): @config.command(name="help", aliases=["info"]) @checks.has_permissions(PermissionLevel.OWNER) - async def config_help(self, ctx, key: str.lower): + async def config_help(self, ctx, key: str.lower = None): """ Show information on a specified configuration. """ - if not ( + if key is not None and not ( key in self.bot.config.public_keys or key in self.bot.config.protected_keys ): embed = Embed( @@ -883,7 +869,7 @@ async def config_help(self, ctx, key: str.lower): config_help = self.bot.config.config_help - if key not in config_help: + if key is not None and key not in config_help: embed = Embed( title="Error", color=Color.red(), @@ -955,10 +941,12 @@ async def alias(self, ctx, *, name: str.lower = None): if name is not None: val = self.bot.aliases.get(name) if val is None: - embed = create_not_found_embed(name, self.bot.aliases.keys(), "Alias") + embed = utils.create_not_found_embed( + name, self.bot.aliases.keys(), "Alias" + ) return await ctx.send(embed=embed) - values = parse_alias(val) + values = utils.parse_alias(val) if not values: embed = Embed( @@ -998,7 +986,7 @@ async def alias(self, ctx, *, name: str.lower = None): embeds = [] for i, names in enumerate(zip_longest(*(iter(sorted(self.bot.aliases)),) * 15)): - description = format_description(i, names) + description = utils.format_description(i, names) embed = Embed(color=self.bot.main_color, description=description) embed.set_author(name="Command Aliases", icon_url=ctx.guild.icon_url) embeds.append(embed) @@ -1014,7 +1002,7 @@ async def alias_raw(self, ctx, *, name: str.lower): """ val = self.bot.aliases.get(name) if val is None: - embed = create_not_found_embed(name, self.bot.aliases.keys(), "Alias") + embed = utils.create_not_found_embed(name, self.bot.aliases.keys(), "Alias") return await ctx.send(embed=embed) return await ctx.send(escape_markdown(escape_mentions(val)).replace("<", "\\<")) @@ -1066,7 +1054,7 @@ async def alias_add(self, ctx, name: str.lower, *, value): if embed is not None: return await ctx.send(embed=embed) - values = parse_alias(value) + values = utils.parse_alias(value) if not values: embed = Embed( @@ -1133,7 +1121,7 @@ async def alias_remove(self, ctx, *, name: str.lower): description=f"Successfully deleted `{name}`.", ) else: - embed = create_not_found_embed(name, self.bot.aliases.keys(), "Alias") + embed = utils.create_not_found_embed(name, self.bot.aliases.keys(), "Alias") return await ctx.send(embed=embed) @@ -1144,10 +1132,10 @@ async def alias_edit(self, ctx, name: str.lower, *, value): Edit an alias. """ if name not in self.bot.aliases: - embed = create_not_found_embed(name, self.bot.aliases.keys(), "Alias") + embed = utils.create_not_found_embed(name, self.bot.aliases.keys(), "Alias") return await ctx.send(embed=embed) - values = parse_alias(value) + values = utils.parse_alias(value) if not values: embed = Embed( @@ -1222,10 +1210,13 @@ async def permissions(self, ctx): @staticmethod def _verify_user_or_role(user_or_role): + if isinstance(user_or_role, Role): + if user_or_role.is_default(): + return -1 + elif user_or_role in {"everyone", "all"}: + return -1 if hasattr(user_or_role, "id"): return user_or_role.id - if user_or_role in {"everyone", "all"}: - return -1 raise commands.BadArgument(f'User or Role "{user_or_role}" not found') @staticmethod @@ -1247,7 +1238,12 @@ def _parse_level(name): @permissions.command(name="add", usage="[command/level] [name] [user_or_role]") @checks.has_permissions(PermissionLevel.OWNER) async def permissions_add( - self, ctx, type_: str.lower, name: str, *, user_or_role: Union[Role, User, str] + self, + ctx, + type_: str.lower, + name: str, + *, + user_or_role: Union[Role, utils.User, str], ): """ Add a permission to a command or a permission level. @@ -1290,6 +1286,18 @@ async def permissions_add( else: await self.bot.update_perms(level, value) name = level.name + if level > PermissionLevel.REGULAR: + if value == -1: + key = self.bot.modmail_guild.default_role + elif isinstance(user_or_role, Role): + key = user_or_role + else: + key = self.bot.modmail_guild.get_member(value) + if key is not None: + logger.info("Granting %s access to Modmail category.", key.name) + await self.bot.main_category.set_permissions( + key, read_messages=True + ) embed = Embed( title="Success", @@ -1305,7 +1313,12 @@ async def permissions_add( ) @checks.has_permissions(PermissionLevel.OWNER) async def permissions_remove( - self, ctx, type_: str.lower, name: str, *, user_or_role: Union[Role, User, str] + self, + ctx, + type_: str.lower, + name: str, + *, + user_or_role: Union[Role, utils.User, str], ): """ Remove permission to use a command or permission level. @@ -1341,6 +1354,30 @@ async def permissions_remove( value = self._verify_user_or_role(user_or_role) await self.bot.update_perms(level or name, value, add=False) + if type_ == "level": + if level > PermissionLevel.REGULAR: + if value == -1: + logger.info("Denying @everyone access to Modmail category.") + await self.bot.main_category.set_permissions( + self.bot.modmail_guild.default_role, read_messages=False + ) + elif isinstance(user_or_role, Role): + logger.info( + "Denying %s access to Modmail category.", user_or_role.name + ) + await self.bot.main_category.set_permissions( + user_or_role, overwrite=None + ) + else: + member = self.bot.modmail_guild.get_member(value) + if member is not None and member != self.bot.modmail_guild.me: + logger.info( + "Denying %s access to Modmail category.", member.name + ) + await self.bot.main_category.set_permissions( + member, overwrite=None + ) + embed = Embed( title="Success", color=self.bot.main_color, @@ -1389,7 +1426,7 @@ def _get_perm(self, ctx, name, type_): @permissions.command(name="get", usage="[@user] or [command/level] [name]") @checks.has_permissions(PermissionLevel.OWNER) async def permissions_get( - self, ctx, user_or_role: Union[Role, User, str], *, name: str = None + self, ctx, user_or_role: Union[Role, utils.User, str], *, name: str = None ): """ View the currently-set permissions. @@ -1514,7 +1551,7 @@ async def oauth(self, ctx): @oauth.command(name="whitelist") @checks.has_permissions(PermissionLevel.OWNER) - async def oauth_whitelist(self, ctx, target: Union[User, Role]): + async def oauth_whitelist(self, ctx, target: Union[Role, utils.User]): """ Whitelist or un-whitelist a user or role to have access to logs. @@ -1593,7 +1630,7 @@ async def eval_(self, ctx, *, body: str): env.update(globals()) - body = cleanup_code(body) + body = utils.cleanup_code(body) stdout = StringIO() to_compile = f'async def func():\n{indent(body, " ")}' diff --git a/core/checks.py b/core/checks.py index 503877b3a7..17d2b4353b 100644 --- a/core/checks.py +++ b/core/checks.py @@ -7,6 +7,16 @@ logger = logging.getLogger("Modmail") +def has_permissions_help(permission_level: PermissionLevel = PermissionLevel.REGULAR): + async def predicate(ctx): + return await check_permissions( + ctx, ctx.command.qualified_name, permission_level + ) + + predicate.permission_level = permission_level + return predicate + + def has_permissions(permission_level: PermissionLevel = PermissionLevel.REGULAR): """ A decorator that checks if the author has the required permissions. diff --git a/core/clients.py b/core/clients.py index 31a73be390..a8bb006e6d 100644 --- a/core/clients.py +++ b/core/clients.py @@ -99,9 +99,9 @@ async def get_log(self, channel_id: Union[str, int]) -> dict: async def get_log_link(self, channel_id: Union[str, int]) -> str: doc = await self.get_log(channel_id) logger.debug("Retrieving log link for channel %s.", channel_id) - prefix = self.bot.config['log_url_prefix'].strip('/') - if prefix == 'NONE': - prefix = '' + prefix = self.bot.config["log_url_prefix"].strip("/") + if prefix == "NONE": + prefix = "" return f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{doc['key']}" async def create_log_entry( @@ -138,9 +138,9 @@ async def create_log_entry( } ) logger.debug("Created a log entry, key %s.", key) - prefix = self.bot.config['log_url_prefix'].strip('/') - if prefix == 'NONE': - prefix = '' + prefix = self.bot.config["log_url_prefix"].strip("/") + if prefix == "NONE": + prefix = "" return f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{key}" async def get_config(self) -> dict: diff --git a/core/config_help.json b/core/config_help.json index f4e8eae36f..be499bb230 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -281,7 +281,7 @@ "notes": [ "When `recipient_thread_close` is enabled and the recipient closed their own thread, `thread_self_close_response` is used instead of this configuration.", "You may use the `{{closer}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that closed the thread.", - "`{{loglink}}` can be used as a placeholder substitute for the full URL linked to the thread in the log viewer and `{{loglink}}` for the unique key (ie. 3ifdm9204) of the log.", + "`{{loglink}}` can be used as a placeholder substitute for the full URL linked to the thread in the log viewer and `{{loglink}}` for the unique key (ie. s3kf91a) of the log.", "Discord flavoured markdown is fully supported in `thread_close_response`.", "See also: `thread_close_title`, `thread_close_footer`, `thread_self_close_response`, `thread_creation_response`." ] @@ -295,7 +295,7 @@ "notes": [ "When `recipient_thread_close` is disabled or the thread wasn't closed by the recipient, `thread_close_response` is used instead of this configuration.", "You may use the `{{closer}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that closed the thread.", - "`{{loglink}}` can be used as a placeholder substitute for the full URL linked to the thread in the log viewer and `{{loglink}}` for the unique key (ie. 3ifdm9204) of the log.", + "`{{loglink}}` can be used as a placeholder substitute for the full URL linked to the thread in the log viewer and `{{loglink}}` for the unique key (ie. s3kf91a) of the log.", "Discord flavoured markdown is fully supported in `thread_self_close_response`.", "See also: `thread_close_title`, `thread_close_footer`, `thread_close_response`." ] diff --git a/core/paginator.py b/core/paginator.py index 0a8aa8b814..c2bed558ed 100644 --- a/core/paginator.py +++ b/core/paginator.py @@ -228,7 +228,6 @@ async def _show_page(self, page): class MessagePaginatorSession(PaginatorSession): - def __init__( self, ctx: commands.Context, *messages, embed: Embed = None, **options ): diff --git a/core/thread.py b/core/thread.py index 95eeadc3dd..00aa24c9c7 100644 --- a/core/thread.py +++ b/core/thread.py @@ -4,7 +4,7 @@ import string import typing from datetime import datetime, timedelta -from types import SimpleNamespace as param +from types import SimpleNamespace import isodate @@ -144,8 +144,8 @@ async def send_genesis_message(): msg = await channel.send(mention, embed=info_embed) self.bot.loop.create_task(msg.pin()) self.genesis_message = msg - except Exception as e: - logger.error(str(e)) + except Exception: + logger.error("Failed unexpectedly:", exc_info=True) finally: self.ready = True @@ -539,7 +539,7 @@ async def delete_message(self, message_id): async def note(self, message: discord.Message) -> None: if not message.content and not message.attachments: - raise MissingRequiredArgument(param(name="msg")) + raise MissingRequiredArgument(SimpleNamespace(name="msg")) _, msg = await asyncio.gather( self.bot.api.append_log(message, self.channel.id, type_="system"), @@ -550,7 +550,7 @@ async def note(self, message: discord.Message) -> None: async def reply(self, message: discord.Message, anonymous: bool = False) -> None: if not message.content and not message.attachments: - raise MissingRequiredArgument(param(name="msg")) + raise MissingRequiredArgument(SimpleNamespace(name="msg")) if not any(g.get_member(self.id) for g in self.bot.guilds): return await message.channel.send( embed=discord.Embed( From 2c046227970d145b2b01fe687b5d76c90646c85e Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Tue, 6 Aug 2019 22:21:35 -0700 Subject: [PATCH 49/50] Updated README.md --- README.md | 60 +++++++++++++++++++++++++---------------------- core/paginator.py | 2 +- core/utils.py | 2 +- 3 files changed, 34 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 514d0719ba..e9aabe9a76 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,6 @@ Made with Python 3.7 - - - - @@ -53,9 +49,10 @@ Modmail is similar to Reddit's Modmail both in functionality and purpose. It ser This bot is free for everyone and always will be. If you like this project and would like to show your appreciation, you can support us on **[Patreon](https://www.patreon.com/kyber)**, cool benefits included! ## How does it work? -When a member sends a direct message to the bot, a channel or "thread" is created within an isolated category for that member. This channel is where messages will be relayed and where any available staff member can respond to that user. -All threads are logged and you can view previous threads through the corresponding generated log link. Here is an [**example**](https://logs.modmail.tk/example). +When a member sends a direct message to the bot, Modmail will create a channel or "thread" within an isolated category. All further DM messages will automatically relay to that channel, for any available staff can respond within the channel. + +All threads are logged and you can view previous threads through their corresponding log link. Here is an [**example**](https://logs.modmail.tk/example). ## Features @@ -65,46 +62,51 @@ All threads are logged and you can view previous threads through the correspondi * Interface elements (color, responses, reactions, etc). * Snippets and *command aliases*. * Minimum duration for accounts to be created before allowed to contact Modmail (`account_age`). - * Minimum duration for member to be in the guild allowed to contact Modmail (`guild_age`). + * Minimum duration for members to be in the guild before allowed to contact Modmail (`guild_age`). + * **Advanced Logging Functionality:** - * When you close a thread, a [log link](https://logs.modmail.tk/example) is generated and posted to your log channel. + * When you close a thread, Modmail will generate a [log link](https://logs.modmail.tk/example) and post it to your log channel. * Native Discord dark-mode feel. * Markdown/formatting support. * Login via Discord to protect your logs ([premium Patreon feature](https://patreon.com/kyber)). * See past logs of a user with `?logs`. * Searchable by text queries using `?logs search`. + * **Robust implementation:** - * Scheduled tasks in human time, e.g. `?close in 2 hours silently`. - * Editing and deleting messages are synced on all channels. - * Support for the full range of message content (multiple images, files). + * Schedule tasks in human time, e.g. `?close in 2 hours silently`. + * Editing and deleting messages are synced. + * Support for the diverse range of message contents (multiple images, files). * Paginated commands interfaces via reactions. - + This list is ever-growing thanks to active development and our exceptional contributors. See a full list of documented commands by using the `?help` command. ## Installation -<<<<<<< HEAD +Where is the Modmail bot invite link? Unfortunately, due to how this bot functions, it cannot be invited. This is to ensure the individuality to your server and grant you full control over your bot and data. Nonetheless, you can easily obtain a free copy of Modmail for your server by following one of the methods listed below (roughly takes 15 minutes of your time): + ### Heroku -This bot can be hosted on Heroku. +This bot can be hosted on Heroku. -Installation via Heroku is possible with only your web browser. -The [**installation guide**](https://github.com/kyb3r/modmail/wiki/Installation) will guide you through the entire installation process. If you run into any problems, join the [development server](https://discord.gg/etJNHCQ) for help and support. +Installation via Heroku is possible with your web browser alone. +The [**installation guide**](https://github.com/kyb3r/modmail/wiki/Installation) (which includes a video tutorial!) will guide you through the entire installation process. If you run into any problems, join our [Modmail Discord Server](https://discord.gg/etJNHCQ) for help and support. -You can also set up auto-update. To do this: +To configure automatic updates: + - Login to [GitHub](https://github.com/) and verify your account. - [Fork the repo](https://github.com/kyb3r/modmail/fork). - - [Install the Pull app for your fork](https://github.com/apps/pull). - - Then go to the Deploy tab in your Heroku account, select GitHub and connect your fork. + - Install the [Pull app](https://github.com/apps/pull) for your fork. + - Then go to the Deploy tab in your [Heroku account](https://dashboard.heroku.com/apps) of your bot app, select GitHub and connect your fork (usually by typing "Modmail"). - Turn on auto-deploy for the `master` branch. ### Hosting for patrons -If you don't want to go through the trouble of setting up your own bot, and want to support this project as well, we offer installation, hosting and maintenance for Modmail bots for [**Patrons**](https://patreon.com/kyber). Join the support server for more info! +If you don't want to go through the trouble of setting up your very own Modmail bot, and/or want to support this project, we offer the all inclusive installation, hosting and maintenance of your Modmail with [**Patron**](https://patreon.com/kyber). Join our [Modmail Discord Server](https://discord.gg/etJNHCQ) for more info! ### Locally -Installation locally for development reasons or otherwise is as follows, you will need [`python 3.7`](https://www.python.org/downloads/). -Follow the [**installation guide**](https://github.com/kyb3r/modmail/wiki/Installation) and disregard deploying Heroku apps. If you run into any problems, join the [development server](https://discord.gg/etJNHCQ) for help and support. +Local hosting of Modmail is also possible, first you will need [`python 3.7`](https://www.python.org/downloads/). + +Follow the [**installation guide**](https://github.com/kyb3r/modmail/wiki/Installation) and disregard deploying the Heroku bot application. If you run into any problems, join our [Modmail Discord Server](https://discord.gg/etJNHCQ) for help and support. Clone the repo: @@ -119,9 +121,9 @@ Install dependencies: $ pipenv install ``` -Rename the `.env.example` to `.env` and fill out the fields. If `.env.example` is hidden, create a text file named `.env` and copy the contents of [`.env.example`](https://raw.githubusercontent.com/kyb3r/modmail/master/.env.example) then modify the values. +Rename the `.env.example` to `.env` and fill out the fields. If `.env.example` is nonexistent (hidden), create a text file named `.env` and copy the contents of [`.env.example`](https://raw.githubusercontent.com/kyb3r/modmail/master/.env.example) then modify the values. -Finally, run the bot. +Finally, start Modmail. ```console $ pipenv run bot @@ -135,14 +137,16 @@ Special thanks to our sponsors for supporting the project. -Become a [sponsor](https://patreon.com/kyber). +Become a sponsor on [Patreon](https://patreon.com/kyber). ## Plugins -Modmail supports the use of third-party plugins to extend or add functionality to the bot. This allows the introduction of niche features as well as anything else outside of the scope of the core functionality of Modmail. A list of third party plugins can be found using the `plugins registry` command. To develop your own, check out the [documentation](https://github.com/kyb3r/modmail/wiki/Plugins) for plugins. +Modmail supports the use of third-party plugins to extend or add functionalities to the bot. This allows niche features as well as anything else outside of the scope of the core functionality of Modmail. A list of third-party plugins can be found using the `plugins registry` command. To develop your own, check out the [plugins documentation](https://github.com/kyb3r/modmail/wiki/Plugins). + +Plugins requests and support is available in our (Modmail Plugins Server)[https://discord.gg/4JE4XSW]. ## Contributing -Contributions to Modmail are always welcome, whether it be improvements to the documentation or new functionality, please feel free to make the change. Check out our contribution [guidelines](https://github.com/kyb3r/modmail/blob/master/CONTRIBUTING.md) before you get started. +Contributions to Modmail are always welcome, whether it be improvements to the documentation or new functionality, please feel free to make the change. Check out our contribution [guidelines](https://github.com/kyb3r/modmail/blob/master/CONTRIBUTING.md) before you get started. -This bot is free for everyone and always will be. If you like this project and would like to show your appreciation, here's the link to our **[Patreon Page](https://www.patreon.com/kyber)**! \ No newline at end of file +If you like this project and would like to show your appreciation, support us on **[Patreon](https://www.patreon.com/kyber)**! \ No newline at end of file diff --git a/core/paginator.py b/core/paginator.py index c2bed558ed..835337dbed 100644 --- a/core/paginator.py +++ b/core/paginator.py @@ -94,7 +94,7 @@ async def show_page(self, index: int) -> None: page = self.pages[index] if self.running: - return await self._show_page(page) + await self._show_page(page) else: await self.create_base(page) diff --git a/core/utils.py b/core/utils.py index 89f9b55f81..585c0fba3d 100644 --- a/core/utils.py +++ b/core/utils.py @@ -2,7 +2,7 @@ import shlex import typing from difflib import get_close_matches -from distutils.util import strtobool as _stb +from distutils.util import strtobool as _stb # pylint: disable=import-error from itertools import takewhile from urllib import parse From 6a7a60410b09650ae0c1c19e17208adf7e94210c Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Thu, 8 Aug 2019 10:28:54 -0700 Subject: [PATCH 50/50] Fix a blocked bug --- cogs/modmail.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/cogs/modmail.py b/cogs/modmail.py index 486ddb303b..7f2bb4ce56 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -906,22 +906,21 @@ async def blocked(self, ctx): pass if users: - embed = embeds[-1] + embed = embeds[0] for mention, reason in users: line = mention + f" - `{reason or 'No reason provided'}`\n" if len(embed.description) + len(line) > 2048: - embeds.append( - discord.Embed( + embed = discord.Embed( title="Blocked Users (Continued)", color=self.bot.main_color, description=line, ) - ) + embeds.append(embed) else: embed.description += line else: - embeds[-1].description = "Currently there are no blocked users." + embeds[0].description = "Currently there are no blocked users." session = EmbedPaginatorSession(ctx, *embeds) await session.run()