aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/build.yml4
-rw-r--r--.github/workflows/deploy.yml6
-rw-r--r--.github/workflows/lint-test.yml10
-rw-r--r--.github/workflows/sentry_release.yml4
-rw-r--r--.github/workflows/status_embed.yaml4
-rw-r--r--.gitignore1
-rw-r--r--Dockerfile2
-rw-r--r--bot/__init__.py9
-rw-r--r--bot/async_stats.py4
-rw-r--r--bot/bot.py2
-rw-r--r--bot/command.py18
-rw-r--r--bot/constants.py2
-rw-r--r--bot/converters.py36
-rw-r--r--bot/decorators.py4
-rw-r--r--bot/errors.py19
-rw-r--r--bot/exts/backend/branding/_cog.py3
-rw-r--r--bot/exts/backend/config_verifier.py4
-rw-r--r--bot/exts/backend/logging.py4
-rw-r--r--bot/exts/backend/sync/_cog.py3
-rw-r--r--bot/exts/backend/sync/_syncers.py3
-rw-r--r--bot/exts/events/code_jams/_cog.py7
-rw-r--r--bot/exts/filters/antimalware.py2
-rw-r--r--bot/exts/filters/antispam.py45
-rw-r--r--bot/exts/filters/filter_lists.py3
-rw-r--r--bot/exts/filters/filtering.py18
-rw-r--r--bot/exts/filters/token_remover.py7
-rw-r--r--bot/exts/fun/duck_pond.py3
-rw-r--r--bot/exts/fun/off_topic_names.py5
-rw-r--r--bot/exts/help_channels/_channel.py4
-rw-r--r--bot/exts/help_channels/_cog.py14
-rw-r--r--bot/exts/info/codeblock/_cog.py4
-rw-r--r--bot/exts/info/doc/_batch_parser.py5
-rw-r--r--bot/exts/info/doc/_cog.py32
-rw-r--r--bot/exts/info/doc/_inventory_parser.py22
-rw-r--r--bot/exts/info/information.py12
-rw-r--r--bot/exts/info/pep.py3
-rw-r--r--bot/exts/info/python_news.py26
-rw-r--r--bot/exts/moderation/defcon.py6
-rw-r--r--bot/exts/moderation/incidents.py5
-rw-r--r--bot/exts/moderation/infraction/_scheduler.py2
-rw-r--r--bot/exts/moderation/infraction/infractions.py5
-rw-r--r--bot/exts/moderation/infraction/management.py39
-rw-r--r--bot/exts/moderation/infraction/superstarify.py3
-rw-r--r--bot/exts/moderation/metabase.py4
-rw-r--r--bot/exts/moderation/modpings.py7
-rw-r--r--bot/exts/moderation/silence.py3
-rw-r--r--bot/exts/moderation/stream.py29
-rw-r--r--bot/exts/moderation/watchchannels/_watchchannel.py16
-rw-r--r--bot/exts/recruitment/talentpool/_cog.py268
-rw-r--r--bot/exts/recruitment/talentpool/_review.py31
-rw-r--r--bot/exts/utils/extensions.py2
-rw-r--r--bot/exts/utils/reminders.py24
-rw-r--r--bot/exts/utils/snekbox.py4
-rw-r--r--bot/monkey_patches.py50
-rw-r--r--bot/resources/tags/paste.md2
-rw-r--r--bot/resources/tags/string-formatting.md24
-rw-r--r--bot/utils/channel.py2
-rw-r--r--bot/utils/members.py24
-rw-r--r--bot/utils/regex.py3
-rw-r--r--config-default.yml11
-rw-r--r--docker-compose.yml22
-rw-r--r--pyproject.toml3
-rw-r--r--tests/bot/exts/backend/sync/test_cog.py7
-rw-r--r--tests/bot/exts/backend/sync/test_users.py5
-rw-r--r--tests/bot/exts/filters/test_token_remover.py13
-rw-r--r--tests/bot/exts/moderation/infraction/test_infractions.py3
-rw-r--r--tests/helpers.py5
67 files changed, 653 insertions, 323 deletions
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 84a671917..f8f2c8888 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -8,6 +8,10 @@ on:
types:
- completed
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
build:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 8b809b777..88abe6fb6 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -8,6 +8,10 @@ on:
types:
- completed
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
build:
environment: production
@@ -38,6 +42,6 @@ jobs:
uses: Azure/k8s-deploy@v1
with:
manifests: |
- bot/deployment.yaml
+ namespaces/default/bot/deployment.yaml
images: 'ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }}'
kubectl-version: 'latest'
diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml
index ac7e47f0e..2f42f1895 100644
--- a/.github/workflows/lint-test.yml
+++ b/.github/workflows/lint-test.yml
@@ -6,6 +6,9 @@ on:
- main
pull_request:
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
jobs:
lint-test:
@@ -118,13 +121,6 @@ jobs:
- name: Run tests and generate coverage report
run: pytest -n auto --cov --disable-warnings -q
- # This step will publish the coverage reports coveralls.io and
- # print a "job" link in the output of the GitHub Action
- - name: Publish coverage report to coveralls.io
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: coveralls
-
# Prepare the Pull Request Payload artifact. If this fails, we
# we fail silently using the `continue-on-error` option. It's
# nice if this succeeds, but if it fails for any reason, it
diff --git a/.github/workflows/sentry_release.yml b/.github/workflows/sentry_release.yml
index f6a1e1f0e..48f5e50f4 100644
--- a/.github/workflows/sentry_release.yml
+++ b/.github/workflows/sentry_release.yml
@@ -5,6 +5,10 @@ on:
branches:
- main
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
create_sentry_release:
runs-on: ubuntu-latest
diff --git a/.github/workflows/status_embed.yaml b/.github/workflows/status_embed.yaml
index b6a71b887..4178c366d 100644
--- a/.github/workflows/status_embed.yaml
+++ b/.github/workflows/status_embed.yaml
@@ -9,6 +9,10 @@ on:
types:
- completed
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
status_embed:
# We need to send a status embed whenever the workflow
diff --git a/.gitignore b/.gitignore
index f74a142f3..177345908 100644
--- a/.gitignore
+++ b/.gitignore
@@ -116,6 +116,7 @@ log.*
# Custom user configuration
config.yml
docker-compose.override.yml
+metricity-config.toml
# xmlrunner unittest XML reports
TEST-**.xml
diff --git a/Dockerfile b/Dockerfile
index 4d8592590..30bf8a361 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.9-slim
+FROM --platform=linux/amd64 python:3.9-slim
# Set pip to have no saved cache
ENV PIP_NO_CACHE_DIR=false \
diff --git a/bot/__init__.py b/bot/__init__.py
index 8f880b8e6..a1c4466f1 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -5,8 +5,7 @@ from typing import TYPE_CHECKING
from discord.ext import commands
-from bot import log
-from bot.command import Command
+from bot import log, monkey_patches
if TYPE_CHECKING:
from bot.bot import Bot
@@ -17,9 +16,11 @@ log.setup()
if os.name == "nt":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
+monkey_patches.patch_typing()
+
# Monkey-patch discord.py decorators to use the Command subclass which supports root aliases.
# Must be patched before any cogs are added.
-commands.command = partial(commands.command, cls=Command)
-commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=Command)
+commands.command = partial(commands.command, cls=monkey_patches.Command)
+commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=monkey_patches.Command)
instance: "Bot" = None # Global Bot instance.
diff --git a/bot/async_stats.py b/bot/async_stats.py
index 58a80f528..2af832e5b 100644
--- a/bot/async_stats.py
+++ b/bot/async_stats.py
@@ -3,6 +3,8 @@ import socket
from statsd.client.base import StatsClientBase
+from bot.utils import scheduling
+
class AsyncStatsClient(StatsClientBase):
"""An async transport method for statsd communication."""
@@ -32,7 +34,7 @@ class AsyncStatsClient(StatsClientBase):
def _send(self, data: str) -> None:
"""Start an async task to send data to statsd."""
- self._loop.create_task(self._async_send(data))
+ scheduling.create_task(self._async_send(data), event_loop=self._loop)
async def _async_send(self, data: str) -> None:
"""Send data to the statsd server using the async transport."""
diff --git a/bot/bot.py b/bot/bot.py
index 914da9c98..db3d651a3 100644
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -109,7 +109,7 @@ class Bot(commands.Bot):
def create(cls) -> "Bot":
"""Create and return an instance of a Bot."""
loop = asyncio.get_event_loop()
- allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES]
+ allowed_roles = list({discord.Object(id_) for id_ in constants.MODERATION_ROLES})
intents = discord.Intents.all()
intents.presences = False
diff --git a/bot/command.py b/bot/command.py
deleted file mode 100644
index 0fb900f7b..000000000
--- a/bot/command.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from discord.ext import commands
-
-
-class Command(commands.Command):
- """
- A `discord.ext.commands.Command` subclass which supports root aliases.
-
- A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as
- top-level commands rather than being aliases of the command's group. It's stored as an attribute
- also named `root_aliases`.
- """
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.root_aliases = kwargs.get("root_aliases", [])
-
- if not isinstance(self.root_aliases, (list, tuple)):
- raise TypeError("Root aliases of a command must be a list or a tuple of strings.")
diff --git a/bot/constants.py b/bot/constants.py
index 4e99df7f3..f99913b17 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -472,7 +472,6 @@ class Channels(metaclass=YAMLGetter):
voice_chat_1: int
big_brother_logs: int
- talent_pool: int
class Webhooks(metaclass=YAMLGetter):
@@ -483,7 +482,6 @@ class Webhooks(metaclass=YAMLGetter):
dev_log: int
duck_pond: int
incidents_archive: int
- talent_pool: int
class Roles(metaclass=YAMLGetter):
diff --git a/bot/converters.py b/bot/converters.py
index bd4044c7e..c96e2c984 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -17,6 +17,7 @@ from discord.utils import DISCORD_EPOCH, escape_markdown, snowflake_time
from bot import exts
from bot.api import ResponseCodeError
from bot.constants import URLs
+from bot.errors import InvalidInfraction
from bot.exts.info.doc import _inventory_parser
from bot.utils.extensions import EXTENSIONS, unqualify
from bot.utils.regex import INVITE_RE
@@ -234,11 +235,16 @@ class Inventory(Converter):
async def convert(ctx: Context, url: str) -> t.Tuple[str, _inventory_parser.InventoryDict]:
"""Convert url to Intersphinx inventory URL."""
await ctx.trigger_typing()
- if (inventory := await _inventory_parser.fetch_inventory(url)) is None:
- raise BadArgument(
- f"Failed to fetch inventory file after {_inventory_parser.FAILED_REQUEST_ATTEMPTS} attempts."
- )
- return url, inventory
+ try:
+ inventory = await _inventory_parser.fetch_inventory(url)
+ except _inventory_parser.InvalidHeaderError:
+ raise BadArgument("Unable to parse inventory because of invalid header, check if URL is correct.")
+ else:
+ if inventory is None:
+ raise BadArgument(
+ f"Failed to fetch inventory file after {_inventory_parser.FAILED_REQUEST_ATTEMPTS} attempts."
+ )
+ return url, inventory
class Snowflake(IDConverter):
@@ -391,7 +397,8 @@ class Duration(DurationDelta):
class OffTopicName(Converter):
"""A converter that ensures an added off-topic name is valid."""
- ALLOWED_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-"
+ ALLOWED_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-<>"
+ TRANSLATED_CHARACTERS = "๐– ๐–ก๐–ข๐–ฃ๐–ค๐–ฅ๐–ฆ๐–ง๐–จ๐–ฉ๐–ช๐–ซ๐–ฌ๐–ญ๐–ฎ๐–ฏ๐–ฐ๐–ฑ๐–ฒ๐–ณ๐–ด๐–ต๐–ถ๐–ท๐–ธ๐–นวƒ๏ผŸโ€™โ€™-๏ผœ๏ผž"
@classmethod
def translate_name(cls, name: str, *, from_unicode: bool = True) -> str:
@@ -401,9 +408,9 @@ class OffTopicName(Converter):
If `from_unicode` is True, the name is translated from a discord-safe format, back to normalized text.
"""
if from_unicode:
- table = str.maketrans(cls.ALLOWED_CHARACTERS, '๐– ๐–ก๐–ข๐–ฃ๐–ค๐–ฅ๐–ฆ๐–ง๐–จ๐–ฉ๐–ช๐–ซ๐–ฌ๐–ญ๐–ฎ๐–ฏ๐–ฐ๐–ฑ๐–ฒ๐–ณ๐–ด๐–ต๐–ถ๐–ท๐–ธ๐–นวƒ๏ผŸโ€™โ€™-')
+ table = str.maketrans(cls.ALLOWED_CHARACTERS, cls.TRANSLATED_CHARACTERS)
else:
- table = str.maketrans('๐– ๐–ก๐–ข๐–ฃ๐–ค๐–ฅ๐–ฆ๐–ง๐–จ๐–ฉ๐–ช๐–ซ๐–ฌ๐–ญ๐–ฎ๐–ฏ๐–ฐ๐–ฑ๐–ฒ๐–ณ๐–ด๐–ต๐–ถ๐–ท๐–ธ๐–นวƒ๏ผŸโ€™โ€™-', cls.ALLOWED_CHARACTERS)
+ table = str.maketrans(cls.TRANSLATED_CHARACTERS, cls.ALLOWED_CHARACTERS)
return name.translate(table)
@@ -558,7 +565,7 @@ class Infraction(Converter):
"ordering": "-inserted_at"
}
- infractions = await ctx.bot.api_client.get("bot/infractions", params=params)
+ infractions = await ctx.bot.api_client.get("bot/infractions/expanded", params=params)
if not infractions:
raise BadArgument(
@@ -568,7 +575,16 @@ class Infraction(Converter):
return infractions[0]
else:
- return await ctx.bot.api_client.get(f"bot/infractions/{arg}")
+ try:
+ return await ctx.bot.api_client.get(f"bot/infractions/{arg}/expanded")
+ except ResponseCodeError as e:
+ if e.status == 404:
+ raise InvalidInfraction(
+ converter=Infraction,
+ original=e,
+ infraction_arg=arg
+ )
+ raise e
if t.TYPE_CHECKING:
diff --git a/bot/decorators.py b/bot/decorators.py
index f65ec4103..ee210be26 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -10,7 +10,7 @@ from discord.ext import commands
from discord.ext.commands import Cog, Context
from bot.constants import Channels, DEBUG_MODE, RedirectOutput
-from bot.utils import function
+from bot.utils import function, scheduling
from bot.utils.checks import ContextCheckFailure, in_whitelist_check
from bot.utils.function import command_wraps
@@ -154,7 +154,7 @@ def redirect_output(
if ping_user:
await ctx.send(f"Here's the output of your command, {ctx.author.mention}")
- asyncio.create_task(func(self, ctx, *args, **kwargs))
+ scheduling.create_task(func(self, ctx, *args, **kwargs))
message = await old_channel.send(
f"Hey, {ctx.author.mention}, you can find the output of your command here: "
diff --git a/bot/errors.py b/bot/errors.py
index 2633390a8..078b645f1 100644
--- a/bot/errors.py
+++ b/bot/errors.py
@@ -1,6 +1,9 @@
from __future__ import annotations
-from typing import Hashable, TYPE_CHECKING
+from typing import Hashable, TYPE_CHECKING, Union
+
+from discord.ext.commands import ConversionError, Converter
+
if TYPE_CHECKING:
from bot.converters import MemberOrUser
@@ -40,6 +43,20 @@ class InvalidInfractedUserError(Exception):
super().__init__(reason)
+class InvalidInfraction(ConversionError):
+ """
+ Raised by the Infraction converter when trying to fetch an invalid infraction id.
+
+ Attributes:
+ `infraction_arg` -- the value that we attempted to convert into an Infraction
+ """
+
+ def __init__(self, converter: Converter, original: Exception, infraction_arg: Union[int, str]):
+
+ self.infraction_arg = infraction_arg
+ super().__init__(converter, original)
+
+
class BrandingMisconfiguration(RuntimeError):
"""Raised by the Branding cog when a misconfigured event is encountered."""
diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py
index 0ba146635..ab0a761ff 100644
--- a/bot/exts/backend/branding/_cog.py
+++ b/bot/exts/backend/branding/_cog.py
@@ -17,6 +17,7 @@ from bot.bot import Bot
from bot.constants import Branding as BrandingConfig, Channels, Colours, Guild, MODERATION_ROLES
from bot.decorators import mock_in_debug
from bot.exts.backend.branding._repository import BrandingRepository, Event, RemoteObject
+from bot.utils import scheduling
log = logging.getLogger(__name__)
@@ -126,7 +127,7 @@ class Branding(commands.Cog):
self.bot = bot
self.repository = BrandingRepository(bot)
- self.bot.loop.create_task(self.maybe_start_daemon()) # Start depending on cache.
+ scheduling.create_task(self.maybe_start_daemon(), event_loop=self.bot.loop) # Start depending on cache.
# region: Internal logic & state management
diff --git a/bot/exts/backend/config_verifier.py b/bot/exts/backend/config_verifier.py
index d72c6c22e..c24cb324f 100644
--- a/bot/exts/backend/config_verifier.py
+++ b/bot/exts/backend/config_verifier.py
@@ -4,7 +4,7 @@ from discord.ext.commands import Cog
from bot import constants
from bot.bot import Bot
-
+from bot.utils import scheduling
log = logging.getLogger(__name__)
@@ -14,7 +14,7 @@ class ConfigVerifier(Cog):
def __init__(self, bot: Bot):
self.bot = bot
- self.channel_verify_task = self.bot.loop.create_task(self.verify_channels())
+ self.channel_verify_task = scheduling.create_task(self.verify_channels(), event_loop=self.bot.loop)
async def verify_channels(self) -> None:
"""
diff --git a/bot/exts/backend/logging.py b/bot/exts/backend/logging.py
index 823f14ea4..8f1b8026f 100644
--- a/bot/exts/backend/logging.py
+++ b/bot/exts/backend/logging.py
@@ -5,7 +5,7 @@ from discord.ext.commands import Cog
from bot.bot import Bot
from bot.constants import Channels, DEBUG_MODE
-
+from bot.utils import scheduling
log = logging.getLogger(__name__)
@@ -16,7 +16,7 @@ class Logging(Cog):
def __init__(self, bot: Bot):
self.bot = bot
- self.bot.loop.create_task(self.startup_greeting())
+ scheduling.create_task(self.startup_greeting(), event_loop=self.bot.loop)
async def startup_greeting(self) -> None:
"""Announce our presence to the configured devlog channel."""
diff --git a/bot/exts/backend/sync/_cog.py b/bot/exts/backend/sync/_cog.py
index 48d2b6f02..f88dcf538 100644
--- a/bot/exts/backend/sync/_cog.py
+++ b/bot/exts/backend/sync/_cog.py
@@ -9,6 +9,7 @@ from bot import constants
from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.exts.backend.sync import _syncers
+from bot.utils import scheduling
log = logging.getLogger(__name__)
@@ -18,7 +19,7 @@ class Sync(Cog):
def __init__(self, bot: Bot) -> None:
self.bot = bot
- self.bot.loop.create_task(self.sync_guild())
+ scheduling.create_task(self.sync_guild(), event_loop=self.bot.loop)
async def sync_guild(self) -> None:
"""Syncs the roles/users of the guild with the database."""
diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py
index c9f2d2da8..50016df0c 100644
--- a/bot/exts/backend/sync/_syncers.py
+++ b/bot/exts/backend/sync/_syncers.py
@@ -9,6 +9,7 @@ from more_itertools import chunked
import bot
from bot.api import ResponseCodeError
+from bot.utils.members import get_or_fetch_member
log = logging.getLogger(__name__)
@@ -156,7 +157,7 @@ class UserSyncer(Syncer):
if db_user[db_field] != guild_value:
updated_fields[db_field] = guild_value
- if guild_user := guild.get_member(db_user["id"]):
+ if guild_user := await get_or_fetch_member(guild, db_user["id"]):
seen_guild_users.add(guild_user.id)
maybe_update("name", guild_user.name)
diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py
index e099f7dfa..7b0831ab4 100644
--- a/bot/exts/events/code_jams/_cog.py
+++ b/bot/exts/events/code_jams/_cog.py
@@ -11,6 +11,7 @@ from discord.ext import commands
from bot.bot import Bot
from bot.constants import Emojis, Roles
from bot.exts.events.code_jams import _channels
+from bot.utils.members import get_or_fetch_member
from bot.utils.services import send_to_paste_service
log = logging.getLogger(__name__)
@@ -59,7 +60,7 @@ class CodeJams(commands.Cog):
reader = csv.DictReader(csv_file.splitlines())
for row in reader:
- member = ctx.guild.get_member(int(row["Team Member Discord ID"]))
+ member = await get_or_fetch_member(ctx.guild, int(row["Team Member Discord ID"]))
if member is None:
log.trace(f"Got an invalid member ID: {row['Team Member Discord ID']}")
@@ -69,8 +70,8 @@ class CodeJams(commands.Cog):
team_leaders = await ctx.guild.create_role(name="Code Jam Team Leaders", colour=TEAM_LEADERS_COLOUR)
- for team_name, members in teams.items():
- await _channels.create_team_channel(ctx.guild, team_name, members, team_leaders)
+ for team_name, team_members in teams.items():
+ await _channels.create_team_channel(ctx.guild, team_name, team_members, team_leaders)
await _channels.create_team_leader_channel(ctx.guild, team_leaders)
await ctx.send(f"{Emojis.check_mark} Created Code Jam with {len(teams)} teams.")
diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py
index 0eedeb0fb..e708e5149 100644
--- a/bot/exts/filters/antimalware.py
+++ b/bot/exts/filters/antimalware.py
@@ -63,7 +63,7 @@ class AntiMalware(Cog):
return
# Ignore code jam channels
- if hasattr(message.channel, "category") and message.channel.category.name == JAM_CATEGORY_NAME:
+ if getattr(message.channel, "category", None) and message.channel.category.name == JAM_CATEGORY_NAME:
return
# Check if user is staff, if is, return
diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py
index 8c075fa95..8bae159d2 100644
--- a/bot/exts/filters/antispam.py
+++ b/bot/exts/filters/antispam.py
@@ -82,28 +82,34 @@ class DeletionContext:
f"**Rules:** {', '.join(rule for rule in self.rules)}\n"
)
- # For multiple messages or those with excessive newlines, use the logs API
- if len(self.messages) > 1 or 'newlines' in self.rules:
+ messages_as_list = list(self.messages.values())
+ first_message = messages_as_list[0]
+ # For multiple messages and those with attachments or excessive newlines, use the logs API
+ if any((
+ len(messages_as_list) > 1,
+ len(first_message.attachments) > 0,
+ first_message.content.count('\n') > 15
+ )):
url = await modlog.upload_log(self.messages.values(), actor_id, self.attachments)
mod_alert_message += f"A complete log of the offending messages can be found [here]({url})"
else:
mod_alert_message += "Message:\n"
- [message] = self.messages.values()
- content = message.clean_content
+ content = first_message.clean_content
remaining_chars = 4080 - len(mod_alert_message)
if len(content) > remaining_chars:
- content = content[:remaining_chars] + "..."
+ url = await modlog.upload_log([first_message], actor_id, self.attachments)
+ log_site_msg = f"The full message can be found [here]({url})"
+ content = content[:remaining_chars - (3 + len(log_site_msg))] + "..."
- mod_alert_message += f"{content}"
+ mod_alert_message += content
- *_, last_message = self.messages.values()
await modlog.send_log_message(
icon_url=Icons.filtering,
colour=Colour(Colours.soft_red),
title="Spam detected!",
text=mod_alert_message,
- thumbnail=last_message.author.avatar_url_as(static_format="png"),
+ thumbnail=first_message.author.avatar_url_as(static_format="png"),
channel_id=Channels.mod_alerts,
ping_everyone=AntiSpamConfig.ping_everyone
)
@@ -129,7 +135,11 @@ class AntiSpam(Cog):
self.max_interval = max_interval_config['interval']
self.cache = MessageCache(AntiSpamConfig.cache_size, newest_first=True)
- self.bot.loop.create_task(self.alert_on_validation_error(), name="AntiSpam.alert_on_validation_error")
+ scheduling.create_task(
+ self.alert_on_validation_error(),
+ name="AntiSpam.alert_on_validation_error",
+ event_loop=self.bot.loop,
+ )
@property
def mod_log(self) -> ModLog:
@@ -162,7 +172,7 @@ class AntiSpam(Cog):
not message.guild
or message.guild.id != GuildConfig.id
or message.author.bot
- or (hasattr(message.channel, "category") and message.channel.category.name == JAM_CATEGORY_NAME)
+ or (getattr(message.channel, "category", None) and message.channel.category.name == JAM_CATEGORY_NAME)
or (message.channel.id in Filter.channel_whitelist and not DEBUG_MODE)
or (any(role.id in Filter.role_whitelist for role in message.author.roles) and not DEBUG_MODE)
):
@@ -250,7 +260,20 @@ class AntiSpam(Cog):
for message in messages:
channel_messages[message.channel].append(message)
for channel, messages in channel_messages.items():
- await channel.delete_messages(messages)
+ try:
+ await channel.delete_messages(messages)
+ except NotFound:
+ # In the rare case where we found messages matching the
+ # spam filter across multiple channels, it is possible
+ # that a single channel will only contain a single message
+ # to delete. If that should be the case, discord.py will
+ # use the "delete single message" endpoint instead of the
+ # bulk delete endpoint, and the single message deletion
+ # endpoint will complain if you give it that does not exist.
+ # As this means that we have no other message to delete in
+ # this channel (and message deletes work per-channel),
+ # we can just log an exception and carry on with business.
+ log.info(f"Tried to delete message `{messages[0].id}`, but message could not be found.")
# Otherwise, the bulk delete endpoint will throw up.
# Delete the message directly instead.
diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py
index 232c1e48b..a06437f3d 100644
--- a/bot/exts/filters/filter_lists.py
+++ b/bot/exts/filters/filter_lists.py
@@ -9,6 +9,7 @@ from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.converters import ValidDiscordServerInvite, ValidFilterListType
from bot.pagination import LinePaginator
+from bot.utils import scheduling
log = logging.getLogger(__name__)
@@ -27,7 +28,7 @@ class FilterLists(Cog):
def __init__(self, bot: Bot) -> None:
self.bot = bot
- self.bot.loop.create_task(self._amend_docstrings())
+ scheduling.create_task(self._amend_docstrings(), event_loop=self.bot.loop)
async def _amend_docstrings(self) -> None:
"""Add the valid FilterList types to the docstrings, so they'll appear in !help invocations."""
diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py
index 10cc7885d..64f3b82af 100644
--- a/bot/exts/filters/filtering.py
+++ b/bot/exts/filters/filtering.py
@@ -21,9 +21,9 @@ from bot.constants import (
)
from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME
from bot.exts.moderation.modlog import ModLog
+from bot.utils import scheduling
from bot.utils.messages import format_user
from bot.utils.regex import INVITE_RE
-from bot.utils.scheduling import Scheduler
log = logging.getLogger(__name__)
@@ -64,7 +64,7 @@ class Filtering(Cog):
def __init__(self, bot: Bot):
self.bot = bot
- self.scheduler = Scheduler(self.__class__.__name__)
+ self.scheduler = scheduling.Scheduler(self.__class__.__name__)
self.name_lock = asyncio.Lock()
staff_mistake_str = "If you believe this was a mistake, please let staff know!"
@@ -133,7 +133,7 @@ class Filtering(Cog):
},
}
- self.bot.loop.create_task(self.reschedule_offensive_msg_deletion())
+ scheduling.create_task(self.reschedule_offensive_msg_deletion(), event_loop=self.bot.loop)
def cog_unload(self) -> None:
"""Cancel scheduled tasks."""
@@ -478,16 +478,12 @@ class Filtering(Cog):
Second return value is a reason of URL blacklisting (can be None).
"""
text = self.clean_input(text)
- if not URL_RE.search(text):
- return False, None
- text = text.lower()
domain_blacklist = self._get_filterlist_items("domain_name", allowed=False)
-
- for url in domain_blacklist:
- if url.lower() in text:
- return True, self._get_filterlist_value("domain_name", url, allowed=False)["comment"]
-
+ for match in URL_RE.finditer(text):
+ for url in domain_blacklist:
+ if url.lower() in match.group(1).lower():
+ return True, self._get_filterlist_value("domain_name", url, allowed=False)["comment"]
return False, None
@staticmethod
diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py
index 93f1f3c33..6c86ff849 100644
--- a/bot/exts/filters/token_remover.py
+++ b/bot/exts/filters/token_remover.py
@@ -11,6 +11,7 @@ from bot import utils
from bot.bot import Bot
from bot.constants import Channels, Colours, Event, Icons
from bot.exts.moderation.modlog import ModLog
+from bot.utils.members import get_or_fetch_member
from bot.utils.messages import format_user
log = logging.getLogger(__name__)
@@ -99,7 +100,7 @@ class TokenRemover(Cog):
await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention))
log_message = self.format_log_message(msg, found_token)
- userid_message, mention_everyone = self.format_userid_log_message(msg, found_token)
+ userid_message, mention_everyone = await self.format_userid_log_message(msg, found_token)
log.debug(log_message)
# Send pretty mod log embed to mod-alerts
@@ -116,7 +117,7 @@ class TokenRemover(Cog):
self.bot.stats.incr("tokens.removed_tokens")
@classmethod
- def format_userid_log_message(cls, msg: Message, token: Token) -> t.Tuple[str, bool]:
+ async def format_userid_log_message(cls, msg: Message, token: Token) -> t.Tuple[str, bool]:
"""
Format the portion of the log message that includes details about the detected user ID.
@@ -128,7 +129,7 @@ class TokenRemover(Cog):
Returns a tuple of (log_message, mention_everyone)
"""
user_id = cls.extract_user_id(token.user_id)
- user = msg.guild.get_member(user_id)
+ user = await get_or_fetch_member(msg.guild, user_id)
if user:
return KNOWN_USER_LOG_MESSAGE.format(
diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py
index 7f7e4585c..8ced6922c 100644
--- a/bot/exts/fun/duck_pond.py
+++ b/bot/exts/fun/duck_pond.py
@@ -9,6 +9,7 @@ from discord.ext.commands import Cog, Context, command
from bot import constants
from bot.bot import Bot
from bot.converters import MemberOrUser
+from bot.utils import scheduling
from bot.utils.checks import has_any_role
from bot.utils.messages import count_unique_users_reaction, send_attachments
from bot.utils.webhooks import send_webhook
@@ -24,7 +25,7 @@ class DuckPond(Cog):
self.webhook_id = constants.Webhooks.duck_pond
self.webhook = None
self.ducked_messages = []
- self.bot.loop.create_task(self.fetch_webhook())
+ scheduling.create_task(self.fetch_webhook(), event_loop=self.bot.loop)
self.relay_lock = None
async def fetch_webhook(self) -> None:
diff --git a/bot/exts/fun/off_topic_names.py b/bot/exts/fun/off_topic_names.py
index 845b8175c..2f56aa5ba 100644
--- a/bot/exts/fun/off_topic_names.py
+++ b/bot/exts/fun/off_topic_names.py
@@ -11,6 +11,7 @@ from bot.bot import Bot
from bot.constants import Channels, MODERATION_ROLES
from bot.converters import OffTopicName
from bot.pagination import LinePaginator
+from bot.utils import scheduling
CHANNELS = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2)
log = logging.getLogger(__name__)
@@ -50,7 +51,7 @@ class OffTopicNames(Cog):
self.bot = bot
self.updater_task = None
- self.bot.loop.create_task(self.init_offtopic_updater())
+ scheduling.create_task(self.init_offtopic_updater(), event_loop=self.bot.loop)
def cog_unload(self) -> None:
"""Cancel any running updater tasks on cog unload."""
@@ -62,7 +63,7 @@ class OffTopicNames(Cog):
await self.bot.wait_until_guild_available()
if self.updater_task is None:
coro = update_names(self.bot)
- self.updater_task = self.bot.loop.create_task(coro)
+ self.updater_task = scheduling.create_task(coro, event_loop=self.bot.loop)
@group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True)
@has_any_role(*MODERATION_ROLES)
diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py
index 0846b28c8..f1bcea171 100644
--- a/bot/exts/help_channels/_channel.py
+++ b/bot/exts/help_channels/_channel.py
@@ -10,7 +10,7 @@ from arrow import Arrow
import bot
from bot import constants
from bot.exts.help_channels import _caches, _message
-from bot.utils.channel import try_get_channel
+from bot.utils.channel import get_or_fetch_channel
log = logging.getLogger(__name__)
@@ -133,7 +133,7 @@ async def move_to_bottom(channel: discord.TextChannel, category_id: int, **optio
options should be avoided, as it may interfere with the category move we perform.
"""
# Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had.
- category = await try_get_channel(category_id)
+ category = await get_or_fetch_channel(category_id)
payload = [{"id": c.id, "position": c.position} for c in category.channels]
diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py
index cfc9cf477..7c39bc132 100644
--- a/bot/exts/help_channels/_cog.py
+++ b/bot/exts/help_channels/_cog.py
@@ -14,7 +14,7 @@ from bot import constants
from bot.bot import Bot
from bot.constants import Channels, RedirectOutput
from bot.exts.help_channels import _caches, _channel, _message, _name, _stats
-from bot.utils import channel as channel_utils, lock, scheduling
+from bot.utils import channel as channel_utils, lock, members, scheduling
log = logging.getLogger(__name__)
@@ -82,7 +82,7 @@ class HelpChannels(commands.Cog):
# Asyncio stuff
self.queue_tasks: t.List[asyncio.Task] = []
- self.init_task = self.bot.loop.create_task(self.init_cog())
+ self.init_task = scheduling.create_task(self.init_cog(), event_loop=self.bot.loop)
def cog_unload(self) -> None:
"""Cancel the init task and scheduled tasks when the cog unloads."""
@@ -278,13 +278,13 @@ class HelpChannels(commands.Cog):
log.trace("Getting the CategoryChannel objects for the help categories.")
try:
- self.available_category = await channel_utils.try_get_channel(
+ self.available_category = await channel_utils.get_or_fetch_channel(
constants.Categories.help_available
)
- self.in_use_category = await channel_utils.try_get_channel(
+ self.in_use_category = await channel_utils.get_or_fetch_channel(
constants.Categories.help_in_use
)
- self.dormant_category = await channel_utils.try_get_channel(
+ self.dormant_category = await channel_utils.get_or_fetch_channel(
constants.Categories.help_dormant
)
except discord.HTTPException:
@@ -434,7 +434,7 @@ class HelpChannels(commands.Cog):
await _caches.claimants.delete(channel.id)
await _caches.session_participants.delete(channel.id)
- claimant = self.bot.get_guild(constants.Guild.id).get_member(claimant_id)
+ claimant = await members.get_or_fetch_member(self.bot.get_guild(constants.Guild.id), claimant_id)
if claimant is None:
log.info(f"{claimant_id} left the guild during their help session; the cooldown role won't be removed")
else:
@@ -507,7 +507,7 @@ class HelpChannels(commands.Cog):
"""Wait for a dormant channel to become available in the queue and return it."""
log.trace("Waiting for a dormant channel.")
- task = asyncio.create_task(self.channel_queue.get())
+ task = scheduling.create_task(self.channel_queue.get())
self.queue_tasks.append(task)
channel = await task
diff --git a/bot/exts/info/codeblock/_cog.py b/bot/exts/info/codeblock/_cog.py
index 9a0705d2b..f63a459ff 100644
--- a/bot/exts/info/codeblock/_cog.py
+++ b/bot/exts/info/codeblock/_cog.py
@@ -11,7 +11,7 @@ from bot.bot import Bot
from bot.exts.filters.token_remover import TokenRemover
from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE
from bot.exts.info.codeblock._instructions import get_instructions
-from bot.utils import has_lines
+from bot.utils import has_lines, scheduling
from bot.utils.channel import is_help_channel
from bot.utils.messages import wait_for_deletion
@@ -114,7 +114,7 @@ class CodeBlockCog(Cog, name="Code Block"):
bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed)
self.codeblock_message_ids[message.id] = bot_message.id
- self.bot.loop.create_task(wait_for_deletion(bot_message, (message.author.id,)))
+ scheduling.create_task(wait_for_deletion(bot_message, (message.author.id,)), event_loop=self.bot.loop)
# Increase amount of codeblock correction in stats
self.bot.stats.incr("codeblock_corrections")
diff --git a/bot/exts/info/doc/_batch_parser.py b/bot/exts/info/doc/_batch_parser.py
index 369bb462c..51ee29b68 100644
--- a/bot/exts/info/doc/_batch_parser.py
+++ b/bot/exts/info/doc/_batch_parser.py
@@ -24,9 +24,10 @@ class StaleInventoryNotifier:
"""Handle sending notifications about stale inventories through `DocItem`s to dev log."""
def __init__(self):
- self._init_task = bot.instance.loop.create_task(
+ self._init_task = scheduling.create_task(
self._init_channel(),
- name="StaleInventoryNotifier channel init"
+ name="StaleInventoryNotifier channel init",
+ event_loop=bot.instance.loop,
)
self._warned_urls = set()
diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py
index fb9b2584a..e7710db24 100644
--- a/bot/exts/info/doc/_cog.py
+++ b/bot/exts/info/doc/_cog.py
@@ -17,11 +17,12 @@ from bot.bot import Bot
from bot.constants import MODERATION_ROLES, RedirectOutput
from bot.converters import Inventory, PackageName, ValidURL, allowed_strings
from bot.pagination import LinePaginator
+from bot.utils import scheduling
from bot.utils.lock import SharedEvent, lock
from bot.utils.messages import send_denial, wait_for_deletion
from bot.utils.scheduling import Scheduler
from . import NAMESPACE, PRIORITY_PACKAGES, _batch_parser, doc_cache
-from ._inventory_parser import InventoryDict, fetch_inventory
+from ._inventory_parser import InvalidHeaderError, InventoryDict, fetch_inventory
log = logging.getLogger(__name__)
@@ -75,9 +76,10 @@ class DocCog(commands.Cog):
self.refresh_event.set()
self.symbol_get_event = SharedEvent()
- self.init_refresh_task = self.bot.loop.create_task(
+ self.init_refresh_task = scheduling.create_task(
self.init_refresh_inventory(),
- name="Doc inventory init"
+ name="Doc inventory init",
+ event_loop=self.bot.loop,
)
@lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True)
@@ -135,7 +137,12 @@ class DocCog(commands.Cog):
The first attempt is rescheduled to execute in `FETCH_RESCHEDULE_DELAY.first` minutes, the subsequent attempts
in `FETCH_RESCHEDULE_DELAY.repeated` minutes.
"""
- package = await fetch_inventory(inventory_url)
+ try:
+ package = await fetch_inventory(inventory_url)
+ except InvalidHeaderError as e:
+ # Do not reschedule if the header is invalid, as the request went through but the contents are invalid.
+ log.warning(f"Invalid inventory header at {inventory_url}. Reason: {e}")
+ return
if not package:
if api_package_name in self.inventory_scheduler:
@@ -150,6 +157,8 @@ class DocCog(commands.Cog):
self.update_or_reschedule_inventory(api_package_name, base_url, inventory_url),
)
else:
+ if not base_url:
+ base_url = self.base_url_from_inventory_url(inventory_url)
self.update_single(api_package_name, base_url, package)
def ensure_unique_symbol_name(self, package_name: str, group_name: str, symbol_name: str) -> str:
@@ -352,6 +361,11 @@ class DocCog(commands.Cog):
msg = await ctx.send(embed=doc_embed)
await wait_for_deletion(msg, (ctx.author.id,))
+ @staticmethod
+ def base_url_from_inventory_url(inventory_url: str) -> str:
+ """Get a base url from the url to an objects inventory by removing the last path segment."""
+ return inventory_url.removesuffix("/").rsplit("/", maxsplit=1)[0] + "/"
+
@docs_group.command(name="setdoc", aliases=("s",))
@commands.has_any_role(*MODERATION_ROLES)
@lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True)
@@ -359,21 +373,21 @@ class DocCog(commands.Cog):
self,
ctx: commands.Context,
package_name: PackageName,
- base_url: ValidURL,
inventory: Inventory,
+ base_url: ValidURL = "",
) -> None:
"""
Adds a new documentation metadata object to the site's database.
The database will update the object, should an existing item with the specified `package_name` already exist.
+ If the base url is not specified, a default created by removing the last segment of the inventory url is used.
Example:
!docs setdoc \
python \
- https://docs.python.org/3/ \
https://docs.python.org/3/objects.inv
"""
- if not base_url.endswith("/"):
+ if base_url and not base_url.endswith("/"):
raise commands.BadArgument("The base url must end with a slash.")
inventory_url, inventory_dict = inventory
body = {
@@ -388,6 +402,8 @@ class DocCog(commands.Cog):
+ "\n".join(f"{key}: {value}" for key, value in body.items())
)
+ if not base_url:
+ base_url = self.base_url_from_inventory_url(inventory_url)
self.update_single(package_name, base_url, inventory_dict)
await ctx.send(f"Added the package `{package_name}` to the database and updated the inventories.")
@@ -447,4 +463,4 @@ class DocCog(commands.Cog):
"""Clear scheduled inventories, queued symbols and cleanup task on cog unload."""
self.inventory_scheduler.cancel_all()
self.init_refresh_task.cancel()
- asyncio.create_task(self.item_fetcher.clear(), name="DocCog.item_fetcher unload clear")
+ scheduling.create_task(self.item_fetcher.clear(), name="DocCog.item_fetcher unload clear")
diff --git a/bot/exts/info/doc/_inventory_parser.py b/bot/exts/info/doc/_inventory_parser.py
index 80d5841a0..61924d070 100644
--- a/bot/exts/info/doc/_inventory_parser.py
+++ b/bot/exts/info/doc/_inventory_parser.py
@@ -16,6 +16,10 @@ _V2_LINE_RE = re.compile(r'(?x)(.+?)\s+(\S*:\S*)\s+(-?\d+)\s+?(\S*)\s+(.*)')
InventoryDict = DefaultDict[str, List[Tuple[str, str]]]
+class InvalidHeaderError(Exception):
+ """Raised when an inventory file has an invalid header."""
+
+
class ZlibStreamReader:
"""Class used for decoding zlib data of a stream line by line."""
@@ -80,19 +84,25 @@ async def _fetch_inventory(url: str) -> InventoryDict:
stream = response.content
inventory_header = (await stream.readline()).decode().rstrip()
- inventory_version = int(inventory_header[-1:])
- await stream.readline() # skip project name
- await stream.readline() # skip project version
+ try:
+ inventory_version = int(inventory_header[-1:])
+ except ValueError:
+ raise InvalidHeaderError("Unable to convert inventory version header.")
+
+ has_project_header = (await stream.readline()).startswith(b"# Project")
+ has_version_header = (await stream.readline()).startswith(b"# Version")
+ if not (has_project_header and has_version_header):
+ raise InvalidHeaderError("Inventory missing project or version header.")
if inventory_version == 1:
return await _load_v1(stream)
elif inventory_version == 2:
if b"zlib" not in await stream.readline():
- raise ValueError(f"Invalid inventory file at url {url}.")
+ raise InvalidHeaderError("'zlib' not found in header of compressed inventory.")
return await _load_v2(stream)
- raise ValueError(f"Invalid inventory file at url {url}.")
+ raise InvalidHeaderError("Incompatible inventory version.")
async def fetch_inventory(url: str) -> Optional[InventoryDict]:
@@ -115,6 +125,8 @@ async def fetch_inventory(url: str) -> Optional[InventoryDict]:
f"Failed to get inventory from {url}; "
f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
)
+ except InvalidHeaderError:
+ raise
except Exception:
log.exception(
f"An unexpected error has occurred during fetching of {url}; "
diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py
index bcf8c10d2..c60fd2127 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -19,6 +19,7 @@ from bot.errors import NonExistentRoleError
from bot.pagination import LinePaginator
from bot.utils.channel import is_mod_channel, is_staff_channel
from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check
+from bot.utils.members import get_or_fetch_member
from bot.utils.time import TimestampFormats, discord_timestamp, humanize_delta
log = logging.getLogger(__name__)
@@ -46,13 +47,13 @@ class Information(Cog):
@staticmethod
def join_role_stats(role_ids: list[int], guild: Guild, name: Optional[str] = None) -> dict[str, int]:
"""Return a dictionary with the number of `members` of each role given, and the `name` for this joined group."""
- members = 0
+ member_count = 0
for role_id in role_ids:
if (role := guild.get_role(role_id)) is not None:
- members += len(role.members)
+ member_count += len(role.members)
else:
raise NonExistentRoleError(role_id)
- return {name or role.name.title(): members}
+ return {name or role.name.title(): member_count}
@staticmethod
def get_member_counts(guild: Guild) -> dict[str, int]:
@@ -72,7 +73,8 @@ class Information(Cog):
"""Return additional server info only visible in moderation channels."""
talentpool_info = ""
if cog := self.bot.get_cog("Talentpool"):
- talentpool_info = f"Nominated: {len(cog.watched_users)}\n"
+ num_nominated = len(cog.cache) if cog.cache else "-"
+ talentpool_info = f"Nominated: {num_nominated}\n"
bb_info = ""
if cog := self.bot.get_cog("Big Brother"):
@@ -243,7 +245,7 @@ class Information(Cog):
async def create_user_embed(self, ctx: Context, user: MemberOrUser) -> Embed:
"""Creates an embed containing information on the `user`."""
- on_server = bool(ctx.guild.get_member(user.id))
+ on_server = bool(await get_or_fetch_member(ctx.guild, user.id))
created = discord_timestamp(user.created_at, TimestampFormats.RELATIVE)
diff --git a/bot/exts/info/pep.py b/bot/exts/info/pep.py
index b11b34db0..bbd112911 100644
--- a/bot/exts/info/pep.py
+++ b/bot/exts/info/pep.py
@@ -9,6 +9,7 @@ from discord.ext.commands import Cog, Context, command
from bot.bot import Bot
from bot.constants import Keys
+from bot.utils import scheduling
from bot.utils.caching import AsyncCache
log = logging.getLogger(__name__)
@@ -32,7 +33,7 @@ class PythonEnhancementProposals(Cog):
self.peps: Dict[int, str] = {}
# To avoid situations where we don't have last datetime, set this to now.
self.last_refreshed_peps: datetime = datetime.now()
- self.bot.loop.create_task(self.refresh_peps_urls())
+ scheduling.create_task(self.refresh_peps_urls(), event_loop=self.bot.loop)
async def refresh_peps_urls(self) -> None:
"""Refresh PEP URLs listing in every 3 hours."""
diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py
index 63eb4ac17..2a8b64f32 100644
--- a/bot/exts/info/python_news.py
+++ b/bot/exts/info/python_news.py
@@ -11,6 +11,7 @@ from discord.ext.tasks import loop
from bot import constants
from bot.bot import Bot
+from bot.utils import scheduling
from bot.utils.webhooks import send_webhook
PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/"
@@ -22,6 +23,14 @@ THREAD_URL = "https://mail.python.org/archives/list/{list}@python.org/thread/{id
AVATAR_URL = "https://www.python.org/static/opengraph-icon-200x200.png"
+# By first matching everything within a codeblock,
+# when matching markdown it won't be within a codeblock
+MARKDOWN_REGEX = re.compile(
+ r"(?P<codeblock>`.*?`)" # matches everything within a codeblock
+ r"|(?P<markdown>(?<!\\)[_|])", # matches unescaped `_` and `|`
+ re.DOTALL # required to support multi-line codeblocks
+)
+
log = logging.getLogger(__name__)
@@ -33,8 +42,8 @@ class PythonNews(Cog):
self.webhook_names = {}
self.webhook: t.Optional[discord.Webhook] = None
- self.bot.loop.create_task(self.get_webhook_names())
- self.bot.loop.create_task(self.get_webhook_and_channel())
+ scheduling.create_task(self.get_webhook_names(), event_loop=self.bot.loop)
+ scheduling.create_task(self.get_webhook_and_channel(), event_loop=self.bot.loop)
async def start_tasks(self) -> None:
"""Start the tasks for fetching new PEPs and mailing list messages."""
@@ -75,8 +84,11 @@ class PythonNews(Cog):
@staticmethod
def escape_markdown(content: str) -> str:
- """Escape the markdown underlines and spoilers."""
- return re.sub(r"[_|]", lambda match: "\\" + match[0], content)
+ """Escape the markdown underlines and spoilers that aren't in codeblocks."""
+ return MARKDOWN_REGEX.sub(
+ lambda match: match.group("codeblock") or "\\" + match.group("markdown"),
+ content
+ )
async def post_pep_news(self) -> None:
"""Fetch new PEPs and when they don't have announcement in #python-news, create it."""
@@ -108,7 +120,7 @@ class PythonNews(Cog):
# Build an embed and send a webhook
embed = discord.Embed(
- title=new["title"],
+ title=self.escape_markdown(new["title"]),
description=self.escape_markdown(new["summary"]),
timestamp=new_datetime,
url=new["link"],
@@ -128,7 +140,7 @@ class PythonNews(Cog):
self.bot.stats.incr("python_news.posted.pep")
if msg.channel.is_news():
- log.trace("Publishing PEP annnouncement because it was in a news channel")
+ log.trace("Publishing PEP announcement because it was in a news channel")
await msg.publish()
# Apply new sent news to DB to avoid duplicate sending
@@ -178,7 +190,7 @@ class PythonNews(Cog):
# Build an embed and send a message to the webhook
embed = discord.Embed(
- title=thread_information["subject"],
+ title=self.escape_markdown(thread_information["subject"]),
description=content[:1000] + f"... [continue reading]({link})" if len(content) > 1000 else content,
timestamp=new_date,
url=link,
diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py
index 6ac077b93..ac813d6ba 100644
--- a/bot/exts/moderation/defcon.py
+++ b/bot/exts/moderation/defcon.py
@@ -1,4 +1,3 @@
-import asyncio
import logging
import traceback
from collections import namedtuple
@@ -17,6 +16,7 @@ from bot.bot import Bot
from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_ROLES, Roles
from bot.converters import DurationDelta, Expiry
from bot.exts.moderation.modlog import ModLog
+from bot.utils import scheduling
from bot.utils.messages import format_user
from bot.utils.scheduling import Scheduler
from bot.utils.time import (
@@ -69,7 +69,7 @@ class Defcon(Cog):
self.scheduler = Scheduler(self.__class__.__name__)
- self.bot.loop.create_task(self._sync_settings())
+ scheduling.create_task(self._sync_settings(), event_loop=self.bot.loop)
@property
def mod_log(self) -> ModLog:
@@ -205,7 +205,7 @@ class Defcon(Cog):
new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {humanize_delta(self.threshold) if self.threshold else '-'})"
self.mod_log.ignore(Event.guild_channel_update, Channels.defcon)
- asyncio.create_task(self.channel.edit(topic=new_topic))
+ scheduling.create_task(self.channel.edit(topic=new_topic))
@defcon_settings.atomic_transaction
async def _update_threshold(self, author: User, threshold: relativedelta, expiry: Optional[Expiry] = None) -> None:
diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py
index 561e0251e..a3d90e3fe 100644
--- a/bot/exts/moderation/incidents.py
+++ b/bot/exts/moderation/incidents.py
@@ -9,6 +9,7 @@ from discord.ext.commands import Cog
from bot.bot import Bot
from bot.constants import Channels, Colours, Emojis, Guild, Webhooks
+from bot.utils import scheduling
from bot.utils.messages import sub_clyde
log = logging.getLogger(__name__)
@@ -190,7 +191,7 @@ class Incidents(Cog):
self.bot = bot
self.event_lock = asyncio.Lock()
- self.crawl_task = self.bot.loop.create_task(self.crawl_incidents())
+ self.crawl_task = scheduling.create_task(self.crawl_incidents(), event_loop=self.bot.loop)
async def crawl_incidents(self) -> None:
"""
@@ -275,7 +276,7 @@ class Incidents(Cog):
return payload.message_id == incident.id
coroutine = self.bot.wait_for(event="raw_message_delete", check=check, timeout=timeout)
- return self.bot.loop.create_task(coroutine)
+ return scheduling.create_task(coroutine, event_loop=self.bot.loop)
async def process_event(self, reaction: str, incident: discord.Message, member: discord.Member) -> None:
"""
diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py
index 6ba4e74e9..8e844822d 100644
--- a/bot/exts/moderation/infraction/_scheduler.py
+++ b/bot/exts/moderation/infraction/_scheduler.py
@@ -29,7 +29,7 @@ class InfractionScheduler:
self.bot = bot
self.scheduler = scheduling.Scheduler(self.__class__.__name__)
- self.bot.loop.create_task(self.reschedule_infractions(supported_infractions))
+ scheduling.create_task(self.reschedule_infractions(supported_infractions), event_loop=self.bot.loop)
def cog_unload(self) -> None:
"""Cancel scheduled tasks."""
diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py
index fd47ff1c7..a7f7dcb7f 100644
--- a/bot/exts/moderation/infraction/infractions.py
+++ b/bot/exts/moderation/infraction/infractions.py
@@ -14,6 +14,7 @@ from bot.converters import Duration, Expiry, MemberOrUser, UnambiguousMemberOrUs
from bot.decorators import respect_role_hierarchy
from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction._scheduler import InfractionScheduler
+from bot.utils.members import get_or_fetch_member
from bot.utils.messages import format_user
log = logging.getLogger(__name__)
@@ -430,7 +431,7 @@ class Infractions(InfractionScheduler, commands.Cog):
notify: bool = True
) -> t.Dict[str, str]:
"""Remove a user's muted role, optionally DM them a notification, and return a log dict."""
- user = guild.get_member(user_id)
+ user = await get_or_fetch_member(guild, user_id)
log_text = {}
if user:
@@ -478,7 +479,7 @@ class Infractions(InfractionScheduler, commands.Cog):
notify: bool = True
) -> t.Dict[str, str]:
"""Optionally DM the user a pardon notification and return a log dict."""
- user = guild.get_member(user_id)
+ user = await get_or_fetch_member(guild, user_id)
log_text = {}
if user:
diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py
index 223a124d8..0cb2a8b60 100644
--- a/bot/exts/moderation/infraction/management.py
+++ b/bot/exts/moderation/infraction/management.py
@@ -11,14 +11,15 @@ from discord.ext.commands import Context
from discord.utils import escape_markdown
from bot import constants
-from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, UnambiguousUser, allowed_strings
+from bot.errors import InvalidInfraction
from bot.exts.moderation.infraction.infractions import Infractions
from bot.exts.moderation.modlog import ModLog
from bot.pagination import LinePaginator
from bot.utils import messages, time
from bot.utils.channel import is_mod_channel
+from bot.utils.members import get_or_fetch_member
from bot.utils.time import humanize_delta, until_expiration
log = logging.getLogger(__name__)
@@ -45,25 +46,22 @@ class ModManagement(commands.Cog):
# region: Edit infraction commands
@commands.group(name='infraction', aliases=('infr', 'infractions', 'inf', 'i'), invoke_without_command=True)
- async def infraction_group(self, ctx: Context, infr_id: int = None) -> None:
- """Infraction manipulation commands. If `infr_id` is passed then this command fetches that infraction."""
- if infr_id is None:
+ async def infraction_group(self, ctx: Context, infraction: Infraction = None) -> None:
+ """
+ Infraction manipulation commands.
+
+ If `infraction` is passed then this command fetches that infraction. The `Infraction` converter
+ supports 'l', 'last' and 'recent' to get the most recent infraction made by `ctx.author`.
+ """
+ if infraction is None:
await ctx.send_help(ctx.command)
return
- try:
- infraction_list = [await self.bot.api_client.get(f"bot/infractions/{infr_id}/expanded")]
- except ResponseCodeError as e:
- if e.status == 404:
- await ctx.send(f":x: No infraction with ID `{infr_id}` could be found.")
- return
- raise e
-
embed = discord.Embed(
- title=f"Infraction #{infr_id}",
+ title=f"Infraction #{infraction['id']}",
colour=discord.Colour.orange()
)
- await self.send_infraction_list(ctx, embed, infraction_list)
+ await self.send_infraction_list(ctx, embed, [infraction])
@infraction_group.command(name="append", aliases=("amend", "add", "a"))
async def infraction_append(
@@ -193,7 +191,7 @@ class ModManagement(commands.Cog):
# Get information about the infraction's user
user_id = new_infraction['user']
- user = ctx.guild.get_member(user_id)
+ user = await get_or_fetch_member(ctx.guild, user_id)
if user:
user_text = messages.format_user(user)
@@ -348,13 +346,20 @@ class ModManagement(commands.Cog):
return all(checks)
# This cannot be static (must have a __func__ attribute).
- async def cog_command_error(self, ctx: Context, error: Exception) -> None:
- """Send a notification to the invoking context on a Union failure."""
+ async def cog_command_error(self, ctx: Context, error: commands.CommandError) -> None:
+ """Handles errors for commands within this cog."""
if isinstance(error, commands.BadUnionArgument):
if discord.User in error.converters:
await ctx.send(str(error.errors[0]))
error.handled = True
+ elif isinstance(error, InvalidInfraction):
+ if error.infraction_arg.isdigit():
+ await ctx.send(f":x: Could not find an infraction with id `{error.infraction_arg}`.")
+ else:
+ await ctx.send(f":x: `{error.infraction_arg}` is not a valid integer infraction id.")
+ error.handled = True
+
def setup(bot: Bot) -> None:
"""Load the ModManagement cog."""
diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py
index 986decdd6..17cde68f6 100644
--- a/bot/exts/moderation/infraction/superstarify.py
+++ b/bot/exts/moderation/infraction/superstarify.py
@@ -14,6 +14,7 @@ from bot.bot import Bot
from bot.converters import Duration, Expiry
from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction._scheduler import InfractionScheduler
+from bot.utils.members import get_or_fetch_member
from bot.utils.messages import format_user
from bot.utils.time import format_infraction
@@ -202,7 +203,7 @@ class Superstarify(InfractionScheduler, Cog):
return
guild = self.bot.get_guild(constants.Guild.id)
- user = guild.get_member(infraction["user"])
+ user = await get_or_fetch_member(guild, infraction["user"])
# Don't bother sending a notification if the user left the guild.
if not user:
diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py
index 9eeeec074..6eadd4bad 100644
--- a/bot/exts/moderation/metabase.py
+++ b/bot/exts/moderation/metabase.py
@@ -14,7 +14,7 @@ from discord.ext.commands import Cog, Context, group, has_any_role
from bot.bot import Bot
from bot.constants import Metabase as MetabaseConfig, Roles
from bot.converters import allowed_strings
-from bot.utils import send_to_paste_service
+from bot.utils import scheduling, send_to_paste_service
from bot.utils.channel import is_mod_channel
from bot.utils.scheduling import Scheduler
@@ -40,7 +40,7 @@ class Metabase(Cog):
self.exports: Dict[int, List[Dict]] = {} # Saves the output of each question, so internal eval can access it
- self.init_task = self.bot.loop.create_task(self.init_cog())
+ self.init_task = scheduling.create_task(self.init_cog(), event_loop=self.bot.loop)
async def cog_command_error(self, ctx: Context, error: Exception) -> None:
"""Handle ClientResponseError errors locally to invalidate token if needed."""
diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py
index 80c9f0c38..d775cdedf 100644
--- a/bot/exts/moderation/modpings.py
+++ b/bot/exts/moderation/modpings.py
@@ -9,6 +9,7 @@ from discord.ext.commands import Cog, Context, group, has_any_role
from bot.bot import Bot
from bot.constants import Colours, Emojis, Guild, Icons, MODERATION_ROLES, Roles
from bot.converters import Expiry
+from bot.utils import scheduling
from bot.utils.scheduling import Scheduler
log = logging.getLogger(__name__)
@@ -29,7 +30,11 @@ class ModPings(Cog):
self.guild = None
self.moderators_role = None
- self.reschedule_task = self.bot.loop.create_task(self.reschedule_roles(), name="mod-pings-reschedule")
+ self.reschedule_task = scheduling.create_task(
+ self.reschedule_roles(),
+ name="mod-pings-reschedule",
+ event_loop=self.bot.loop,
+ )
async def reschedule_roles(self) -> None:
"""Reschedule moderators role re-apply times."""
diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py
index 95e2792c3..2ee6496df 100644
--- a/bot/exts/moderation/silence.py
+++ b/bot/exts/moderation/silence.py
@@ -13,6 +13,7 @@ from discord.ext.commands import Context
from bot import constants
from bot.bot import Bot
from bot.converters import HushDurationConverter
+from bot.utils import scheduling
from bot.utils.lock import LockedResourceError, lock, lock_arg
from bot.utils.scheduling import Scheduler
@@ -104,7 +105,7 @@ class Silence(commands.Cog):
self.bot = bot
self.scheduler = Scheduler(self.__class__.__name__)
- self._init_task = self.bot.loop.create_task(self._async_init())
+ self._init_task = scheduling.create_task(self._async_init(), event_loop=self.bot.loop)
async def _async_init(self) -> None:
"""Set instance attributes once the guild is available and reschedule unsilences."""
diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py
index 01d2614b0..a179a9acc 100644
--- a/bot/exts/moderation/stream.py
+++ b/bot/exts/moderation/stream.py
@@ -15,7 +15,8 @@ from bot.constants import (
)
from bot.converters import Expiry
from bot.pagination import LinePaginator
-from bot.utils.scheduling import Scheduler
+from bot.utils import scheduling
+from bot.utils.members import get_or_fetch_member
from bot.utils.time import discord_timestamp, format_infraction_with_duration
log = logging.getLogger(__name__)
@@ -30,8 +31,8 @@ class Stream(commands.Cog):
def __init__(self, bot: Bot):
self.bot = bot
- self.scheduler = Scheduler(self.__class__.__name__)
- self.reload_task = self.bot.loop.create_task(self._reload_tasks_from_redis())
+ self.scheduler = scheduling.Scheduler(self.__class__.__name__)
+ self.reload_task = scheduling.create_task(self._reload_tasks_from_redis(), event_loop=self.bot.loop)
def cog_unload(self) -> None:
"""Cancel all scheduled tasks."""
@@ -47,23 +48,17 @@ class Stream(commands.Cog):
"""Reload outstanding tasks from redis on startup, delete the task if the member has since left the server."""
await self.bot.wait_until_guild_available()
items = await self.task_cache.items()
+ guild = self.bot.get_guild(Guild.id)
for key, value in items:
- member = self.bot.get_guild(Guild.id).get_member(key)
+ member = await get_or_fetch_member(guild, key)
if not member:
- # Member isn't found in the cache
- try:
- member = await self.bot.get_guild(Guild.id).fetch_member(key)
- except discord.errors.NotFound:
- log.debug(
- f"Member {key} left the guild before we could schedule "
- "the revoking of their streaming permissions."
- )
- await self.task_cache.delete(key)
- continue
- except discord.HTTPException:
- log.exception(f"Exception while trying to retrieve member {key} from Discord.")
- continue
+ log.debug(
+ "User with ID %d left the guild before their streaming permissions could be revoked.",
+ key
+ )
+ await self.task_cache.delete(key)
+ continue
revoke_time = Arrow.utcfromtimestamp(value)
log.debug(f"Scheduling {member} ({member.id}) to have streaming permission revoked at {revoke_time}")
diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py
index 146426569..3fafd097b 100644
--- a/bot/exts/moderation/watchchannels/_watchchannel.py
+++ b/bot/exts/moderation/watchchannels/_watchchannel.py
@@ -18,7 +18,8 @@ from bot.exts.filters.token_remover import TokenRemover
from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE
from bot.exts.moderation.modlog import ModLog
from bot.pagination import LinePaginator
-from bot.utils import CogABCMeta, messages
+from bot.utils import CogABCMeta, messages, scheduling
+from bot.utils.members import get_or_fetch_member
from bot.utils.time import get_time_delta
log = logging.getLogger(__name__)
@@ -69,7 +70,7 @@ class WatchChannel(metaclass=CogABCMeta):
self.message_history = MessageHistory()
self.disable_header = disable_header
- self._start = self.bot.loop.create_task(self.start_watchchannel())
+ self._start = scheduling.create_task(self.start_watchchannel(), event_loop=self.bot.loop)
@property
def modlog(self) -> ModLog:
@@ -169,7 +170,7 @@ class WatchChannel(metaclass=CogABCMeta):
"""Queues up messages sent by watched users."""
if msg.author.id in self.watched_users:
if not self.consuming_messages:
- self._consume_task = self.bot.loop.create_task(self.consume_messages())
+ self._consume_task = scheduling.create_task(self.consume_messages(), event_loop=self.bot.loop)
self.log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)")
self.message_queue[msg.author.id][msg.channel.id].append(msg)
@@ -199,7 +200,10 @@ class WatchChannel(metaclass=CogABCMeta):
if self.message_queue:
self.log.trace("Channel queue not empty: Continuing consuming queues")
- self._consume_task = self.bot.loop.create_task(self.consume_messages(delay_consumption=False))
+ self._consume_task = scheduling.create_task(
+ self.consume_messages(delay_consumption=False),
+ event_loop=self.bot.loop,
+ )
else:
self.log.trace("Done consuming messages.")
@@ -278,7 +282,7 @@ class WatchChannel(metaclass=CogABCMeta):
user_id = msg.author.id
guild = self.bot.get_guild(GuildConfig.id)
- actor = guild.get_member(self.watched_users[user_id]['actor'])
+ actor = await get_or_fetch_member(guild, self.watched_users[user_id]['actor'])
actor = actor.display_name if actor else self.watched_users[user_id]['actor']
inserted_at = self.watched_users[user_id]['inserted_at']
@@ -352,7 +356,7 @@ class WatchChannel(metaclass=CogABCMeta):
list_data["info"] = {}
for user_id, user_data in watched_iter:
- member = ctx.guild.get_member(user_id)
+ member = await get_or_fetch_member(ctx.guild, user_id)
line = f"โ€ข `{user_id}`"
if member:
line += f" ({member.name}#{member.discriminator})"
diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py
index c297f70c2..f9c836bbd 100644
--- a/bot/exts/recruitment/talentpool/_cog.py
+++ b/bot/exts/recruitment/talentpool/_cog.py
@@ -1,22 +1,23 @@
import logging
import textwrap
-from collections import ChainMap
+from collections import ChainMap, defaultdict
from io import StringIO
-from typing import Union
+from typing import Optional, Union
import discord
from async_rediscache import RedisCache
-from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent
-from discord.ext.commands import Cog, Context, group, has_any_role
+from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent, User
+from discord.ext.commands import BadArgument, Cog, Context, group, has_any_role
from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES, Webhooks
-from bot.converters import MemberOrUser
-from bot.exts.moderation.watchchannels._watchchannel import WatchChannel
+from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES
+from bot.converters import MemberOrUser, UnambiguousMemberOrUser
from bot.exts.recruitment.talentpool._review import Reviewer
from bot.pagination import LinePaginator
-from bot.utils import time
+from bot.utils import scheduling, time
+from bot.utils.members import get_or_fetch_member
+from bot.utils.time import get_time_delta
AUTOREVIEW_ENABLED_KEY = "autoreview_enabled"
REASON_MAX_CHARS = 1000
@@ -24,40 +25,58 @@ REASON_MAX_CHARS = 1000
log = logging.getLogger(__name__)
-class TalentPool(WatchChannel, Cog, name="Talentpool"):
- """Relays messages of helper candidates to a watch channel to observe them."""
+class TalentPool(Cog, name="Talentpool"):
+ """Used to nominate potential helper candidates."""
# RedisCache[str, bool]
# Can contain a single key, "autoreview_enabled", with the value a bool indicating if autoreview is enabled.
talentpool_settings = RedisCache()
def __init__(self, bot: Bot) -> None:
- super().__init__(
- bot,
- destination=Channels.talent_pool,
- webhook_id=Webhooks.talent_pool,
- api_endpoint='bot/nominations',
- api_default_params={'active': 'true', 'ordering': '-inserted_at'},
- logger=log,
- disable_header=True,
- )
-
+ self.bot = bot
self.reviewer = Reviewer(self.__class__.__name__, bot, self)
- self.bot.loop.create_task(self.schedule_autoreviews())
+ self.cache: Optional[defaultdict[dict]] = None
+ self.api_default_params = {'active': 'true', 'ordering': '-inserted_at'}
+
+ self.initial_refresh_task = scheduling.create_task(self.refresh_cache(), event_loop=self.bot.loop)
+ scheduling.create_task(self.schedule_autoreviews(), event_loop=self.bot.loop)
async def schedule_autoreviews(self) -> None:
"""Reschedule reviews for active nominations if autoreview is enabled."""
if await self.autoreview_enabled():
+ # Wait for a populated cache first
+ await self.initial_refresh_task
await self.reviewer.reschedule_reviews()
else:
- self.log.trace("Not scheduling reviews as autoreview is disabled.")
+ log.trace("Not scheduling reviews as autoreview is disabled.")
async def autoreview_enabled(self) -> bool:
"""Return whether automatic posting of nomination reviews is enabled."""
return await self.talentpool_settings.get(AUTOREVIEW_ENABLED_KEY, True)
+ async def refresh_cache(self) -> bool:
+ """Updates TalentPool users cache."""
+ # Wait until logged in to ensure bot api client exists
+ await self.bot.wait_until_guild_available()
+ try:
+ data = await self.bot.api_client.get(
+ 'bot/nominations',
+ params=self.api_default_params
+ )
+ except ResponseCodeError as err:
+ log.exception("Failed to fetch the currently nominated users from the API", exc_info=err)
+ return False
+
+ self.cache = defaultdict(dict)
+
+ for entry in data:
+ user_id = entry.pop('user')
+ self.cache[user_id] = entry
+
+ return True
+
@group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True)
- @has_any_role(*MODERATION_ROLES)
+ @has_any_role(*STAFF_ROLES)
async def nomination_group(self, ctx: Context) -> None:
"""Highlights the activity of helper nominees by relaying their messages to the talent pool channel."""
await ctx.send_help(ctx.command)
@@ -106,25 +125,29 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
else:
await ctx.send("Autoreview is currently disabled")
- @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",))
+ @nomination_group.command(
+ name="nominees",
+ aliases=("nominated", "all", "list", "watched"),
+ root_aliases=("nominees",)
+ )
@has_any_role(*MODERATION_ROLES)
- async def watched_command(
+ async def list_command(
self,
ctx: Context,
oldest_first: bool = False,
update_cache: bool = True
) -> None:
"""
- Shows the users that are currently being monitored in the talent pool.
+ Shows the users that are currently in the talent pool.
The optional kwarg `oldest_first` can be used to order the list by oldest nomination.
The optional kwarg `update_cache` can be used to update the user
cache using the API before listing the users.
"""
- await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache)
+ await self.list_nominated_users(ctx, oldest_first=oldest_first, update_cache=update_cache)
- async def list_watched_users(
+ async def list_nominated_users(
self,
ctx: Context,
oldest_first: bool = False,
@@ -141,16 +164,27 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
The optional kwarg `update_cache` specifies whether the cache should
be refreshed by polling the API.
"""
- # TODO Once the watch channel is removed, this can be done in a smarter way, without splitting and overriding
- # the list_watched_users function.
- watched_data = await self.prepare_watched_users_data(ctx, oldest_first, update_cache)
+ successful_update = False
+ if update_cache:
+ if not (successful_update := await self.refresh_cache()):
+ await ctx.send(":warning: Unable to update cache. Data may be inaccurate.")
- if update_cache and not watched_data["updated"]:
- await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache")
+ nominations = self.cache.items()
+ if oldest_first:
+ nominations = reversed(nominations)
lines = []
- for user_id, line in watched_data["info"].items():
- if self.watched_users[user_id]['reviewed']:
+
+ for user_id, user_data in nominations:
+ member = await get_or_fetch_member(ctx.guild, user_id)
+ line = f"โ€ข `{user_id}`"
+ if member:
+ line += f" ({member.name}#{member.discriminator})"
+ inserted_at = user_data['inserted_at']
+ line += f", added {get_time_delta(inserted_at)}"
+ if not member: # Cross off users who left the server.
+ line = f"~~{line}~~"
+ if user_data['reviewed']:
line += " *(reviewed)*"
elif user_id in self.reviewer:
line += " *(scheduled)*"
@@ -160,7 +194,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
lines = ("There's nothing here yet.",)
embed = Embed(
- title=watched_data["title"],
+ title=f"Talent Pool active nominations ({'updated' if update_cache and successful_update else 'cached'})",
color=Color.blue()
)
await LinePaginator.paginate(lines, ctx, embed, empty=False)
@@ -169,26 +203,30 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
@has_any_role(*MODERATION_ROLES)
async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None:
"""
- Shows talent pool monitored users ordered by oldest nomination.
+ Shows talent pool users ordered by oldest nomination.
The optional kwarg `update_cache` can be used to update the user
cache using the API before listing the users.
"""
- await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache)
+ await ctx.invoke(self.list_command, oldest_first=True, update_cache=update_cache)
- @nomination_group.command(name='forcewatch', aliases=('fw', 'forceadd', 'fa'), root_aliases=("forcenominate",))
+ @nomination_group.command(
+ name="forcenominate",
+ aliases=("fw", "forceadd", "fa", "fn", "forcewatch"),
+ root_aliases=("forcenominate",)
+ )
@has_any_role(*MODERATION_ROLES)
- async def force_watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None:
+ async def force_nominate_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None:
"""
Adds the given `user` to the talent pool, from any channel.
A `reason` for adding the user to the talent pool is optional.
"""
- await self._watch_user(ctx, user, reason)
+ await self._nominate_user(ctx, user, reason)
- @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",))
+ @nomination_group.command(name='nominate', aliases=("w", "add", "a", "watch"), root_aliases=("nominate",))
@has_any_role(*STAFF_ROLES)
- async def watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None:
+ async def nominate_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None:
"""
Adds the given `user` to the talent pool.
@@ -199,26 +237,26 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
if any(role.id in MODERATION_ROLES for role in ctx.author.roles):
await ctx.send(
f":x: Nominations should be run in the <#{Channels.nominations}> channel. "
- "Use `!tp forcewatch` to override this check."
+ "Use `!tp forcenominate` to override this check."
)
else:
await ctx.send(f":x: Nominations must be run in the <#{Channels.nominations}> channel")
return
- await self._watch_user(ctx, user, reason)
+ await self._nominate_user(ctx, user, reason)
- async def _watch_user(self, ctx: Context, user: MemberOrUser, reason: str) -> None:
+ async def _nominate_user(self, ctx: Context, user: MemberOrUser, reason: str) -> None:
"""Adds the given user to the talent pool."""
if user.bot:
- await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.")
+ await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. Only humans can be nominated.")
return
if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles):
await ctx.send(":x: Nominating staff members, eh? Here's a cookie :cookie:")
return
- if not await self.fetch_user_cache():
- await ctx.send(f":x: Failed to update the user cache; can't add {user}")
+ if not await self.refresh_cache():
+ await ctx.send(f":x: Failed to update the cache; can't add {user}")
return
if len(reason) > REASON_MAX_CHARS:
@@ -227,7 +265,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
# Manual request with `raise_for_status` as False because we want the actual response
session = self.bot.api_client.session
- url = self.bot.api_client._url_for(self.api_endpoint)
+ url = self.bot.api_client._url_for('bot/nominations')
kwargs = {
'json': {
'actor': ctx.author.id,
@@ -249,23 +287,12 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
else:
resp.raise_for_status()
- self.watched_users[user.id] = response_data
+ self.cache[user.id] = response_data
if await self.autoreview_enabled() and user.id not in self.reviewer:
self.reviewer.schedule_review(user.id)
- history = await self.bot.api_client.get(
- self.api_endpoint,
- params={
- "user__id": str(user.id),
- "active": "false",
- "ordering": "-inserted_at"
- }
- )
-
msg = f"โœ… The nomination for {user.mention} has been added to the talent pool"
- if history:
- msg += f"\n\n({len(history)} previous nominations in total)"
await ctx.send(msg)
@@ -274,7 +301,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
async def history_command(self, ctx: Context, user: MemberOrUser) -> None:
"""Shows the specified user's nomination history."""
result = await self.bot.api_client.get(
- self.api_endpoint,
+ 'bot/nominations',
params={
'user__id': str(user.id),
'ordering': "-active,-inserted_at"
@@ -288,7 +315,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
title=f"Nominations for {user.display_name} `({user.id})`",
color=Color.blue()
)
- lines = [self._nomination_to_string(nomination) for nomination in result]
+ lines = [await self._nomination_to_string(nomination) for nomination in result]
await LinePaginator.paginate(
lines,
ctx=ctx,
@@ -298,42 +325,99 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
max_size=1000
)
- @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",))
+ @nomination_group.command(name="end", aliases=("unwatch", "unnominate"), root_aliases=("unnominate",))
@has_any_role(*MODERATION_ROLES)
- async def unwatch_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None:
+ async def end_nomination_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None:
"""
Ends the active nomination of the specified user with the given reason.
Providing a `reason` is required.
"""
if len(reason) > REASON_MAX_CHARS:
- await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.")
+ await ctx.send(f":x: Maximum allowed characters for the end reason is {REASON_MAX_CHARS}.")
return
- if await self.unwatch(user.id, reason):
- await ctx.send(f":white_check_mark: Messages sent by {user.mention} will no longer be relayed")
+ if await self.end_nomination(user.id, reason):
+ await ctx.send(f":white_check_mark: Successfully un-nominated {user}")
else:
await ctx.send(":x: The specified user does not have an active nomination")
@nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True)
- @has_any_role(*MODERATION_ROLES)
+ @has_any_role(*STAFF_ROLES)
async def nomination_edit_group(self, ctx: Context) -> None:
"""Commands to edit nominations."""
await ctx.send_help(ctx.command)
@nomination_edit_group.command(name='reason')
- @has_any_role(*MODERATION_ROLES)
- async def edit_reason_command(self, ctx: Context, nomination_id: int, actor: MemberOrUser, *, reason: str) -> None:
- """Edits the reason of a specific nominator in a specific active nomination."""
+ @has_any_role(*STAFF_ROLES)
+ async def edit_reason_command(
+ self,
+ ctx: Context,
+ nominee_or_nomination_id: Union[UnambiguousMemberOrUser, int],
+ nominator: Optional[UnambiguousMemberOrUser] = None,
+ *,
+ reason: str
+ ) -> None:
+ """
+ Edit the nomination reason of a specific nominator for a given nomination.
+
+ If nominee_or_nomination_id resolves to a member or user, edit the currently active nomination for that person.
+ Otherwise, if it's an int, look up that nomination ID to edit.
+
+ If no nominator is specified, assume the invoker is editing their own nomination reason.
+ Otherwise, edit the reason from that specific nominator.
+
+ Raise a permission error if a non-mod staff member invokes this command on a
+ specific nomination ID, or with an nominator other than themselves.
+ """
+ # If not specified, assume the invoker is editing their own nomination reason.
+ nominator = nominator or ctx.author
+
+ if not any(role.id in MODERATION_ROLES for role in ctx.author.roles):
+ if ctx.channel.id != Channels.nominations:
+ await ctx.send(f":x: Nomination edits must be run in the <#{Channels.nominations}> channel")
+ return
+
+ if nominator != ctx.author or isinstance(nominee_or_nomination_id, int):
+ # Invoker has specified another nominator, or a specific nomination id
+ raise BadArgument(
+ "Only moderators can edit specific nomination IDs, "
+ "or the reason of a nominator other than themselves."
+ )
+
+ await self._edit_nomination_reason(
+ ctx,
+ target=nominee_or_nomination_id,
+ actor=nominator,
+ reason=reason
+ )
+
+ async def _edit_nomination_reason(
+ self,
+ ctx: Context,
+ *,
+ target: Union[int, Member, User],
+ actor: MemberOrUser,
+ reason: str,
+ ) -> None:
+ """Edit a nomination reason in the database after validating the input."""
if len(reason) > REASON_MAX_CHARS:
- await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.")
+ await ctx.send(f":x: Maximum allowed characters for the reason is {REASON_MAX_CHARS}.")
return
+ if isinstance(target, int):
+ nomination_id = target
+ else:
+ if nomination := self.cache.get(target.id):
+ nomination_id = nomination["id"]
+ else:
+ await ctx.send("No active nomination found for that member.")
+ return
try:
- nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}")
+ nomination = await self.bot.api_client.get(f"bot/nominations/{nomination_id}")
except ResponseCodeError as e:
if e.response.status == 404:
- self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}")
+ log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}")
await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`")
return
else:
@@ -347,13 +431,13 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
await ctx.send(f":x: {actor.mention} doesn't have an entry in this nomination.")
return
- self.log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}")
+ log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}")
await self.bot.api_client.patch(
- f"{self.api_endpoint}/{nomination_id}",
+ f"bot/nominations/{nomination_id}",
json={"actor": actor.id, "reason": reason}
)
- await self.fetch_user_cache() # Update cache
+ await self.refresh_cache() # Update cache
await ctx.send(":white_check_mark: Successfully updated nomination reason.")
@nomination_edit_group.command(name='end_reason')
@@ -365,10 +449,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
return
try:
- nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}")
+ nomination = await self.bot.api_client.get(f"bot/nominations/{nomination_id}")
except ResponseCodeError as e:
if e.response.status == 404:
- self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}")
+ log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}")
await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`")
return
else:
@@ -378,13 +462,13 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
await ctx.send(":x: Can't edit the end reason of an active nomination.")
return
- self.log.trace(f"Changing end reason for nomination with id {nomination_id} to {repr(reason)}")
+ log.trace(f"Changing end reason for nomination with id {nomination_id} to {repr(reason)}")
await self.bot.api_client.patch(
- f"{self.api_endpoint}/{nomination_id}",
+ f"bot/nominations/{nomination_id}",
json={"end_reason": reason}
)
- await self.fetch_user_cache() # Update cache.
+ await self.refresh_cache() # Update cache.
await ctx.send(":white_check_mark: Updated the end reason of the nomination!")
@nomination_group.command(aliases=('mr',))
@@ -419,7 +503,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
@Cog.listener()
async def on_member_ban(self, guild: Guild, user: Union[MemberOrUser]) -> None:
"""Remove `user` from the talent pool after they are banned."""
- await self.unwatch(user.id, "User was banned.")
+ await self.end_nomination(user.id, "User was banned.")
@Cog.listener()
async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None:
@@ -441,10 +525,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
log.info(f"Archiving nomination {message.id}")
await self.reviewer.archive_vote(message, emoji == Emojis.incident_actioned)
- async def unwatch(self, user_id: int, reason: str) -> bool:
+ async def end_nomination(self, user_id: int, reason: str) -> bool:
"""End the active nomination of a user with the given reason and return True on success."""
active_nomination = await self.bot.api_client.get(
- self.api_endpoint,
+ 'bot/nominations',
params=ChainMap(
{"user__id": str(user_id)},
self.api_default_params,
@@ -459,23 +543,23 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
nomination = active_nomination[0]
await self.bot.api_client.patch(
- f"{self.api_endpoint}/{nomination['id']}",
+ f"bot/nominations/{nomination['id']}",
json={'end_reason': reason, 'active': False}
)
- self._remove_user(user_id)
+ self.cache.pop(user_id)
if await self.autoreview_enabled():
self.reviewer.cancel(user_id)
return True
- def _nomination_to_string(self, nomination_object: dict) -> str:
+ async def _nomination_to_string(self, nomination_object: dict) -> str:
"""Creates a string representation of a nomination."""
guild = self.bot.get_guild(Guild.id)
entries = []
for site_entry in nomination_object["entries"]:
actor_id = site_entry["actor"]
- actor = guild.get_member(actor_id)
+ actor = await get_or_fetch_member(guild, actor_id)
reason = site_entry["reason"] or "*None*"
created = time.format_infraction(site_entry["inserted_at"])
@@ -512,7 +596,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
{entries_string}
End date: {end_date}
- Unwatch reason: {nomination_object["end_reason"]}
+ Unnomination reason: {nomination_object["end_reason"]}
===============
"""
)
diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py
index 4d496a1f7..14a8dd4c0 100644
--- a/bot/exts/recruitment/talentpool/_review.py
+++ b/bot/exts/recruitment/talentpool/_review.py
@@ -16,6 +16,7 @@ from discord.ext.commands import Context
from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import Channels, Colours, Emojis, Guild
+from bot.utils.members import get_or_fetch_member
from bot.utils.messages import count_unique_users_reaction, pin_no_system_message
from bot.utils.scheduling import Scheduler
from bot.utils.time import get_time_delta, time_since
@@ -57,10 +58,8 @@ class Reviewer:
"""Reschedule all active nominations to be reviewed at the appropriate time."""
log.trace("Rescheduling reviews")
await self.bot.wait_until_guild_available()
- # TODO Once the watch channel is removed, this can be done in a smarter way, e.g create a sync function.
- await self._pool.fetch_user_cache()
- for user_id, user_data in self._pool.watched_users.items():
+ for user_id, user_data in self._pool.cache.items():
if not user_data["reviewed"]:
self.schedule_review(user_id)
@@ -68,7 +67,7 @@ class Reviewer:
"""Schedules a single user for review."""
log.trace(f"Scheduling review of user with ID {user_id}")
- user_data = self._pool.watched_users.get(user_id)
+ user_data = self._pool.cache.get(user_id)
inserted_at = isoparse(user_data['inserted_at']).replace(tzinfo=None)
review_at = inserted_at + timedelta(days=MAX_DAYS_IN_POOL)
@@ -96,24 +95,24 @@ class Reviewer:
await last_message.add_reaction(reaction)
if update_database:
- nomination = self._pool.watched_users.get(user_id)
- await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True})
+ nomination = self._pool.cache.get(user_id)
+ await self.bot.api_client.patch(f"bot/nominations/{nomination['id']}", json={"reviewed": True})
async def make_review(self, user_id: int) -> typing.Tuple[str, Optional[Emoji]]:
"""Format a generic review of a user and return it with the reviewed emoji."""
log.trace(f"Formatting the review of {user_id}")
- # Since `watched_users` is a defaultdict, we should take care
+ # Since `cache` is a defaultdict, we should take care
# not to accidentally insert the IDs of users that have no
- # active nominated by using the `watched_users.get(user_id)`
- # instead of `watched_users[user_id]`.
- nomination = self._pool.watched_users.get(user_id)
+ # active nominated by using the `cache.get(user_id)`
+ # instead of `cache[user_id]`.
+ nomination = self._pool.cache.get(user_id)
if not nomination:
log.trace(f"There doesn't appear to be an active nomination for {user_id}")
return "", None
guild = self.bot.get_guild(Guild.id)
- member = guild.get_member(user_id)
+ member = await get_or_fetch_member(guild, user_id)
if not member:
return (
@@ -332,7 +331,7 @@ class Reviewer:
"""
log.trace(f"Fetching the nomination history data for {member.id}'s review")
history = await self.bot.api_client.get(
- self._pool.api_endpoint,
+ "bot/nominations",
params={
"user__id": str(member.id),
"active": "false",
@@ -390,18 +389,18 @@ class Reviewer:
Returns True if the user was successfully marked as reviewed, False otherwise.
"""
log.trace(f"Updating user {user_id} as reviewed")
- await self._pool.fetch_user_cache()
- if user_id not in self._pool.watched_users:
+ await self._pool.refresh_cache()
+ if user_id not in self._pool.cache:
log.trace(f"Can't find a nominated user with id {user_id}")
await ctx.send(f":x: Can't find a currently nominated user with id `{user_id}`")
return False
- nomination = self._pool.watched_users.get(user_id)
+ nomination = self._pool.cache.get(user_id)
if nomination["reviewed"]:
await ctx.send(":x: This nomination was already reviewed, but here's a cookie :cookie:")
return False
- await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True})
+ await self.bot.api_client.patch(f"bot/nominations/{nomination['id']}", json={"reviewed": True})
if user_id in self._review_scheduler:
self._review_scheduler.cancel(user_id)
diff --git a/bot/exts/utils/extensions.py b/bot/exts/utils/extensions.py
index f78664527..309126d0e 100644
--- a/bot/exts/utils/extensions.py
+++ b/bot/exts/utils/extensions.py
@@ -36,7 +36,7 @@ class Extensions(commands.Cog):
def __init__(self, bot: Bot):
self.bot = bot
- @group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True)
+ @group(name="extensions", aliases=("ext", "exts", "c", "cog", "cogs"), invoke_without_command=True)
async def extensions_group(self, ctx: Context) -> None:
"""Load, unload, reload, and list loaded extensions."""
await ctx.send_help(ctx.command)
diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py
index 41b6cac5c..95f3661af 100644
--- a/bot/exts/utils/reminders.py
+++ b/bot/exts/utils/reminders.py
@@ -1,4 +1,3 @@
-import asyncio
import logging
import random
import textwrap
@@ -17,8 +16,10 @@ from bot.constants import (
)
from bot.converters import Duration, UnambiguousUser
from bot.pagination import LinePaginator
+from bot.utils import scheduling
from bot.utils.checks import has_any_role_check, has_no_roles_check
from bot.utils.lock import lock_arg
+from bot.utils.members import get_or_fetch_member
from bot.utils.messages import send_denial
from bot.utils.scheduling import Scheduler
from bot.utils.time import TimestampFormats, discord_timestamp
@@ -40,7 +41,7 @@ class Reminders(Cog):
self.bot = bot
self.scheduler = Scheduler(self.__class__.__name__)
- self.bot.loop.create_task(self.reschedule_reminders())
+ scheduling.create_task(self.reschedule_reminders(), event_loop=self.bot.loop)
def cog_unload(self) -> None:
"""Cancel scheduled tasks."""
@@ -80,7 +81,7 @@ class Reminders(Cog):
f"Reminder {reminder['id']} invalid: "
f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}."
)
- asyncio.create_task(self.bot.api_client.delete(f"bot/reminders/{reminder['id']}"))
+ scheduling.create_task(self.bot.api_client.delete(f"bot/reminders/{reminder['id']}"))
return is_valid, user, channel
@@ -136,11 +137,12 @@ class Reminders(Cog):
await send_denial(ctx, f"You can't mention other {disallowed_mentions} in your reminder!")
return False
- def get_mentionables(self, mention_ids: t.List[int]) -> t.Iterator[Mentionable]:
+ async def get_mentionables(self, mention_ids: t.List[int]) -> t.Iterator[Mentionable]:
"""Converts Role and Member ids to their corresponding objects if possible."""
guild = self.bot.get_guild(Guild.id)
for mention_id in mention_ids:
- if mentionable := (guild.get_member(mention_id) or guild.get_role(mention_id)):
+ member = await get_or_fetch_member(guild, mention_id)
+ if mentionable := (member or guild.get_role(mention_id)):
yield mentionable
def schedule_reminder(self, reminder: dict) -> None:
@@ -194,9 +196,9 @@ class Reminders(Cog):
embed.description = f"Here's your reminder: {reminder['content']}"
# Here the jump URL is in the format of base_url/guild_id/channel_id/message_id
- additional_mentions = ' '.join(
- mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"])
- )
+ additional_mentions = ' '.join([
+ mentionable.mention async for mentionable in self.get_mentionables(reminder["mentions"])
+ ])
jump_url = reminder.get("jump_url")
embed.description += f"\n[Jump back to when you created the reminder]({jump_url})"
@@ -337,10 +339,10 @@ class Reminders(Cog):
remind_datetime = isoparse(remind_at).replace(tzinfo=None)
time = discord_timestamp(remind_datetime, TimestampFormats.RELATIVE)
- mentions = ", ".join(
+ mentions = ", ".join([
# Both Role and User objects have the `name` attribute
- mention.name for mention in self.get_mentionables(mentions)
- )
+ mention.name async for mention in self.get_mentionables(mentions)
+ ])
mention_string = f"\n**Mentions:** {mentions}" if mentions else ""
text = textwrap.dedent(f"""
diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py
index b1f1ba6a8..5fb10a25b 100644
--- a/bot/exts/utils/snekbox.py
+++ b/bot/exts/utils/snekbox.py
@@ -14,7 +14,7 @@ from discord.ext.commands import Cog, Context, command, guild_only
from bot.bot import Bot
from bot.constants import Categories, Channels, Roles, URLs
from bot.decorators import redirect_output
-from bot.utils import send_to_paste_service
+from bot.utils import scheduling, send_to_paste_service
from bot.utils.messages import wait_for_deletion
log = logging.getLogger(__name__)
@@ -219,7 +219,7 @@ class Snekbox(Cog):
response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.")
else:
response = await ctx.send(msg)
- self.bot.loop.create_task(wait_for_deletion(response, (ctx.author.id,)))
+ scheduling.create_task(wait_for_deletion(response, (ctx.author.id,)), event_loop=self.bot.loop)
log.info(f"{ctx.author}'s job had a return code of {results['returncode']}")
return response
diff --git a/bot/monkey_patches.py b/bot/monkey_patches.py
new file mode 100644
index 000000000..4dbdb5eab
--- /dev/null
+++ b/bot/monkey_patches.py
@@ -0,0 +1,50 @@
+import logging
+from datetime import datetime, timedelta
+
+from discord import Forbidden, http
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+
+
+class Command(commands.Command):
+ """
+ A `discord.ext.commands.Command` subclass which supports root aliases.
+
+ A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as
+ top-level commands rather than being aliases of the command's group. It's stored as an attribute
+ also named `root_aliases`.
+ """
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.root_aliases = kwargs.get("root_aliases", [])
+
+ if not isinstance(self.root_aliases, (list, tuple)):
+ raise TypeError("Root aliases of a command must be a list or a tuple of strings.")
+
+
+def patch_typing() -> None:
+ """
+ Sometimes discord turns off typing events by throwing 403's.
+
+ Handle those issues by patching the trigger_typing method so it ignores 403's in general.
+ """
+ log.debug("Patching send_typing, which should fix things breaking when discord disables typing events. Stay safe!")
+
+ original = http.HTTPClient.send_typing
+ last_403 = None
+
+ async def honeybadger_type(self, channel_id: int) -> None: # noqa: ANN001
+ nonlocal last_403
+ if last_403 and (datetime.utcnow() - last_403) < timedelta(minutes=5):
+ log.warning("Not sending typing event, we got a 403 less than 5 minutes ago.")
+ return
+ try:
+ await original(self, channel_id)
+ except Forbidden:
+ last_403 = datetime.utcnow()
+ log.warning("Got a 403 from typing event!")
+ pass
+
+ http.HTTPClient.send_typing = honeybadger_type
diff --git a/bot/resources/tags/paste.md b/bot/resources/tags/paste.md
index 2ed51def7..8c3c2985d 100644
--- a/bot/resources/tags/paste.md
+++ b/bot/resources/tags/paste.md
@@ -1,6 +1,6 @@
**Pasting large amounts of code**
If your code is too long to fit in a codeblock in discord, you can paste your code here:
-https://paste.pydis.com/
+https://paste.pythondiscord.com/
After pasting your code, **save** it by clicking the floppy disk icon in the top right, or by typing `ctrl + S`. After doing that, the URL should **change**. Copy the URL and post it here so others can see it.
diff --git a/bot/resources/tags/string-formatting.md b/bot/resources/tags/string-formatting.md
new file mode 100644
index 000000000..707d19c90
--- /dev/null
+++ b/bot/resources/tags/string-formatting.md
@@ -0,0 +1,24 @@
+**String Formatting Mini-Language**
+The String Formatting Language in Python is a powerful way to tailor the display of strings and other data structures. This string formatting mini language works for f-strings and `.format()`.
+
+Take a look at some of these examples!
+```py
+>>> my_num = 2134234523
+>>> print(f"{my_num:,}")
+2,134,234,523
+
+>>> my_smaller_num = -30.0532234
+>>> print(f"{my_smaller_num:=09.2f}")
+-00030.05
+
+>>> my_str = "Center me!"
+>>> print(f"{my_str:-^20}")
+-----Center me!-----
+
+>>> repr_str = "Spam \t Ham"
+>>> print(f"{repr_str!r}")
+'Spam \t Ham'
+```
+**Full Specification & Resources**
+[String Formatting Mini Language Specification](https://docs.python.org/3/library/string.html#format-specification-mini-language)
+[pyformat.info](https://pyformat.info/)
diff --git a/bot/utils/channel.py b/bot/utils/channel.py
index 72603c521..6d2356679 100644
--- a/bot/utils/channel.py
+++ b/bot/utils/channel.py
@@ -53,7 +53,7 @@ def is_in_category(channel: discord.TextChannel, category_id: int) -> bool:
return getattr(channel, "category_id", None) == category_id
-async def try_get_channel(channel_id: int) -> discord.abc.GuildChannel:
+async def get_or_fetch_channel(channel_id: int) -> discord.abc.GuildChannel:
"""Attempt to get or fetch a channel and return it."""
log.trace(f"Getting the channel {channel_id}.")
diff --git a/bot/utils/members.py b/bot/utils/members.py
new file mode 100644
index 000000000..302fe6d63
--- /dev/null
+++ b/bot/utils/members.py
@@ -0,0 +1,24 @@
+import logging
+import typing as t
+
+import discord
+
+log = logging.getLogger(__name__)
+
+
+async def get_or_fetch_member(guild: discord.Guild, member_id: int) -> t.Optional[discord.Member]:
+ """
+ Attempt to get a member from cache; on failure fetch from the API.
+
+ Return `None` to indicate the member could not be found.
+ """
+ if member := guild.get_member(member_id):
+ log.trace("%s retrieved from cache.", member)
+ else:
+ try:
+ member = await guild.fetch_member(member_id)
+ except discord.errors.NotFound:
+ log.trace("Failed to fetch %d from API.", member_id)
+ return None
+ log.trace("%s fetched from API.", member)
+ return member
diff --git a/bot/utils/regex.py b/bot/utils/regex.py
index a8efe1446..7bad1e627 100644
--- a/bot/utils/regex.py
+++ b/bot/utils/regex.py
@@ -6,7 +6,8 @@ INVITE_RE = re.compile(
r"discordapp(?:[\.,]|dot)com(?:\/|slash)invite|" # or discordapp.com/invite/
r"discord(?:[\.,]|dot)me|" # or discord.me
r"discord(?:[\.,]|dot)li|" # or discord.li
- r"discord(?:[\.,]|dot)io" # or discord.io.
+ r"discord(?:[\.,]|dot)io|" # or discord.io.
+ r"(?:[\.,]|dot)gg" # or .gg/
r")(?:[\/]|slash)" # / or 'slash'
r"([a-zA-Z0-9\-]+)", # the invite code itself
flags=re.IGNORECASE
diff --git a/config-default.yml b/config-default.yml
index baece5c51..3405934e0 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -157,9 +157,10 @@ guild:
reddit: &REDDIT_CHANNEL 458224812528238616
# Development
- dev_contrib: &DEV_CONTRIB 635950537262759947
- dev_core: &DEV_CORE 411200599653351425
- dev_log: &DEV_LOG 622895325144940554
+ dev_contrib: &DEV_CONTRIB 635950537262759947
+ dev_core: &DEV_CORE 411200599653351425
+ dev_voting: &DEV_CORE_VOTING 839162966519447552
+ dev_log: &DEV_LOG 622895325144940554
# Discussion
meta: 429409067623251969
@@ -232,7 +233,6 @@ guild:
# Watch
big_brother_logs: &BB_LOGS 468507907357409333
- talent_pool: &TALENT_POOL 534321732593647616
moderation_categories:
- *MODS_CATEGORY
@@ -252,6 +252,7 @@ guild:
- *MESSAGE_LOG
- *MOD_LOG
- *STAFF_VOICE
+ - *DEV_CORE_VOTING
reminder_whitelist:
- *BOT_CMD
@@ -305,7 +306,6 @@ guild:
duck_pond: 637821475327311927
incidents_archive: 720671599790915702
python_news: &PYNEWS_WEBHOOK 704381182279942324
- talent_pool: 569145364800602132
filter:
@@ -336,7 +336,6 @@ filter:
- *MESSAGE_LOG
- *MOD_LOG
- *STAFF_LOUNGE
- - *TALENT_POOL
role_whitelist:
- *ADMINS_ROLE
diff --git a/docker-compose.yml b/docker-compose.yml
index 0f0355dac..b3ca6baa4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -23,6 +23,11 @@ services:
POSTGRES_DB: pysite
POSTGRES_PASSWORD: pysite
POSTGRES_USER: pysite
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U pysite"]
+ interval: 2s
+ timeout: 1s
+ retries: 5
redis:
<< : *logging
@@ -31,6 +36,21 @@ services:
ports:
- "127.0.0.1:6379:6379"
+ metricity:
+ << : *logging
+ restart: on-failure # USE_METRICITY=false will stop the container, so this ensures it only restarts on error
+ depends_on:
+ postgres:
+ condition: service_healthy
+ image: ghcr.io/python-discord/metricity:latest
+ env_file:
+ - .env
+ environment:
+ DATABASE_URI: postgres://pysite:pysite@postgres/metricity
+ USE_METRICITY: ${USE_METRICITY-false}
+ volumes:
+ - .:/tmp/bot:ro
+
snekbox:
<< : *logging
<< : *restart_policy
@@ -56,7 +76,7 @@ services:
- "127.0.0.1:8000:8000"
tty: true
depends_on:
- - postgres
+ - metricity
environment:
DATABASE_URL: postgres://pysite:pysite@postgres:5432/pysite
METRICITY_DB_URL: postgres://pysite:pysite@postgres:5432/metricity
diff --git a/pyproject.toml b/pyproject.toml
index 23cbba19b..4431a41c5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -62,7 +62,8 @@ precommit = "pre-commit install"
build = "docker build -t ghcr.io/python-discord/bot:latest -f Dockerfile ."
push = "docker push ghcr.io/python-discord/bot:latest"
test-nocov = "pytest -n auto"
-test = "pytest -n auto --cov-report= --cov"
+test = "pytest -n auto --cov-report= --cov --ff"
+retest = "pytest -n auto --cov-report= --cov --lf"
html = "coverage html"
report = "coverage report"
diff --git a/tests/bot/exts/backend/sync/test_cog.py b/tests/bot/exts/backend/sync/test_cog.py
index 22a07313e..fdd0ab74a 100644
--- a/tests/bot/exts/backend/sync/test_cog.py
+++ b/tests/bot/exts/backend/sync/test_cog.py
@@ -60,13 +60,13 @@ class SyncCogTestCase(unittest.IsolatedAsyncioTestCase):
class SyncCogTests(SyncCogTestCase):
"""Tests for the Sync cog."""
+ @mock.patch("bot.utils.scheduling.create_task")
@mock.patch.object(Sync, "sync_guild", new_callable=mock.MagicMock)
- def test_sync_cog_init(self, sync_guild):
+ def test_sync_cog_init(self, sync_guild, create_task):
"""Should instantiate syncers and run a sync for the guild."""
# Reset because a Sync cog was already instantiated in setUp.
self.RoleSyncer.reset_mock()
self.UserSyncer.reset_mock()
- self.bot.loop.create_task = mock.MagicMock()
mock_sync_guild_coro = mock.MagicMock()
sync_guild.return_value = mock_sync_guild_coro
@@ -74,7 +74,8 @@ class SyncCogTests(SyncCogTestCase):
Sync(self.bot)
sync_guild.assert_called_once_with()
- self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro)
+ create_task.assert_called_once()
+ self.assertEqual(create_task.call_args.args[0], mock_sync_guild_coro)
async def test_sync_cog_sync_guild(self):
"""Roles and users should be synced only if a guild is successfully retrieved."""
diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py
index 5469f6b67..2fc97af2d 100644
--- a/tests/bot/exts/backend/sync/test_users.py
+++ b/tests/bot/exts/backend/sync/test_users.py
@@ -1,6 +1,8 @@
import unittest
from unittest import mock
+from discord.errors import NotFound
+
from bot.exts.backend.sync._syncers import UserSyncer, _Diff
from tests import helpers
@@ -134,6 +136,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
self.get_mock_member(fake_user()),
None
]
+ guild.fetch_member.side_effect = NotFound(mock.Mock(status=404), "Not found")
actual_diff = await UserSyncer._get_diff(guild)
expected_diff = ([], [{"id": 63, "in_guild": False}], None)
@@ -158,6 +161,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
self.get_mock_member(updated_user),
None
]
+ guild.fetch_member.side_effect = NotFound(mock.Mock(status=404), "Not found")
actual_diff = await UserSyncer._get_diff(guild)
expected_diff = ([new_user], [{"id": 55, "name": "updated"}, {"id": 63, "in_guild": False}], None)
@@ -177,6 +181,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
self.get_mock_member(fake_user()),
None
]
+ guild.fetch_member.side_effect = NotFound(mock.Mock(status=404), "Not found")
actual_diff = await UserSyncer._get_diff(guild)
expected_diff = ([], [], None)
diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py
index 51feae9cb..05e790723 100644
--- a/tests/bot/exts/filters/test_token_remover.py
+++ b/tests/bot/exts/filters/test_token_remover.py
@@ -295,20 +295,21 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
)
@autospec("bot.exts.filters.token_remover", "UNKNOWN_USER_LOG_MESSAGE")
- def test_format_userid_log_message_unknown(self, unknown_user_log_message):
+ async def test_format_userid_log_message_unknown(self, unknown_user_log_message,):
"""Should correctly format the user ID portion when the actual user it belongs to is unknown."""
token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4")
unknown_user_log_message.format.return_value = " Partner"
msg = MockMessage(id=555, content="hello world")
msg.guild.get_member.return_value = None
+ msg.guild.fetch_member.side_effect = NotFound(mock.Mock(status=404), "Not found")
- return_value = TokenRemover.format_userid_log_message(msg, token)
+ return_value = await TokenRemover.format_userid_log_message(msg, token)
self.assertEqual(return_value, (unknown_user_log_message.format.return_value, False))
unknown_user_log_message.format.assert_called_once_with(user_id=472265943062413332)
@autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE")
- def test_format_userid_log_message_bot(self, known_user_log_message):
+ async def test_format_userid_log_message_bot(self, known_user_log_message):
"""Should correctly format the user ID portion when the ID belongs to a known bot."""
token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4")
known_user_log_message.format.return_value = " Partner"
@@ -316,7 +317,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
msg.guild.get_member.return_value.__str__.return_value = "Sam"
msg.guild.get_member.return_value.bot = True
- return_value = TokenRemover.format_userid_log_message(msg, token)
+ return_value = await TokenRemover.format_userid_log_message(msg, token)
self.assertEqual(return_value, (known_user_log_message.format.return_value, True))
@@ -327,12 +328,12 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
)
@autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE")
- def test_format_log_message_user_token_user(self, user_token_message):
+ async def test_format_log_message_user_token_user(self, user_token_message):
"""Should correctly format the user ID portion when the ID belongs to a known user."""
token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4")
user_token_message.format.return_value = "Partner"
- return_value = TokenRemover.format_userid_log_message(self.msg, token)
+ return_value = await TokenRemover.format_userid_log_message(self.msg, token)
self.assertEqual(return_value, (user_token_message.format.return_value, True))
user_token_message.format.assert_called_once_with(
diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py
index 56528b290..4d01e18a5 100644
--- a/tests/bot/exts/moderation/infraction/test_infractions.py
+++ b/tests/bot/exts/moderation/infraction/test_infractions.py
@@ -3,6 +3,8 @@ import textwrap
import unittest
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch
+from discord.errors import NotFound
+
from bot.constants import Event
from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction.infractions import Infractions
@@ -196,6 +198,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
async def test_voice_unban_user_not_found(self):
"""Should include info to return dict when user was not found from guild."""
self.guild.get_member.return_value = None
+ self.guild.fetch_member.side_effect = NotFound(Mock(status=404), "Not found")
result = await self.cog.pardon_voice_ban(self.user.id, self.guild)
self.assertEqual(result, {"Info": "User was not found in the guild."})
diff --git a/tests/helpers.py b/tests/helpers.py
index 8443150eb..83b9b2363 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -279,7 +279,10 @@ def _get_mock_loop() -> unittest.mock.Mock:
# Since calling `create_task` on our MockBot does not actually schedule the coroutine object
# as a task in the asyncio loop, this `side_effect` calls `close()` on the coroutine object
# to prevent "has not been awaited"-warnings.
- loop.create_task.side_effect = lambda coroutine: coroutine.close()
+ def mock_create_task(coroutine, **kwargs):
+ coroutine.close()
+ return unittest.mock.Mock()
+ loop.create_task.side_effect = mock_create_task
return loop