aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/exts/recruitment/talentpool/_cog.py35
-rw-r--r--bot/exts/recruitment/talentpool/_review.py92
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])