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/bot.py2
-rw-r--r--bot/constants.py2
-rw-r--r--bot/converters.py14
-rw-r--r--bot/errors.py19
-rw-r--r--bot/exts/filters/antispam.py15
-rw-r--r--bot/exts/filters/filtering.py12
-rw-r--r--bot/exts/info/information.py3
-rw-r--r--bot/exts/moderation/infraction/management.py36
-rw-r--r--bot/exts/recruitment/talentpool/_cog.py186
-rw-r--r--bot/exts/recruitment/talentpool/_review.py28
-rw-r--r--bot/exts/utils/extensions.py2
-rw-r--r--bot/resources/tags/paste.md2
-rw-r--r--bot/resources/tags/string-formatting.md24
-rw-r--r--bot/utils/regex.py3
-rw-r--r--config-default.yml11
-rw-r--r--docker-compose.yml22
23 files changed, 266 insertions, 146 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/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/constants.py b/bot/constants.py
index 33ba784af..b84a3747a 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..18bb6e4e5 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
@@ -558,7 +559,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 +569,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/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/filters/antispam.py b/bot/exts/filters/antispam.py
index 8c075fa95..72103c9fb 100644
--- a/bot/exts/filters/antispam.py
+++ b/bot/exts/filters/antispam.py
@@ -250,7 +250,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/filtering.py b/bot/exts/filters/filtering.py
index 10cc7885d..7e698880f 100644
--- a/bot/exts/filters/filtering.py
+++ b/bot/exts/filters/filtering.py
@@ -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/info/information.py b/bot/exts/info/information.py
index bcf8c10d2..be67910a6 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -72,7 +72,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"):
diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py
index 223a124d8..d72cf8f89 100644
--- a/bot/exts/moderation/infraction/management.py
+++ b/bot/exts/moderation/infraction/management.py
@@ -11,9 +11,9 @@ 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
@@ -45,25 +45,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(
@@ -348,13 +345,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/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py
index c297f70c2..aaafff973 100644
--- a/bot/exts/recruitment/talentpool/_cog.py
+++ b/bot/exts/recruitment/talentpool/_cog.py
@@ -1,8 +1,8 @@
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
@@ -11,12 +11,12 @@ from discord.ext.commands import 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.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES
from bot.converters import MemberOrUser
-from bot.exts.moderation.watchchannels._watchchannel import WatchChannel
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.time import get_time_delta
AUTOREVIEW_ENABLED_KEY = "autoreview_enabled"
REASON_MAX_CHARS = 1000
@@ -24,38 +24,56 @@ 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)
async def nomination_group(self, ctx: Context) -> None:
@@ -106,25 +124,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 +163,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 = ctx.guild.get_member(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 +193,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 +202,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 +236,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 +264,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 +286,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 +300,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"
@@ -298,20 +324,20 @@ 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")
@@ -330,10 +356,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:
@@ -347,13 +373,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 +391,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 +404,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 +445,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 +467,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,11 +485,11 @@ 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)
@@ -512,7 +538,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..f4aa73e75 100644
--- a/bot/exts/recruitment/talentpool/_review.py
+++ b/bot/exts/recruitment/talentpool/_review.py
@@ -57,10 +57,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 +66,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,18 +94,18 @@ 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
@@ -332,7 +330,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 +388,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/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/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 95ba9fa53..ac4cb887f 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
@@ -310,7 +311,6 @@ guild:
duck_pond: 637821475327311927
incidents_archive: 720671599790915702
python_news: &PYNEWS_WEBHOOK 704381182279942324
- talent_pool: 569145364800602132
filter:
@@ -341,7 +341,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