diff --git a/bot/constants.py b/bot/constants.py index c40b779..c0a3c76 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -168,6 +168,17 @@ class _Roles(EnvConfig, env_prefix="ROLE_"): team_dict: int = 1222691368653033652 team_tuple: int = 1222691399246286888 + levels_crystal: int = 1488465184803524758 + levels_level3: int = 1488465669401088000 + levels_s_tier: int = 1488465793082462248 + levels_diamond_rank: int = 1488465915560460399 + levels_GOAT: int = 1488465529835487232 #noqa: N815 + levels_mtfn: int = 1488537117976957030 + levels_champion: int = 1488464806855053433 + levels_mythical_python_charmer: int = 1488466097412771853 + levels_supernova_wonder: int = 1488466164106395718 + levels_ascenion_20: int = 1488466329672351876 + Roles = _Roles() diff --git a/bot/exts/levels/__init__.py b/bot/exts/levels/__init__.py new file mode 100644 index 0000000..3dcfc32 --- /dev/null +++ b/bot/exts/levels/__init__.py @@ -0,0 +1,10 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from bot.bot import SirRobin + + +async def setup(bot: "SirRobin") -> None: + """Load the CodeJams cog.""" + from bot.exts.levels._cog import Levels + await bot.add_cog(Levels(bot)) diff --git a/bot/exts/levels/_cog.py b/bot/exts/levels/_cog.py new file mode 100644 index 0000000..a118b02 --- /dev/null +++ b/bot/exts/levels/_cog.py @@ -0,0 +1,342 @@ +import random +import re +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +import discord +from async_rediscache import RedisCache +from discord.ext import commands, tasks +from pydis_core.utils.logging import get_logger + +from bot import constants +from bot.bot import SirRobin +from bot.utils import members +from bot.utils.decorators import in_whitelist + +logger = get_logger(__name__) + +ELEVATED_ROLES = (constants.Roles.admins, constants.Roles.moderation_team, constants.Roles.events_lead) + +ALLOWED_COMMAND_CHANNELS = (constants.Channels.bot_commands, constants.Channels.sir_lancebot_playground,) + +# Channels where the game runs. +ALLOWED_CHANNELS = ( + constants.Channels.off_topic_0, + constants.Channels.off_topic_1, + constants.Channels.off_topic_2, +) + +LEVEL_ROLES = ( + constants.Roles.levels_crystal, + constants.Roles.levels_level3, + constants.Roles.levels_s_tier, + constants.Roles.levels_diamond_rank, + constants.Roles.levels_GOAT, + constants.Roles.levels_mtfn, + constants.Roles.levels_champion, + constants.Roles.levels_mythical_python_charmer, + constants.Roles.levels_supernova_wonder, + constants.Roles.levels_ascenion_20 +) + +class Levels(commands.Cog): + """Cog that handles all Level functionality.""" + + #RedisCache[user_id: int, points: int] + user_points_cache = RedisCache() + + #RedisCache[role_id: int, point_threshold: int] + levels_cache = RedisCache() + + #RedisCache["value", bool] + running = RedisCache() + + def __init__(self, bot: SirRobin): + self.bot = bot + + self.rules_all = [] + self.rules_pool = [] + self.rules_active = [] + self.rules_folder_path = Path("./bot/exts/levels/rules/") + self.active_rules_num = 3 + + self.active_reaction_rule_triggers = [] + self.active_message_rule_triggers = [] + + + async def cog_load(self) -> None: + """Run startup tasks needed when cog is first loaded.""" + await self._load_rules() + + # Fill in cache with data for later functions to use + if await self.levels_cache.length() == 0: + shuffled_roles = random.sample(LEVEL_ROLES, len(LEVEL_ROLES)) + init_threshold_dict = dict.fromkeys(shuffled_roles, 0) + await self.levels_cache.update(init_threshold_dict) + + if await self.running.get("value", False): + logger.debug("Starting Rules and Point Renormalization tasks") + await self._cycle_rules_task.start() + await self._calculate_point_thresholds_task.start() + + async def _load_rules(self) -> None: + """ + Load and parse levels rules for usage. + + If a rule file does not comply with the format + and throws and error, it is skipped over. + """ + total_files_loaded = 0 + for toml_file in self.rules_folder_path.glob("*.toml"): + with open(toml_file, "rb") as f: + rule_dict = tomllib.load(f) + + rule_name = toml_file.stem + try: + rule_triggers = [RuleTrigger(**rule_trigger) for rule_trigger in rule_dict["rule"]] + rule = LevelRules(rule_name, rule_triggers) + except (TypeError, KeyError): + logger.info(f"{toml_file} not properly formatted, skipping.") + continue + + self.rules_all.append(rule) + total_files_loaded += 1 + + logger.info(f"Total rules loaded: {total_files_loaded}") + + @tasks.loop(minutes=42.0) + async def _cycle_rules_task(self) -> None: + """ + Change which rules are currently active. + + Rules will statistically be used before a repeat is seen. + This is not a guarnatee though. + """ + if len(self.rules_pool) < self.active_rules_num: + # If pool is empty, reshuffle completely to avoid activating same rule twice + self.rules_pool = random.sample(self.rules_all, len(self.rules_all)) + self.rules_active = [self.rules_pool.pop() for _ in range(self.active_rules_num)] + logger.debug(f"Cycled active rules to: {[rule.name for rule in self.rules_active]}") + + + self.active_message_rule_triggers = [ + rule for rule in self.rules_active + for rule_trigger in rule.rule_triggers if rule_trigger.interaction_type=="message" + ] + self.active_reaction_rule_triggers = [ + rule for rule in self.rules_active + for rule_trigger in rule.rule_triggers if rule_trigger.interaction_type=="reaction" + ] + # [rule for rule in self.rules_active if rule.interaction_type=="reaction"] + # self.active_message_rule_triggers = [rule for rule in self.rules_active if rule.interaction_type=="message"] + + @tasks.loop(minutes=90.0) + async def _calculate_point_thresholds_task(self) -> None: + """ + Calculate point thresholds based on number of roles, aiming for even deciles based on scores. + + If current max score is less than 100, it will fix deciles to increments of 10. + """ + user_points = await self.user_points_cache.to_dict() + all_scores = sorted(user_points.values()) + if all_scores and all_scores[-1] >= 100: + num_scores = len(all_scores) + num_levels = len(LEVEL_ROLES) + thresholds = [ + all_scores[round(num_scores * level/num_levels)] + for level in range(1, num_levels+1) + ] + else: + # At the start of the event, just use multiples of 10 up to 100 + thresholds = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100] + + levels = await self.levels_cache.to_dict() + new_levels = dict(zip(levels.keys(), thresholds, strict=False)) + await self.levels_cache.update(new_levels) + logger.debug(f"Renormalizing score thresholds. Total scores: {len(all_scores)}") + logger.debug(f"New thresholds: {thresholds}") + + + async def _update_points(self, user_id: int, points: int) -> None: + """Updates user's score and ensures correct role is assigned.""" + logger.debug(f"User {user_id} getting {points} points.") + if not await self.user_points_cache.contains(user_id): + await self.user_points_cache.set(user_id, points) + else: + if points == 0: + return + + current_points = await self.user_points_cache.get(user_id) + new_point_total = current_points + points + await self.user_points_cache.set(user_id, new_point_total) + + await self._update_role_assignment(user_id) + + + async def _update_role_assignment(self, user_id: int) -> None: + """Updates user's role based on current points and role-point thresholds.""" + user_points = await self.user_points_cache.get(user_id) + levels = await self.levels_cache.to_dict() + level_to_assign = None + + for role, point_threshold in sorted(levels.items(), key=lambda item: item[1]): + level_to_assign = role + if point_threshold >= user_points: + break + + guild = self.bot.get_guild(constants.Bot.guild) + role = guild.get_role(level_to_assign) + user = await members.get_or_fetch_member(guild, user_id) + if role in user.roles: + return + logger.debug(f"Assigning {role.name} to {user.name}") + await members.handle_role_change(user, user.add_roles, role) + + + @commands.Cog.listener() + async def on_message(self, msg: discord.Message) -> None: + """Listens to messages and checks against active message rules.""" + if not await self.running.get("value", False): + return + if msg.channel.id not in ALLOWED_CHANNELS or msg.author.bot: + return + if len(self.active_message_rules) == 0: + return + + total_points = 0 + rule_matches = 0 + for rule_trigger in self.active_message_rule_triggers: + re_pattern = rule_trigger.message_content + match = re.search(re_pattern, msg.content) + if match: + total_points += rule_trigger.points + rule_matches += 1 + + # Only update points if they've matched any rules + # If they match multiple rules and earn 0 points, + # that should still get them a role + if rule_matches != 0: + user_id = msg.author.id + await self._update_points(user_id, total_points) + + + @commands.Cog.listener() + async def on_reaction_add(self, reaction: discord.Reaction, user: discord.Member) -> None: + """ + Listens for reactions and checks for against active reaction rules. + + It will only listen for reactions added to messages within the bot's message cache. + """ + if not await self.running.get("value", False): + return + if reaction.message.channel.id not in ALLOWED_CHANNELS or user.bot: + return + if len(self.active_reaction_rules) == 0: + return + + if isinstance(reaction.emoji, str): + emoji_name = reaction.emoji + else: + emoji_name = reaction.emoji.name + + total_points = 0 + rule_matches = 0 + for rule_trigger in self.active_reaction_rule_triggers: + if emoji_name in rule_trigger.reaction_content: + total_points += rule_trigger.points + rule_matches += 1 + + # Only update points if they've matched any rules + # If they match multiple rules and earn 0 points, + # that should still get them a role + if rule_matches != 0: + await self._update_points(user.id, total_points) + + + @commands.group(name="levels") + async def levels_command_group(self, ctx: commands.Context) -> None: + """Levels group command.""" + if not ctx.invoked_subcommand: + await self.bot.invoke_help_command(ctx) + + + @levels_command_group.command() + @in_whitelist(channels=ALLOWED_COMMAND_CHANNELS) + async def points(self, ctx: commands.Context) -> None: + """Check how many points you've accrued for the Role Level system.""" + user_id = ctx.author.id + + if await self.user_points_cache.contains(user_id): + points = await self.user_points_cache.get(user_id) + await ctx.reply(f"You have {points} points.") + else: + await ctx.reply("You have not earned any points so far! :D") + + + @levels_command_group.command() + @commands.has_any_role(*ELEVATED_ROLES) + async def shuffle_role_order(self, ctx: commands.Context) -> None: + """Shuffle which roles are assigned to which point thresholds.""" + levels = await self.levels_cache.to_dict() + thresholds = levels.values() + + role_order = random.sample(LEVEL_ROLES, len(LEVEL_ROLES)) + updated_ordering = dict(zip(role_order, thresholds, strict=False)) + + await self.levels_cache.update(updated_ordering) + logger.info(f"Roles have been re-shuffled per request of {ctx.author.name}") + + @levels_command_group.command() + @commands.has_any_role(*ELEVATED_ROLES) + async def start(self, ctx: commands.Context) -> None: + """Allows Levels to run, check messages, and assign roles.""" + current_state = await self.running.get("value", False) + if current_state: + await ctx.reply("Levels is already running.") + return + + self._cycle_rules_task.start() + self._calculate_point_thresholds_task.start() + await self.running.set("value", True) + await ctx.reply("Levels is now turned on.") + + @levels_command_group.command() + @commands.has_any_role(*ELEVATED_ROLES) + async def stop(self, ctx: commands.Context) -> None: + """Disallows Levels to run, check messages, and assign roles.""" + current_state = await self.running.get("value", False) + if not current_state: + await ctx.reply("Levels is already off.") + return + + self._cycle_rules_task.cancel() + self._calculate_point_thresholds_task.cancel() + await self.running.set("value", False) + await ctx.reply("Levels is now turned off.") + + @levels_command_group.command() + @commands.has_any_role(*ELEVATED_ROLES) + async def status(self, ctx: commands.Context) -> None: + """Replies with current status of Levels.""" + current_state = await self.running.get("value", False) + if current_state: + await ctx.reply(":white_check_mark: Levels is currently running.") + else: + await ctx.reply(":x: Levels is current **not** running.") + + +# Please see ./rules/README.md for how to format rules + +@dataclass +class RuleTrigger: + interaction_type: Literal["message", "reaction"] + reaction_content: list[str] | None = None + message_content: str | None = None + points: int = 0 + +@dataclass +class LevelRules: + name: str + rule_triggers: list[RuleTrigger] diff --git a/bot/exts/levels/rules/README.md b/bot/exts/levels/rules/README.md new file mode 100644 index 0000000..6bf51b0 --- /dev/null +++ b/bot/exts/levels/rules/README.md @@ -0,0 +1,46 @@ +# Rules format +Each rule should be in the following format: +```toml +[[rule]] +type = "message" | "reaction" +reaction_content = [] # each list item should be the name of a reaction or the reaction unicode itself +message_content = '''...''' # this should be a valid regex that can be compiled +points = 0 # the number of points that should be added +``` + +You can have multiple triggers with different point values for each rule file. +Each rule trigger is independent of each other. Any of them can trigger, they do not all have to trigger for points to be given. + +Notes: +- Each rule trigger needs to start with `[[rule]]` +- `reaction_content` - If the reaction is a default reaction, it should be the unicode emoji itself. If the reaction is a custom one, use the reaction name. +- `message_content` - Use triple quotes to avoid an escaping problem within toml, especially with regex. +- `points` - Can be a negative number. + +Examples of different rules: +```toml +[[rule]] +type = "message" +message_content = '''\b((?:p|P)ython)\b''' +points = 1 +``` + +```toml +[[rule]] +type = "reaction" +reaction_content = ["🦀", "rust"] +points = -1 +``` + +```toml +[[rule]] +interaction_type = "message" +message_content = '''((?:b|B)lazing(?:ly)*\sfast)''' +points = -1 + +[[rule]] +interaction_type = "message" +message_content = '''(?:🚀)+.*((?:b|B)lazing(?:ly)*\sfast).*(?:🚀)+''' +points = -2 +# A message that with the rocket emojis *and* blazing(ly) fast will get a total of -3 points with these triggers +``` diff --git a/bot/exts/levels/rules/blazing_fast.toml b/bot/exts/levels/rules/blazing_fast.toml new file mode 100644 index 0000000..62a9582 --- /dev/null +++ b/bot/exts/levels/rules/blazing_fast.toml @@ -0,0 +1,9 @@ +[[rule]] +interaction_type = "message" +message_content = '''((?:b|B)lazing(?:ly)*\sfast)''' +points = -1 + +[[rule]] +interaction_type = "message" +message_content = '''(?:🚀)+.*((?:b|B)lazing(?:ly)*\sfast).*?(?:🚀)+''' +points = -2 diff --git a/bot/exts/levels/rules/java.toml b/bot/exts/levels/rules/java.toml new file mode 100644 index 0000000..3b0c716 --- /dev/null +++ b/bot/exts/levels/rules/java.toml @@ -0,0 +1,4 @@ +[[rule]] +interaction_type = "message" +message_content = '''\b((?:j|J)ava)''' +points = -2 diff --git a/bot/exts/levels/rules/monty_python.toml b/bot/exts/levels/rules/monty_python.toml new file mode 100644 index 0000000..721fb4b --- /dev/null +++ b/bot/exts/levels/rules/monty_python.toml @@ -0,0 +1,19 @@ +[[rule]] +interaction_type = "message" +message_content = '''\b([Ss]panish [Ii]nquisition)\b''' +points = 2 + +[[rule]] +interaction_type = "message" +message_content = '''\b([Cc]heese [Ss]hop)\b''' +points = 2 + +[[rule]] +interaction_type = "message" +message_content = '''\b(Ni!)\b''' +points = 3 + +[[rule]] +interaction_type = "message" +message_content = '''\b(IDLE|Idle|Cleese|Gilliam|Jones|Chapman|Palin)\b''' +points = 1 diff --git a/bot/exts/levels/rules/numbers.toml b/bot/exts/levels/rules/numbers.toml new file mode 100644 index 0000000..22e8ce8 --- /dev/null +++ b/bot/exts/levels/rules/numbers.toml @@ -0,0 +1,9 @@ +[[rule]] +interaction_type = "message" +message_content = '''\b(42|23|1337)\b''' +points = 2 + +[[rule]] +interaction_type = "message" +message_content = '''\b(67|69|420)\b''' +points = -1 diff --git a/bot/exts/levels/rules/python_mention.toml b/bot/exts/levels/rules/python_mention.toml new file mode 100644 index 0000000..79d56e6 --- /dev/null +++ b/bot/exts/levels/rules/python_mention.toml @@ -0,0 +1,4 @@ +[[rule]] +interaction_type = "message" +message_content = '''\b((?:p|P)ython)\b''' +points = 1 diff --git a/bot/exts/levels/rules/rust_mention.toml b/bot/exts/levels/rules/rust_mention.toml new file mode 100644 index 0000000..b9a2189 --- /dev/null +++ b/bot/exts/levels/rules/rust_mention.toml @@ -0,0 +1,4 @@ +[[rule]] +interaction_type = "reaction" +reaction_content = ['🦀', "rust"] +points = 1 diff --git a/bot/exts/levels/rules/slorb_mention.toml b/bot/exts/levels/rules/slorb_mention.toml new file mode 100644 index 0000000..ad03b08 --- /dev/null +++ b/bot/exts/levels/rules/slorb_mention.toml @@ -0,0 +1,4 @@ +[[rule]] +interaction_type = "message" +message_content = '''(!slorb)''' +points = 3 diff --git a/bot/exts/levels/rules/wordle.toml b/bot/exts/levels/rules/wordle.toml new file mode 100644 index 0000000..75804e5 --- /dev/null +++ b/bot/exts/levels/rules/wordle.toml @@ -0,0 +1,14 @@ +[[rule]] +interaction_type = "message" +message_content = '''(🟩🟩🟩🟩🟩)''' +points = 2 + +[[rule]] +interaction_type = "message" +message_content = '''(⬜⬜⬜⬜⬜)''' +points = -1 + +[[rule]] +interaction_type = "message" +message_content = '''(⬛⬛⬛⬛⬛)''' +points = -1 diff --git a/bot/exts/levels/rules/xkcd.toml b/bot/exts/levels/rules/xkcd.toml new file mode 100644 index 0000000..e6ac649 --- /dev/null +++ b/bot/exts/levels/rules/xkcd.toml @@ -0,0 +1,14 @@ +[[rule]] +interaction_type = "message" +message_content = '''\b([Bb]obby [Tt]ables|[Ss]hibboleet)\b''' +points = 2 + +[[rule]] +interaction_type = "message" +message_content = '''\b(xkcd)\b''' +points = 1 + +[[rule]] +interaction_type = "message" +message_content = '''\b(is just applied)\b''' +points = 1