diff --git a/.github/pull.yml b/.github/pull.yml new file mode 100644 index 0000000000..2fec8bbda0 --- /dev/null +++ b/.github/pull.yml @@ -0,0 +1,8 @@ +version: "1" +rules: + - base: master + upstream: kyb3r:master + mergeMethod: hardreset + - base: development + upstream: kyb3r:development + mergeMethod: hardreset \ No newline at end of file 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 47934e28ff..da6784d12c 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 @@ -25,6 +26,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 @@ -37,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`. @@ -63,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 @@ -225,7 +229,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 +251,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/Dockerfile b/Dockerfile index d33f146c9e..851c65b4cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ -FROM kennethreitz/pipenv +FROM library/python:latest +RUN apt update && apt install -y pipenv +RUN mkdir -p /bot && cd /bot && git clone https://github.com/kyb3r/modmail . +WORKDIR /bot +RUN pipenv install -COPY . /app - -CMD python3 bot.py +CMD ["pipenv", "run", "bot"] \ No newline at end of file diff --git a/README.md b/README.md index 31fc107ec4..a2964c643a 100644 --- a/README.md +++ b/README.md @@ -112,20 +112,10 @@ $ pipenv run bot Special thanks to our sponsors for supporting the project. -- [flyAurora](https://flyaurora.xyz/): - - - - -
-
- - - Become a [sponsor](https://patreon.com/kyber). ## Plugins 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 7949688577..c3750d7900 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 @@ -15,9 +15,9 @@ 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 +from core.utils import format_preview, User, create_not_found_embed, format_description logger = logging.getLogger("Modmail") @@ -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." ) @@ -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.` @@ -149,22 +149,20 @@ 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) - session = PaginatorSession(ctx, *embeds) + session = EmbedPaginatorSession(ctx, *embeds) await session.run() @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") @@ -567,16 +565,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"] + prefix = self.bot.config["log_url_prefix"].strip("/") if prefix == "NONE": prefix = "" - - log_url = self.bot.config["log_url"].strip("/") + f"{prefix}/{key}" + log_url = f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{entry['key']}" username = entry["recipient"]["name"] + "#" username += entry["recipient"]["discriminator"] @@ -637,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"]) @@ -670,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"]) @@ -703,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() @@ -899,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 6ed1dccf1b..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") @@ -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) @@ -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 1252341d89..f661cecb40 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -26,13 +26,14 @@ 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, get_perm_level, create_not_found_embed, parse_alias, + format_description, ) logger = logging.getLogger("Modmail") @@ -111,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]): @@ -220,9 +221,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: @@ -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 @@ -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"]) @@ -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"]: @@ -922,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() @@ -994,19 +998,26 @@ 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) - session = PaginatorSession(ctx, *embeds) + session = EmbedPaginatorSession(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): @@ -1023,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) @@ -1102,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) @@ -1213,15 +1224,30 @@ 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): + 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( - 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. @@ -1246,8 +1272,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( @@ -1279,7 +1305,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,17 +1326,16 @@ 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__: + 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) @@ -1364,16 +1389,22 @@ 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. 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` @@ -1388,7 +1419,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( @@ -1402,7 +1433,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 @@ -1421,7 +1454,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, ), @@ -1437,8 +1470,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( @@ -1457,7 +1490,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( @@ -1467,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/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/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/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/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 diff --git a/core/paginator.py b/core/paginator.py index 2fb72da8ff..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: """ @@ -194,6 +176,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 +190,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. @@ -218,197 +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 EmbedPaginatorSession(PaginatorSession): + def __init__(self, ctx: commands.Context, *embeds, **options): + super().__init__(ctx, *embeds, **options) + + 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: - # TODO: Subclass MessagePaginatorSession from PaginatorSession +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 - - self.ctx.bot.loop.create_task(self.ctx.message.add_reaction("✅")) - - 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) diff --git a/core/thread.py b/core/thread.py index 12bb71ab3c..95eeadc3dd 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"] + prefix = self.bot.config["log_url_prefix"].strip("/") 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 if prefix else ''}/{log_data['key']}" if log_data["messages"]: content = str(log_data["messages"][0]["content"]) @@ -412,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() @@ -748,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") @@ -762,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) + ) diff --git a/requirements.min.txt b/requirements.min.txt index 70a2c8ae59..2e9086e887 100644 --- a/requirements.min.txt +++ b/requirements.min.txt @@ -3,7 +3,7 @@ # To install requirements.txt run: pip install -r requirements.min.txt aiohttp==3.5.4 -discord.py==1.1.1 +discord.py==1.2.3 dnspython==1.16.0 emoji==0.5.2 isodate==0.6.0