aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Matteo Bertucci <[email protected]>2021-04-28 08:14:00 +0200
committerGravatar GitHub <[email protected]>2021-04-28 08:14:00 +0200
commitcbbc680e0a47f066e398330e7dabf442cc57f12f (patch)
tree25f209dd38c2b1410b4724b3b36c4d11635a355e
parentUse guild.afk_channel atr to retrieve afk Channel instance. (diff)
parentMerge pull request #1552 from python-discord/not-in-guild-error-message (diff)
Merge branch 'main' into suspend_stream
-rw-r--r--bot/constants.py11
-rw-r--r--bot/exts/info/code_snippets.py247
-rw-r--r--bot/exts/info/information.py5
-rw-r--r--bot/exts/moderation/infraction/infractions.py18
-rw-r--r--bot/exts/moderation/infraction/superstarify.py8
-rw-r--r--bot/exts/moderation/modlog.py9
-rw-r--r--bot/exts/moderation/modpings.py136
-rw-r--r--bot/exts/utils/reminders.py13
-rw-r--r--bot/exts/utils/utils.py2
-rw-r--r--bot/log.py37
-rw-r--r--config-default.yml15
-rw-r--r--tests/README.md2
-rw-r--r--tests/bot/exts/info/test_information.py2
13 files changed, 474 insertions, 31 deletions
diff --git a/bot/constants.py b/bot/constants.py
index 6d14bbb3a..7b2a38079 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -175,13 +175,14 @@ class YAMLGetter(type):
if cls.subsection is not None:
return _CONFIG_YAML[cls.section][cls.subsection][name]
return _CONFIG_YAML[cls.section][name]
- except KeyError:
+ except KeyError as e:
dotted_path = '.'.join(
(cls.section, cls.subsection, name)
if cls.subsection is not None else (cls.section, name)
)
- log.critical(f"Tried accessing configuration variable at `{dotted_path}`, but it could not be found.")
- raise
+ # Only an INFO log since this can be caught through `hasattr` or `getattr`.
+ log.info(f"Tried accessing configuration variable at `{dotted_path}`, but it could not be found.")
+ raise AttributeError(repr(name)) from e
def __getitem__(cls, name):
return cls.__getattr__(name)
@@ -199,6 +200,7 @@ class Bot(metaclass=YAMLGetter):
prefix: str
sentry_dsn: Optional[str]
token: str
+ trace_loggers: Optional[str]
class Redis(metaclass=YAMLGetter):
@@ -279,6 +281,8 @@ class Emojis(metaclass=YAMLGetter):
badge_partner: str
badge_staff: str
badge_verified_bot_developer: str
+ verified_bot: str
+ bot: str
defcon_shutdown: str # noqa: E704
defcon_unshutdown: str # noqa: E704
@@ -491,6 +495,7 @@ class Roles(metaclass=YAMLGetter):
domain_leads: int
helpers: int
moderators: int
+ mod_team: int
owners: int
project_leads: int
diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py
new file mode 100644
index 000000000..c20115830
--- /dev/null
+++ b/bot/exts/info/code_snippets.py
@@ -0,0 +1,247 @@
+import logging
+import re
+import textwrap
+from urllib.parse import quote_plus
+
+from aiohttp import ClientResponseError
+from discord import Message
+from discord.ext.commands import Cog
+
+from bot.bot import Bot
+from bot.utils.messages import wait_for_deletion
+
+log = logging.getLogger(__name__)
+
+GITHUB_RE = re.compile(
+ r'https://github\.com/(?P<repo>[a-zA-Z0-9-]+/[\w.-]+)/blob/'
+ r'(?P<path>[^#>]+)(\?[^#>]+)?(#L(?P<start_line>\d+)([-~:]L(?P<end_line>\d+))?)'
+)
+
+GITHUB_GIST_RE = re.compile(
+ r'https://gist\.github\.com/([a-zA-Z0-9-]+)/(?P<gist_id>[a-zA-Z0-9]+)/*'
+ r'(?P<revision>[a-zA-Z0-9]*)/*#file-(?P<file_path>[^#>]+?)(\?[^#>]+)?'
+ r'(-L(?P<start_line>\d+)([-~:]L(?P<end_line>\d+))?)'
+)
+
+GITHUB_HEADERS = {'Accept': 'application/vnd.github.v3.raw'}
+
+GITLAB_RE = re.compile(
+ r'https://gitlab\.com/(?P<repo>[\w.-]+/[\w.-]+)/\-/blob/(?P<path>[^#>]+)'
+ r'(\?[^#>]+)?(#L(?P<start_line>\d+)(-(?P<end_line>\d+))?)'
+)
+
+BITBUCKET_RE = re.compile(
+ r'https://bitbucket\.org/(?P<repo>[a-zA-Z0-9-]+/[\w.-]+)/src/(?P<ref>[0-9a-zA-Z]+)'
+ r'/(?P<file_path>[^#>]+)(\?[^#>]+)?(#lines-(?P<start_line>\d+)(:(?P<end_line>\d+))?)'
+)
+
+
+class CodeSnippets(Cog):
+ """
+ Cog that parses and sends code snippets to Discord.
+
+ Matches each message against a regex and prints the contents of all matched snippets.
+ """
+
+ async def _fetch_response(self, url: str, response_format: str, **kwargs) -> str:
+ """Makes http requests using aiohttp."""
+ try:
+ async with self.bot.http_session.get(url, raise_for_status=True, **kwargs) as response:
+ if response_format == 'text':
+ return await response.text()
+ elif response_format == 'json':
+ return await response.json()
+ except ClientResponseError as error:
+ log.error(f'Failed to fetch code snippet from {url}. HTTP Status: {error.status}. Message: {str(error)}.')
+
+ def _find_ref(self, path: str, refs: tuple) -> tuple:
+ """Loops through all branches and tags to find the required ref."""
+ # Base case: there is no slash in the branch name
+ ref, file_path = path.split('/', 1)
+ # In case there are slashes in the branch name, we loop through all branches and tags
+ for possible_ref in refs:
+ if path.startswith(possible_ref['name'] + '/'):
+ ref = possible_ref['name']
+ file_path = path[len(ref) + 1:]
+ break
+ return (ref, file_path)
+
+ async def _fetch_github_snippet(
+ self,
+ repo: str,
+ path: str,
+ start_line: str,
+ end_line: str
+ ) -> str:
+ """Fetches a snippet from a GitHub repo."""
+ # Search the GitHub API for the specified branch
+ branches = await self._fetch_response(
+ f'https://api.github.com/repos/{repo}/branches',
+ 'json',
+ headers=GITHUB_HEADERS
+ )
+ tags = await self._fetch_response(f'https://api.github.com/repos/{repo}/tags', 'json', headers=GITHUB_HEADERS)
+ refs = branches + tags
+ ref, file_path = self._find_ref(path, refs)
+
+ file_contents = await self._fetch_response(
+ f'https://api.github.com/repos/{repo}/contents/{file_path}?ref={ref}',
+ 'text',
+ headers=GITHUB_HEADERS,
+ )
+ return self._snippet_to_codeblock(file_contents, file_path, start_line, end_line)
+
+ async def _fetch_github_gist_snippet(
+ self,
+ gist_id: str,
+ revision: str,
+ file_path: str,
+ start_line: str,
+ end_line: str
+ ) -> str:
+ """Fetches a snippet from a GitHub gist."""
+ gist_json = await self._fetch_response(
+ f'https://api.github.com/gists/{gist_id}{f"/{revision}" if len(revision) > 0 else ""}',
+ 'json',
+ headers=GITHUB_HEADERS,
+ )
+
+ # Check each file in the gist for the specified file
+ for gist_file in gist_json['files']:
+ if file_path == gist_file.lower().replace('.', '-'):
+ file_contents = await self._fetch_response(
+ gist_json['files'][gist_file]['raw_url'],
+ 'text',
+ )
+ return self._snippet_to_codeblock(file_contents, gist_file, start_line, end_line)
+ return ''
+
+ async def _fetch_gitlab_snippet(
+ self,
+ repo: str,
+ path: str,
+ start_line: str,
+ end_line: str
+ ) -> str:
+ """Fetches a snippet from a GitLab repo."""
+ enc_repo = quote_plus(repo)
+
+ # Searches the GitLab API for the specified branch
+ branches = await self._fetch_response(
+ f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/branches',
+ 'json'
+ )
+ tags = await self._fetch_response(f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/tags', 'json')
+ refs = branches + tags
+ ref, file_path = self._find_ref(path, refs)
+ enc_ref = quote_plus(ref)
+ enc_file_path = quote_plus(file_path)
+
+ file_contents = await self._fetch_response(
+ f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/files/{enc_file_path}/raw?ref={enc_ref}',
+ 'text',
+ )
+ return self._snippet_to_codeblock(file_contents, file_path, start_line, end_line)
+
+ async def _fetch_bitbucket_snippet(
+ self,
+ repo: str,
+ ref: str,
+ file_path: str,
+ start_line: int,
+ end_line: int
+ ) -> str:
+ """Fetches a snippet from a BitBucket repo."""
+ file_contents = await self._fetch_response(
+ f'https://bitbucket.org/{quote_plus(repo)}/raw/{quote_plus(ref)}/{quote_plus(file_path)}',
+ 'text',
+ )
+ return self._snippet_to_codeblock(file_contents, file_path, start_line, end_line)
+
+ def _snippet_to_codeblock(self, file_contents: str, file_path: str, start_line: str, end_line: str) -> str:
+ """
+ Given the entire file contents and target lines, creates a code block.
+
+ First, we split the file contents into a list of lines and then keep and join only the required
+ ones together.
+
+ We then dedent the lines to look nice, and replace all ` characters with `\u200b to prevent
+ markdown injection.
+
+ Finally, we surround the code with ``` characters.
+ """
+ # Parse start_line and end_line into integers
+ if end_line is None:
+ start_line = end_line = int(start_line)
+ else:
+ start_line = int(start_line)
+ end_line = int(end_line)
+
+ split_file_contents = file_contents.splitlines()
+
+ # Make sure that the specified lines are in range
+ if start_line > end_line:
+ start_line, end_line = end_line, start_line
+ if start_line > len(split_file_contents) or end_line < 1:
+ return ''
+ start_line = max(1, start_line)
+ end_line = min(len(split_file_contents), end_line)
+
+ # Gets the code lines, dedents them, and inserts zero-width spaces to prevent Markdown injection
+ required = '\n'.join(split_file_contents[start_line - 1:end_line])
+ required = textwrap.dedent(required).rstrip().replace('`', '`\u200b')
+
+ # Extracts the code language and checks whether it's a "valid" language
+ language = file_path.split('/')[-1].split('.')[-1]
+ trimmed_language = language.replace('-', '').replace('+', '').replace('_', '')
+ is_valid_language = trimmed_language.isalnum()
+ if not is_valid_language:
+ language = ''
+
+ # Adds a label showing the file path to the snippet
+ if start_line == end_line:
+ ret = f'`{file_path}` line {start_line}\n'
+ else:
+ ret = f'`{file_path}` lines {start_line} to {end_line}\n'
+
+ if len(required) != 0:
+ return f'{ret}```{language}\n{required}```'
+ # Returns an empty codeblock if the snippet is empty
+ return f'{ret}``` ```'
+
+ def __init__(self, bot: Bot):
+ """Initializes the cog's bot."""
+ self.bot = bot
+
+ self.pattern_handlers = [
+ (GITHUB_RE, self._fetch_github_snippet),
+ (GITHUB_GIST_RE, self._fetch_github_gist_snippet),
+ (GITLAB_RE, self._fetch_gitlab_snippet),
+ (BITBUCKET_RE, self._fetch_bitbucket_snippet)
+ ]
+
+ @Cog.listener()
+ async def on_message(self, message: Message) -> None:
+ """Checks if the message has a snippet link, removes the embed, then sends the snippet contents."""
+ if not message.author.bot:
+ all_snippets = []
+
+ for pattern, handler in self.pattern_handlers:
+ for match in pattern.finditer(message.content):
+ snippet = await handler(**match.groupdict())
+ all_snippets.append((match.start(), snippet))
+
+ # Sorts the list of snippets by their match index and joins them into a single message
+ message_to_send = '\n'.join(map(lambda x: x[1], sorted(all_snippets)))
+
+ if 0 < len(message_to_send) <= 2000 and len(all_snippets) <= 15:
+ await message.edit(suppress=True)
+ await wait_for_deletion(
+ await message.channel.send(message_to_send),
+ (message.author.id,)
+ )
+
+
+def setup(bot: Bot) -> None:
+ """Load the CodeSnippets cog."""
+ bot.add_cog(CodeSnippets(bot))
diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py
index 5e2c4b417..834fee1b4 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -230,6 +230,11 @@ class Information(Cog):
if on_server and user.nick:
name = f"{user.nick} ({name})"
+ if user.public_flags.verified_bot:
+ name += f" {constants.Emojis.verified_bot}"
+ elif user.bot:
+ name += f" {constants.Emojis.bot}"
+
badges = []
for badge, is_set in user.public_flags:
diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py
index d89e80acc..38d1ffc0e 100644
--- a/bot/exts/moderation/infraction/infractions.py
+++ b/bot/exts/moderation/infraction/infractions.py
@@ -54,8 +54,12 @@ class Infractions(InfractionScheduler, commands.Cog):
# region: Permanent infractions
@command()
- async def warn(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None:
+ async def warn(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:
"""Warn a user for the given reason."""
+ if not isinstance(user, Member):
+ await ctx.send(":x: The user doesn't appear to be on the server.")
+ return
+
infraction = await _utils.post_infraction(ctx, user, "warning", reason, active=False)
if infraction is None:
return
@@ -63,8 +67,12 @@ class Infractions(InfractionScheduler, commands.Cog):
await self.apply_infraction(ctx, infraction, user)
@command()
- async def kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None:
+ async def kick(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:
"""Kick a user for the given reason."""
+ if not isinstance(user, Member):
+ await ctx.send(":x: The user doesn't appear to be on the server.")
+ return
+
await self.apply_kick(ctx, user, reason)
@command()
@@ -100,7 +108,7 @@ class Infractions(InfractionScheduler, commands.Cog):
@command(aliases=["mute"])
async def tempmute(
self, ctx: Context,
- user: Member,
+ user: FetchedMember,
duration: t.Optional[Expiry] = None,
*,
reason: t.Optional[str] = None
@@ -122,6 +130,10 @@ class Infractions(InfractionScheduler, commands.Cog):
If no duration is given, a one hour duration is used by default.
"""
+ if not isinstance(user, Member):
+ await ctx.send(":x: The user doesn't appear to be on the server.")
+ return
+
if duration is None:
duration = await Duration().convert(ctx, "1h")
await self.apply_mute(ctx, user, reason, expires_at=duration)
diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py
index 704dddf9c..07e79b9fe 100644
--- a/bot/exts/moderation/infraction/superstarify.py
+++ b/bot/exts/moderation/infraction/superstarify.py
@@ -11,7 +11,7 @@ from discord.utils import escape_markdown
from bot import constants
from bot.bot import Bot
-from bot.converters import Expiry
+from bot.converters import Duration, Expiry
from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction._scheduler import InfractionScheduler
from bot.utils.messages import format_user
@@ -19,6 +19,7 @@ from bot.utils.time import format_infraction
log = logging.getLogger(__name__)
NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy"
+SUPERSTARIFY_DEFAULT_DURATION = "1h"
with Path("bot/resources/stars.json").open(encoding="utf-8") as stars_file:
STAR_NAMES = json.load(stars_file)
@@ -109,7 +110,7 @@ class Superstarify(InfractionScheduler, Cog):
self,
ctx: Context,
member: Member,
- duration: Expiry,
+ duration: t.Optional[Expiry],
*,
reason: str = '',
) -> None:
@@ -134,6 +135,9 @@ class Superstarify(InfractionScheduler, Cog):
if await _utils.get_active_infraction(ctx, member, "superstar"):
return
+ # Set to default duration if none was provided.
+ duration = duration or await Duration().convert(ctx, SUPERSTARIFY_DEFAULT_DURATION)
+
# Post the infraction to the API
old_nick = member.display_name
infraction_reason = f'Old nickname: {old_nick}. {reason}'
diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py
index 2dae9d268..e92f76c9a 100644
--- a/bot/exts/moderation/modlog.py
+++ b/bot/exts/moderation/modlog.py
@@ -14,7 +14,7 @@ from discord.abc import GuildChannel
from discord.ext.commands import Cog, Context
from bot.bot import Bot
-from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs
+from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, Roles, URLs
from bot.utils.messages import format_user
from bot.utils.time import humanize_delta
@@ -115,9 +115,9 @@ class ModLog(Cog, name="ModLog"):
if ping_everyone:
if content:
- content = f"@everyone\n{content}"
+ content = f"<@&{Roles.moderators}>\n{content}"
else:
- content = "@everyone"
+ content = f"<@&{Roles.moderators}>"
# Truncate content to 2000 characters and append an ellipsis.
if content and len(content) > 2000:
@@ -127,8 +127,7 @@ class ModLog(Cog, name="ModLog"):
log_message = await channel.send(
content=content,
embed=embed,
- files=files,
- allowed_mentions=discord.AllowedMentions(everyone=True)
+ files=files
)
if additional_embeds:
diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py
new file mode 100644
index 000000000..2f180e594
--- /dev/null
+++ b/bot/exts/moderation/modpings.py
@@ -0,0 +1,136 @@
+import datetime
+import logging
+
+from async_rediscache import RedisCache
+from dateutil.parser import isoparse
+from discord import Member
+from discord.ext.commands import Cog, Context, group, has_any_role
+
+from bot.bot import Bot
+from bot.constants import Emojis, Guild, MODERATION_ROLES, Roles
+from bot.converters import Expiry
+from bot.utils.scheduling import Scheduler
+
+log = logging.getLogger(__name__)
+
+
+class ModPings(Cog):
+ """Commands for a moderator to turn moderator pings on and off."""
+
+ # RedisCache[discord.Member.id, 'Naïve ISO 8601 string']
+ # The cache's keys are mods who have pings off.
+ # The cache's values are the times when the role should be re-applied to them, stored in ISO format.
+ pings_off_mods = RedisCache()
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self._role_scheduler = Scheduler(self.__class__.__name__)
+
+ self.guild = None
+ self.moderators_role = None
+
+ self.reschedule_task = self.bot.loop.create_task(self.reschedule_roles(), name="mod-pings-reschedule")
+
+ async def reschedule_roles(self) -> None:
+ """Reschedule moderators role re-apply times."""
+ await self.bot.wait_until_guild_available()
+ self.guild = self.bot.get_guild(Guild.id)
+ self.moderators_role = self.guild.get_role(Roles.moderators)
+
+ mod_team = self.guild.get_role(Roles.mod_team)
+ pings_on = self.moderators_role.members
+ pings_off = await self.pings_off_mods.to_dict()
+
+ log.trace("Applying the moderators role to the mod team where necessary.")
+ for mod in mod_team.members:
+ if mod in pings_on: # Make sure that on-duty mods aren't in the cache.
+ if mod in pings_off:
+ await self.pings_off_mods.delete(mod.id)
+ continue
+
+ # Keep the role off only for those in the cache.
+ if mod.id not in pings_off:
+ await self.reapply_role(mod)
+ else:
+ expiry = isoparse(pings_off[mod.id]).replace(tzinfo=None)
+ self._role_scheduler.schedule_at(expiry, mod.id, self.reapply_role(mod))
+
+ async def reapply_role(self, mod: Member) -> None:
+ """Reapply the moderator's role to the given moderator."""
+ log.trace(f"Re-applying role to mod with ID {mod.id}.")
+ await mod.add_roles(self.moderators_role, reason="Pings off period expired.")
+
+ @group(name='modpings', aliases=('modping',), invoke_without_command=True)
+ @has_any_role(*MODERATION_ROLES)
+ async def modpings_group(self, ctx: Context) -> None:
+ """Allow the removal and re-addition of the pingable moderators role."""
+ await ctx.send_help(ctx.command)
+
+ @modpings_group.command(name='off')
+ @has_any_role(*MODERATION_ROLES)
+ async def off_command(self, ctx: Context, duration: Expiry) -> None:
+ """
+ Temporarily removes the pingable moderators role for a set amount of time.
+
+ A unit of time should be appended to the duration.
+ Units (∗case-sensitive):
+ \u2003`y` - years
+ \u2003`m` - months∗
+ \u2003`w` - weeks
+ \u2003`d` - days
+ \u2003`h` - hours
+ \u2003`M` - minutes∗
+ \u2003`s` - seconds
+
+ Alternatively, an ISO 8601 timestamp can be provided for the duration.
+
+ The duration cannot be longer than 30 days.
+ """
+ duration: datetime.datetime
+ delta = duration - datetime.datetime.utcnow()
+ if delta > datetime.timedelta(days=30):
+ await ctx.send(":x: Cannot remove the role for longer than 30 days.")
+ return
+
+ mod = ctx.author
+
+ until_date = duration.replace(microsecond=0).isoformat() # Looks noisy with microseconds.
+ await mod.remove_roles(self.moderators_role, reason=f"Turned pings off until {until_date}.")
+
+ await self.pings_off_mods.set(mod.id, duration.isoformat())
+
+ # Allow rescheduling the task without cancelling it separately via the `on` command.
+ if mod.id in self._role_scheduler:
+ self._role_scheduler.cancel(mod.id)
+ self._role_scheduler.schedule_at(duration, mod.id, self.reapply_role(mod))
+
+ await ctx.send(f"{Emojis.check_mark} Moderators role has been removed until {until_date}.")
+
+ @modpings_group.command(name='on')
+ @has_any_role(*MODERATION_ROLES)
+ async def on_command(self, ctx: Context) -> None:
+ """Re-apply the pingable moderators role."""
+ mod = ctx.author
+ if mod in self.moderators_role.members:
+ await ctx.send(":question: You already have the role.")
+ return
+
+ await mod.add_roles(self.moderators_role, reason="Pings off period canceled.")
+
+ await self.pings_off_mods.delete(mod.id)
+
+ # We assume the task exists. Lack of it may indicate a bug.
+ self._role_scheduler.cancel(mod.id)
+
+ await ctx.send(f"{Emojis.check_mark} Moderators role has been re-applied.")
+
+ def cog_unload(self) -> None:
+ """Cancel role tasks when the cog unloads."""
+ log.trace("Cog unload: canceling role tasks.")
+ self.reschedule_task.cancel()
+ self._role_scheduler.cancel_all()
+
+
+def setup(bot: Bot) -> None:
+ """Load the ModPings cog."""
+ bot.add_cog(ModPings(bot))
diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py
index 3113a1149..6c21920a1 100644
--- a/bot/exts/utils/reminders.py
+++ b/bot/exts/utils/reminders.py
@@ -90,15 +90,18 @@ class Reminders(Cog):
delivery_dt: t.Optional[datetime],
) -> None:
"""Send an embed confirming the reminder change was made successfully."""
- embed = discord.Embed()
- embed.colour = discord.Colour.green()
- embed.title = random.choice(POSITIVE_REPLIES)
- embed.description = on_success
+ embed = discord.Embed(
+ description=on_success,
+ colour=discord.Colour.green(),
+ title=random.choice(POSITIVE_REPLIES)
+ )
footer_str = f"ID: {reminder_id}"
+
if delivery_dt:
# Reminder deletion will have a `None` `delivery_dt`
- footer_str = f"{footer_str}, Due: {delivery_dt.strftime('%Y-%m-%dT%H:%M:%S')}"
+ footer_str += ', Due'
+ embed.timestamp = delivery_dt
embed.set_footer(text=footer_str)
diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py
index 8d9d27c64..4c39a7c2a 100644
--- a/bot/exts/utils/utils.py
+++ b/bot/exts/utils/utils.py
@@ -109,7 +109,7 @@ class Utils(Cog):
# handle if it's an index int
if isinstance(search_value, int):
upper_bound = len(zen_lines) - 1
- lower_bound = -1 * upper_bound
+ lower_bound = -1 * len(zen_lines)
if not (lower_bound <= search_value <= upper_bound):
raise BadArgument(f"Please provide an index between {lower_bound} and {upper_bound}.")
diff --git a/bot/log.py b/bot/log.py
index e92233a33..4e20c005e 100644
--- a/bot/log.py
+++ b/bot/log.py
@@ -20,7 +20,6 @@ def setup() -> None:
logging.addLevelName(TRACE_LEVEL, "TRACE")
Logger.trace = _monkeypatch_trace
- log_level = TRACE_LEVEL if constants.DEBUG_MODE else logging.INFO
format_string = "%(asctime)s | %(name)s | %(levelname)s | %(message)s"
log_format = logging.Formatter(format_string)
@@ -30,7 +29,6 @@ def setup() -> None:
file_handler.setFormatter(log_format)
root_log = logging.getLogger()
- root_log.setLevel(log_level)
root_log.addHandler(file_handler)
if "COLOREDLOGS_LEVEL_STYLES" not in os.environ:
@@ -44,11 +42,9 @@ def setup() -> None:
if "COLOREDLOGS_LOG_FORMAT" not in os.environ:
coloredlogs.DEFAULT_LOG_FORMAT = format_string
- if "COLOREDLOGS_LOG_LEVEL" not in os.environ:
- coloredlogs.DEFAULT_LOG_LEVEL = log_level
-
- coloredlogs.install(logger=root_log, stream=sys.stdout)
+ coloredlogs.install(level=logging.TRACE, logger=root_log, stream=sys.stdout)
+ root_log.setLevel(logging.DEBUG if constants.DEBUG_MODE else logging.INFO)
logging.getLogger("discord").setLevel(logging.WARNING)
logging.getLogger("websockets").setLevel(logging.WARNING)
logging.getLogger("chardet").setLevel(logging.WARNING)
@@ -57,6 +53,8 @@ def setup() -> None:
# Set back to the default of INFO even if asyncio's debug mode is enabled.
logging.getLogger("asyncio").setLevel(logging.INFO)
+ _set_trace_loggers()
+
def setup_sentry() -> None:
"""Set up the Sentry logging integrations."""
@@ -86,3 +84,30 @@ def _monkeypatch_trace(self: logging.Logger, msg: str, *args, **kwargs) -> None:
"""
if self.isEnabledFor(TRACE_LEVEL):
self._log(TRACE_LEVEL, msg, args, **kwargs)
+
+
+def _set_trace_loggers() -> None:
+ """
+ Set loggers to the trace level according to the value from the BOT_TRACE_LOGGERS env var.
+
+ When the env var is a list of logger names delimited by a comma,
+ each of the listed loggers will be set to the trace level.
+
+ If this list is prefixed with a "!", all of the loggers except the listed ones will be set to the trace level.
+
+ Otherwise if the env var begins with a "*",
+ the root logger is set to the trace level and other contents are ignored.
+ """
+ level_filter = constants.Bot.trace_loggers
+ if level_filter:
+ if level_filter.startswith("*"):
+ logging.getLogger().setLevel(logging.TRACE)
+
+ elif level_filter.startswith("!"):
+ logging.getLogger().setLevel(logging.TRACE)
+ for logger_name in level_filter.strip("!,").split(","):
+ logging.getLogger(logger_name).setLevel(logging.DEBUG)
+
+ else:
+ for logger_name in level_filter.strip(",").split(","):
+ logging.getLogger(logger_name).setLevel(logging.TRACE)
diff --git a/config-default.yml b/config-default.yml
index 8c6e18470..46475f845 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -1,7 +1,8 @@
bot:
- prefix: "!"
- sentry_dsn: !ENV "BOT_SENTRY_DSN"
- token: !ENV "BOT_TOKEN"
+ prefix: "!"
+ sentry_dsn: !ENV "BOT_SENTRY_DSN"
+ token: !ENV "BOT_TOKEN"
+ trace_loggers: !ENV "BOT_TRACE_LOGGERS"
clean:
# Maximum number of messages to traverse for clean commands
@@ -46,6 +47,8 @@ style:
badge_partner: "<:partner:748666453242413136>"
badge_staff: "<:discord_staff:743882896498098226>"
badge_verified_bot_developer: "<:verified_bot_dev:743882897299210310>"
+ bot: "<:bot:812712599464443914>"
+ verified_bot: "<:verified_bot:811645219220750347>"
defcon_shutdown: "<:defcondisabled:470326273952972810>"
defcon_unshutdown: "<:defconenabled:470326274213150730>"
@@ -260,7 +263,8 @@ guild:
devops: 409416496733880320
domain_leads: 807415650778742785
helpers: &HELPERS_ROLE 267630620367257601
- moderators: &MODS_ROLE 267629731250176001
+ moderators: &MODS_ROLE 831776746206265384
+ mod_team: &MOD_TEAM_ROLE 267629731250176001
owners: &OWNERS_ROLE 267627879762755584
project_leads: 815701647526330398
@@ -273,13 +277,14 @@ guild:
moderation_roles:
- *ADMINS_ROLE
+ - *MOD_TEAM_ROLE
- *MODS_ROLE
- *OWNERS_ROLE
staff_roles:
- *ADMINS_ROLE
- *HELPERS_ROLE
- - *MODS_ROLE
+ - *MOD_TEAM_ROLE
- *OWNERS_ROLE
webhooks:
diff --git a/tests/README.md b/tests/README.md
index 4f62edd68..092324123 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -114,7 +114,7 @@ class BotCogTests(unittest.TestCase):
### Mocking coroutines
-By default, the `unittest.mock.Mock` and `unittest.mock.MagicMock` classes cannot mock coroutines, since the `__call__` method they provide is synchronous. In anticipation of the `AsyncMock` that will be [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest), we have added an `AsyncMock` helper to [`helpers.py`](/tests/helpers.py). Do note that this drop-in replacement only implements an asynchronous `__call__` method, not the additional assertions that will come with the new `AsyncMock` type in Python 3.8.
+By default, the `unittest.mock.Mock` and `unittest.mock.MagicMock` classes cannot mock coroutines, since the `__call__` method they provide is synchronous. The [`AsyncMock`](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.AsyncMock) that has been [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest) is an asynchronous version of `MagicMock` that can be used anywhere a coroutine is expected.
### Special mocks for some `discord.py` types
diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py
index a996ce477..770660fe3 100644
--- a/tests/bot/exts/info/test_information.py
+++ b/tests/bot/exts/info/test_information.py
@@ -281,6 +281,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
"""The embed should use the string representation of the user if they don't have a nick."""
ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1))
user = helpers.MockMember()
+ user.public_flags = unittest.mock.MagicMock(verified_bot=False)
user.nick = None
user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock")
user.colour = 0
@@ -297,6 +298,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
"""The embed should use the nick if it's available."""
ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1))
user = helpers.MockMember()
+ user.public_flags = unittest.mock.MagicMock(verified_bot=False)
user.nick = "Cat lover"
user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock")
user.colour = 0