diff options
| -rw-r--r-- | bot/exts/recruitment/talentpool/_cog.py | 35 | ||||
| -rw-r--r-- | bot/exts/recruitment/talentpool/_review.py | 92 |
2 files changed, 111 insertions, 16 deletions
diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 9819152b0..1df829a84 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -6,8 +6,8 @@ from typing import Optional, Union import discord from async_rediscache import RedisCache from botcore.site_api import ResponseCodeError -from botcore.utils import scheduling from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent, User +from discord.ext import tasks from discord.ext.commands import BadArgument, Cog, Context, group, has_any_role from bot.bot import Bot @@ -38,17 +38,12 @@ class TalentPool(Cog, name="Talentpool"): 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 cog_load(self) -> None: + """Load user cache and maybe start autoreview loop.""" + await self.refresh_cache() - 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: - log.trace("Not scheduling reviews as autoreview is disabled.") + await self.autoreview_loop.start() async def autoreview_enabled(self) -> bool: """Return whether automatic posting of nomination reviews is enabled.""" @@ -93,15 +88,21 @@ class TalentPool(Cog, name="Talentpool"): """ Enable automatic posting of reviews. - This will post reviews up to one day overdue. Older nominations can be - manually reviewed with the `tp post_review <user_id>` command. + A review will be posted when the current number of active reviews is below the limit + and long enough has passed since the last review. + + Users will be considered for review if they have been in the talent pool past a + threshold time. + + The next user to review is chosen based on the number of nominations a user has, + using the age of the first nomination as a tie-breaker (oldest first). """ if await self.autoreview_enabled(): await ctx.send(":x: Autoreview is already enabled.") return await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, True) - await self.reviewer.reschedule_reviews() + await self.autoreview_loop.start() await ctx.send(":white_check_mark: Autoreview enabled.") @nomination_autoreview_group.command(name="disable", aliases=("off",)) @@ -113,7 +114,7 @@ class TalentPool(Cog, name="Talentpool"): return await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, False) - self.reviewer.cancel_all() + await self.autoreview_loop.stop() await ctx.send(":white_check_mark: Autoreview disabled.") @nomination_autoreview_group.command(name="status") @@ -125,6 +126,12 @@ class TalentPool(Cog, name="Talentpool"): else: await ctx.send("Autoreview is currently disabled.") + @tasks.loop(hours=1) + async def autoreview_loop(self) -> None: + """Send request to `reviewer` to send a nomination if ready.""" + log.info("Running check for users to nominate.") + await self.reviewer.maybe_review_user() + @nomination_group.command( name="nominees", aliases=("nominated", "all", "list", "watched"), diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index b6abdd24f..4331a0581 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -5,7 +5,7 @@ import re import textwrap import typing from collections import Counter -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import List, Optional, Union import arrow @@ -36,6 +36,13 @@ MAX_MESSAGE_SIZE = 2000 # Maximum amount of characters allowed in an embed MAX_EMBED_SIZE = 4000 +# Maximum number of active reviews +MAX_ONGOING_REVIEWS = 4 +# Minimum time between reviews +MIN_REVIEW_INTERVAL = timedelta(days=1) +# Minimum time between nomination and sending a review +MIN_NOMINATION_TIME = timedelta(days=7) + # Regex for finding the first message of a nomination, and extracting the nominee. NOMINATION_MESSAGE_REGEX = re.compile( r"<@!?(\d+)> \(.+#\d{4}\) for Helper!\n\n", @@ -55,11 +62,92 @@ class Reviewer: """Return True if the user with ID user_id is scheduled for review, False otherwise.""" return user_id in self._review_scheduler + async def maybe_review_user(self) -> bool: + """ + Checks if a new vote should be triggered, and triggers one if ready. + + Returns a boolean representing whether a new vote was sent or not. + """ + if not await self.is_ready_for_review(): + return False + + user = await self.get_user_for_review() + if not user: + return False + + await self.post_review(user, True) + return True + + async def is_ready_for_review(self) -> bool: + """ + Returns a boolean representing whether a new vote should be triggered. + + The criteria for this are: + - The current number of reviews is lower than `MAX_ONGOING_REVIEWS`. + - The most recent review was sent less than `MIN_REVIEW_INTERVAL` ago. + """ + voting_channel = self.bot.get_channel(Channels.nomination_voting) + + review_count = 0 + is_first_message = True + async for msg in voting_channel.history(): + # Try and filter out any non-review messages. + if not msg.author.bot or "for Helper!" not in msg.content: + continue + + if is_first_message: + if msg.created_at > datetime.now(timezone.utc) - MIN_REVIEW_INTERVAL: + log.debug("Most recent review was less than %s ago, cancelling check", MIN_REVIEW_INTERVAL) + return False + + is_first_message = False + + review_count += 1 + + if review_count >= MAX_ONGOING_REVIEWS: + log.debug("There are already at least %s ongoing reviews, cancelling check.", MAX_ONGOING_REVIEWS) + return False + + return True + + async def get_user_for_review(self) -> Optional[int]: + """ + Returns the user ID of the next user to review, or None if there are no users ready. + + Users will only be selected for review if: + - They have not already been reviewed. + - They have been nominated for longer than `MIN_NOMINATION_TIME`. + + The priority of the review is determined by how many nominations the user has + (more nominations = higher priority). + For users with equal priority the oldest nomination will be reviewed first. + """ + possible = [] + for user_id, user_data in self._pool.cache.items(): + if ( + not user_data["reviewed"] + and isoparse(user_data["inserted_at"]) < datetime.now(timezone.utc) - MIN_NOMINATION_TIME + ): + possible.append((user_id, user_data)) + + if not possible: + log.debug("No users ready to review.") + return None + + # Secondary sort key: creation of first entries on the nomination. + possible.sort(key=lambda x: isoparse(x[1]["inserted_at"])) + + # Primary sort key: number of entries on the nomination. + user = max(possible, key=lambda x: len(x[1]["entries"])) + + return user[0] # user id + async def reschedule_reviews(self) -> None: """Reschedule all active nominations to be reviewed at the appropriate time.""" log.trace("Rescheduling reviews") await self.bot.wait_until_guild_available() + await self._pool.refresh_cache() for user_id, user_data in self._pool.cache.items(): if not user_data["reviewed"]: self.schedule_review(user_id) @@ -85,7 +173,7 @@ class Reviewer: guild = self.bot.get_guild(Guild.id) channel = guild.get_channel(Channels.nomination_voting) - log.trace(f"Posting the review of {nominee} ({nominee.id})") + log.info(f"Posting the review of {nominee} ({nominee.id})") messages = await self._bulk_send(channel, review) await pin_no_system_message(messages[0]) |