Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Take activity into account in talentpool #2418

Merged
merged 14 commits into from
Apr 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bot/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ class _Channels(EnvConfig):
mod_meta = 775412552795947058
mods = 305126844661760000
nominations = 822920136150745168
nomination_discussion = 798959130634747914
nomination_voting = 822853512709931008
organisation = 551789653284356126

Expand Down
21 changes: 21 additions & 0 deletions bot/exts/recruitment/talentpool/_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,24 @@ async def post_nomination(
}
result = await self.site_api.post("bot/nominations", json=data)
return Nomination.parse_obj(result)

async def get_activity(
self,
user_ids: list[int],
*,
days: int,
) -> dict[int, int]:
"""
Get the number of messages sent in the past `days` days by users with the given IDs.

Returns a dictionary mapping user ID to message count.
"""
if not user_ids:
return {}

result = await self.site_api.post(
"bot/users/metricity_activity_data",
json=user_ids,
params={"days": str(days)}
)
return {int(user_id): message_count for user_id, message_count in result.items()}
195 changes: 158 additions & 37 deletions bot/exts/recruitment/talentpool/_cog.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import textwrap
from datetime import datetime, timezone
from io import StringIO
from typing import Optional, Union

Expand All @@ -13,18 +14,21 @@
from bot.bot import Bot
from bot.constants import Bot as BotConfig, Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES
from bot.converters import MemberOrUser, UnambiguousMemberOrUser
from bot.exts.recruitment.talentpool._api import Nomination, NominationAPI
from bot.exts.recruitment.talentpool._review import Reviewer
from bot.log import get_logger
from bot.pagination import LinePaginator
from bot.utils import time
from bot.utils.channel import get_or_fetch_channel
from bot.utils.members import get_or_fetch_member

from ._api import Nomination, NominationAPI

AUTOREVIEW_ENABLED_KEY = "autoreview_enabled"
REASON_MAX_CHARS = 1000

# The number of days that a user can have no activity (no messages sent)
shtlrs marked this conversation as resolved.
Show resolved Hide resolved
# until they should be removed from the talentpool.
DAYS_UNTIL_INACTIVE = 45

log = get_logger(__name__)


Expand All @@ -47,6 +51,8 @@ async def cog_load(self) -> None:
if await self.autoreview_enabled():
self.autoreview_loop.start()

self.prune_talentpool.start()

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)
Expand Down Expand Up @@ -122,69 +128,177 @@ async def autoreview_loop(self) -> None:
log.info("Running check for users to nominate.")
await self.reviewer.maybe_review_user()

@nomination_group.command(
name="nominees",
aliases=("nominated", "all", "list", "watched"),
root_aliases=("nominees",)
mbaruh marked this conversation as resolved.
Show resolved Hide resolved
@tasks.loop(hours=24)
async def prune_talentpool(self) -> None:
"""
Prune any inactive users from the talentpool.

A user is considered inactive if they have sent no messages on the server
in the past `DAYS_UNTIL_INACTIVE` days.
"""
log.info("Running task to prune users from talent pool")
nominations = await self.api.get_nominations(active=True)

if not nominations:
return

messages_per_user = await self.api.get_activity(
[nomination.user_id for nomination in nominations],
days=DAYS_UNTIL_INACTIVE
)

nomination_discussion = await get_or_fetch_channel(Channels.nomination_discussion)
for nomination in nominations:
if messages_per_user[nomination.user_id] > 0:
wookie184 marked this conversation as resolved.
Show resolved Hide resolved
continue

if nomination.reviewed:
continue

log.info("Removing %s from the talent pool due to inactivity", nomination.user_id)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: don't we mostly use f-strings now for string formatting ? It also keeps it consistent with how you're formatting other strings in your changes

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For logging I think we usually use this style across the bot, https://blog.pilosus.org/posts/2020/01/24/python-f-strings-in-logging/ has some reasons and I think there's also potential security issues if you're passing untrusted input (not relevant in this case)


await nomination_discussion.send(
wookie184 marked this conversation as resolved.
Show resolved Hide resolved
f":warning: <@{nomination.user_id}> ({nomination.user_id})"
" was removed from the talentpool as they have sent no messages"
f" in the past {DAYS_UNTIL_INACTIVE} days."
)
await self.api.edit_nomination(
nomination.id,
active=False,
end_reason=f"Automatic removal: User was inactive for more than {DAYS_UNTIL_INACTIVE}"
)
mbaruh marked this conversation as resolved.
Show resolved Hide resolved

@nomination_group.group(
name="list",
aliases=("nominated", "nominees"),
invoke_without_command=True
)
@has_any_role(*MODERATION_ROLES)
async def list_command(
async def list_group(
self,
ctx: Context,
oldest_first: bool = False,
) -> None:
"""
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 "Recent Nominations" sections shows users nominated in the past 7 days,
so will not be considered for autoreview.

In the "Autoreview Priority" section a :zzz: emoji will be shown next to
users that have not been active recently enough to be considered for autoreview.
Note that the order in this section will change over time so should not be relied upon.
"""
await self.list_nominated_users(ctx, oldest_first=oldest_first)
await self.show_nominations_list(ctx, grouped_view=True)

@list_group.command(name="oldest")
async def list_oldest(self, ctx: Context) -> None:
"""Shows the users that are currently in the talent pool, ordered by oldest nomination."""
await self.show_nominations_list(ctx, oldest_first=True)

@list_group.command(name='newest')
async def list_newest(self, ctx: Context) -> None:
"""Shows the users that are currently in the talent pool, ordered by newest nomination."""
await self.show_nominations_list(ctx, oldest_first=False)

async def list_nominated_users(
async def show_nominations_list(
self,
ctx: Context,
*,
oldest_first: bool = False,
grouped_view: bool = False,
) -> None:
"""
Gives an overview of the nominated users list.
Lists the currently nominated users.

It specifies the user's mention, name, how long ago they were nominated, and whether their
review was posted.
If `grouped_view` is passed, nominations will be displayed in the groups
being reviewed, recent nominations, and others by autoreview priority.

The optional kwarg `oldest_first` orders the list by oldest entry.
Otherwise, nominations will be sorted by age
(ordered based on the value of `oldest_first`).
"""
now = datetime.now(tz=timezone.utc)
nominations = await self.api.get_nominations(active=True)
if oldest_first:
nominations = reversed(nominations)
messages_per_user = await self.api.get_activity(
[nomination.user_id for nomination in nominations],
days=DAYS_UNTIL_INACTIVE
)

lines = []
if grouped_view:
reviewed_nominations = []
recent_nominations = []
other_nominations = []
for nomination in nominations:
if nomination.reviewed:
reviewed_nominations.append(nomination)
elif not self.reviewer.is_nomination_old_enough(nomination, now):
recent_nominations.append(nomination)
else:
other_nominations.append(nomination)

other_nominations = await self.reviewer.sort_nominations_to_review(other_nominations, now)

lines = [
"**Being Reviewed:**",
*await self.list_nominations(ctx, reviewed_nominations, messages_per_user),
"**Recent Nominations:**",
*await self.list_nominations(ctx, recent_nominations, messages_per_user),
"**Other Nominations by Autoreview Priority:**",
*await self.list_nominations(ctx, other_nominations, messages_per_user, show_inactive=True)
]
else:
if oldest_first:
nominations.reverse()
lines = await self.list_nominations(ctx, nominations, messages_per_user, show_reviewed=True)

if not lines:
lines = ["There are no active nominations"]

embed = Embed(
title="Talent Pool active nominations",
color=Color.blue()
)
await LinePaginator.paginate(lines, ctx, embed, empty=False)

async def list_nominations(
self,
ctx: Context,
nominations: list[Nomination],
messages_per_user: dict[int, int],
*,
show_reviewed: bool = False,
show_inactive: bool = False,
) -> list[str]:
"""
Formats the given nominations into a list.

Pass `show_reviewed` to indicate reviewed nominations, and `show_inactive` to
indicate if the user doesn't have recent enough activity to be autoreviewed.
"""
lines: list[str] = []

if not nominations:
return ["*None*"]

for nomination in nominations:
member = await get_or_fetch_member(ctx.guild, nomination.user_id)
line = f"• `{nomination.user_id}`"

member = await get_or_fetch_member(ctx.guild, nomination.user_id)
if member:
line += f" ({member.name}#{member.discriminator})"
else:
line += " (not on server)"

line += f", added {time.format_relative(nomination.inserted_at)}"
if not member: # Cross off users who left the server.
line = f"~~{line}~~"
if nomination.reviewed:
line += " *(reviewed)*"
lines.append(line)

if not lines:
lines = ("There's nothing here yet.",)
if show_reviewed and nomination.reviewed:
line += " *(reviewed)*"

embed = Embed(
title="Talent Pool active nominations",
color=Color.blue()
)
await LinePaginator.paginate(lines, ctx, embed, empty=False)
is_active = self.reviewer.is_user_active_enough(messages_per_user[nomination.user_id])
if show_inactive and not is_active:
line += " :zzz:"

@nomination_group.command(name='oldest')
@has_any_role(*MODERATION_ROLES)
async def oldest_command(self, ctx: Context) -> None:
"""Shows the users that are currently in the talent pool, ordered by oldest nomination."""
await self.list_nominated_users(ctx, oldest_first=True)
lines.append(line)
return lines

@nomination_group.command(
name="forcenominate",
Expand Down Expand Up @@ -435,7 +549,12 @@ async def post_review(self, ctx: Context, user_id: int) -> None:
@Cog.listener()
async def on_member_ban(self, guild: Guild, user: MemberOrUser) -> None:
"""Remove `user` from the talent pool after they are banned."""
await self.end_nomination(user.id, "User was banned.")
if await self.end_nomination(user.id, "Automatic removal: User was banned"):
nomination_discussion = await get_or_fetch_channel(Channels.nomination_discussion)
await nomination_discussion.send(
mbaruh marked this conversation as resolved.
Show resolved Hide resolved
f":warning: <@{user.id}> ({user.id})"
" was removed from the talentpool due to being banned."
)

@Cog.listener()
async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None:
Expand Down Expand Up @@ -539,3 +658,5 @@ async def cog_unload(self) -> None:
# Only cancel the loop task when the autoreview code is not running
async with self.autoreview_lock:
self.autoreview_loop.cancel()

self.prune_talentpool.cancel()
Loading