diff options
-rw-r--r-- | bot/constants.py | 1 | ||||
-rw-r--r-- | bot/exts/events/advent_of_code/_cog.py | 73 | ||||
-rw-r--r-- | bot/utils/members.py | 47 |
3 files changed, 119 insertions, 2 deletions
diff --git a/bot/constants.py b/bot/constants.py index 854fbe55..01f825a0 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -291,6 +291,7 @@ class Roles(NamedTuple): helpers = int(environ.get("ROLE_HELPERS", 267630620367257601)) core_developers = 587606783669829632 everyone = int(environ.get("BOT_GUILD", 267624335836053506)) + aoc_completionist = int(environ.get("AOC_COMPLETIONIST_ROLE_ID", 916691790181056532)) class Tokens(NamedTuple): diff --git a/bot/exts/events/advent_of_code/_cog.py b/bot/exts/events/advent_of_code/_cog.py index 52254ea1..30bcaae6 100644 --- a/bot/exts/events/advent_of_code/_cog.py +++ b/bot/exts/events/advent_of_code/_cog.py @@ -7,12 +7,15 @@ from typing import Optional import arrow import discord from async_rediscache import RedisCache -from discord.ext import commands +from discord.ext import commands, tasks from bot.bot import Bot -from bot.constants import AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Roles, WHITELISTED_CHANNELS +from bot.constants import ( + AdventOfCode as AocConfig, Channels, Client, Colours, Emojis, Month, Roles, WHITELISTED_CHANNELS +) from bot.exts.events.advent_of_code import _helpers from bot.exts.events.advent_of_code.views.dayandstarview import AoCDropdownView +from bot.utils import members from bot.utils.decorators import InChannelCheckFailure, in_month, whitelist_override, with_role from bot.utils.extensions import invoke_help_command @@ -31,8 +34,13 @@ class AdventOfCode(commands.Cog): """Advent of Code festivities! Ho Ho Ho!""" # Redis Cache for linking Discord IDs to Advent of Code usernames + # RedisCache[member_id: aoc_username_string] account_links = RedisCache() + # A dict with keys of member_ids to block from getting the role + # RedisCache[member_id: None] + completionist_block_list = RedisCache() + def __init__(self, bot: Bot): self.bot = bot @@ -52,6 +60,52 @@ class AdventOfCode(commands.Cog): self.status_task.set_name("AoC Status Countdown") self.status_task.add_done_callback(_helpers.background_task_callback) + self.completionist_task.start() + + @tasks.loop(minutes=10.0) + async def completionist_task(self) -> None: + """ + Give members who have completed all 50 AoC stars the completionist role. + + Runs on a schedule, as defined in the task.loop decorator. + """ + await self.bot.wait_until_guild_available() + guild = self.bot.get_guild(Client.guild) + completionist_role = guild.get_role(Roles.aoc_completionist) + if completionist_role is None: + log.warning("Could not find the AoC completionist role; cancelling completionist task.") + self.completer_task.cancel() + return + + aoc_name_to_member_id = { + aoc_name: member_id + for member_id, aoc_name in await self.account_links.items() + } + + try: + leaderboard = await _helpers.fetch_leaderboard() + except _helpers.FetchingLeaderboardFailedError: + await self.bot.send_log("Unable to fetch AoC leaderboard during role sync.") + return + + placement_leaderboard = json.loads(leaderboard["placement_leaderboard"]) + + for member_aoc_info in placement_leaderboard.values(): + if not member_aoc_info["stars"] == 50: + # Only give the role to people who have completed all 50 stars + continue + + member_id = aoc_name_to_member_id.get(member_aoc_info["name"], None) + if not member_id: + continue + + member = await members.get_or_fetch_member(guild, member_id) + if member is None or completionist_role in member.roles: + continue + + if not await self.completionist_block_list.contains(member_id): + await members.handle_role_change(member, member.add_roles, completionist_role) + @commands.group(name="adventofcode", aliases=("aoc",)) @whitelist_override(channels=AOC_WHITELIST) async def adventofcode_group(self, ctx: commands.Context) -> None: @@ -59,6 +113,20 @@ class AdventOfCode(commands.Cog): if not ctx.invoked_subcommand: await invoke_help_command(ctx) + @with_role(Roles.admins) + @adventofcode_group.command( + name="block", + brief="Block a user from getting the completionist role.", + ) + async def block_from_role(self, ctx: commands.Context, member: discord.Member) -> None: + """Block the given member from receiving the AoC completionist role, removing it from them if needed.""" + completionist_role = ctx.guild.get_role(Roles.aoc_completionist) + if completionist_role in member.roles: + await member.remove_roles(completionist_role) + + await self.completionist_block_list.set(member.id, "sentinel") + await ctx.send(f":+1: Blocked {member.mention} from getting the AoC completionist role.") + @commands.guild_only() @adventofcode_group.command( name="subscribe", @@ -408,6 +476,7 @@ class AdventOfCode(commands.Cog): log.debug("Unloading the cog and canceling the background task.") self.notification_task.cancel() self.status_task.cancel() + self.completionist_task.cancel() def _build_about_embed(self) -> discord.Embed: """Build and return the informational "About AoC" embed from the resources file.""" diff --git a/bot/utils/members.py b/bot/utils/members.py new file mode 100644 index 00000000..9c8c8dd8 --- /dev/null +++ b/bot/utils/members.py @@ -0,0 +1,47 @@ +import logging +import typing as t + +import discord + +log = logging.getLogger(__name__) + + +async def get_or_fetch_member(guild: discord.Guild, member_id: int) -> t.Optional[discord.Member]: + """ + Attempt to get a member from cache; on failure fetch from the API. + + Return `None` to indicate the member could not be found. + """ + if member := guild.get_member(member_id): + log.trace("%s retrieved from cache.", member) + else: + try: + member = await guild.fetch_member(member_id) + except discord.errors.NotFound: + log.trace("Failed to fetch %d from API.", member_id) + return None + log.trace("%s fetched from API.", member) + return member + + +async def handle_role_change( + member: discord.Member, + coro: t.Callable[..., t.Coroutine], + role: discord.Role +) -> None: + """ + Change `member`'s cooldown role via awaiting `coro` and handle errors. + + `coro` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. + """ + try: + await coro(role) + except discord.NotFound: + log.debug(f"Failed to change role for {member} ({member.id}): member not found") + except discord.Forbidden: + log.debug( + f"Forbidden to change role for {member} ({member.id}); " + f"possibly due to role hierarchy" + ) + except discord.HTTPException as e: + log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") |