diff options
Diffstat (limited to '')
| -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])  |