From 871487e3f512389a9fcfa748854a0163fe2b140d Mon Sep 17 00:00:00 2001 From: Janine vN Date: Tue, 31 Mar 2026 01:27:21 -0400 Subject: [PATCH 1/8] Setup Levels implementation This commit is larger than it should be, but it sets up almost all the functionality needed to get it to run. There are many improvements that can and should be made. Co-authored-by: camcaswell --- bot/exts/levels/__init__.py | 10 + bot/exts/levels/_cog.py | 321 ++++++++++++++++++++++ bot/exts/levels/rules/README.md | 26 ++ bot/exts/levels/rules/python_mention.toml | 3 + bot/exts/levels/rules/rust_mention.toml | 3 + bot/exts/levels/rules/slorb_mention.toml | 3 + 6 files changed, 366 insertions(+) create mode 100644 bot/exts/levels/__init__.py create mode 100644 bot/exts/levels/_cog.py create mode 100644 bot/exts/levels/rules/README.md create mode 100644 bot/exts/levels/rules/python_mention.toml create mode 100644 bot/exts/levels/rules/rust_mention.toml create mode 100644 bot/exts/levels/rules/slorb_mention.toml 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..66b473e --- /dev/null +++ b/bot/exts/levels/_cog.py @@ -0,0 +1,321 @@ +from dataclasses import dataclass +from pathlib import Path +import random +import re +import tomllib +from typing import Literal + +from async_rediscache import RedisCache +import discord +from discord.ext import commands, tasks + +from bot import constants +from bot.bot import SirRobin +from bot.utils import members +from bot.utils.decorators import in_whitelist + +from pydis_core.utils.logging import get_logger +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_rules = [] + self.active_message_rules = [] + + + 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 = {role: 0 for role in shuffled_roles} + await self.levels_cache.update(init_threshold_dict) + + if await self.running.get("value", False): + await self._cycle_rules_task().start() + await self._calculate_point_thresholds_task().start() + + + + async def _load_rules(self): + """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 = LevelRules(name=rule_name, **rule_dict) + except TypeError: + 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): + """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_reaction_rules = [rule for rule in self.rules_active if rule.interaction_type=="reaction"] + self.active_message_rules = [rule for rule in self.rules_active if rule.interaction_type=="message"] + + @tasks.loop(minutes=90.0) + async def _calculate_point_thresholds_task(self): + """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)) + 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): + """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): + """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() + for role, point_threshold in sorted(levels.items(), key=lambda item: item[1]): + if point_threshold >= user_points: + break + + guild = self.bot.get_guild(constants.Bot.guild) + role = guild.get_role(role) + 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 in self.active_message_rules: + re_pattern = rule.message_content + match = re.search(re_pattern, msg.content) + if match: + total_points += rule.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 in self.active_reaction_rules: + if emoji_name in rule.reaction_content: + total_points += rule.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): + """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)) + + 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): + """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): + """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): + """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 LevelRules: + name: str + interaction_type: Literal["message", "reaction"] + reaction_content: list[str] | None = None + message_content: str | None = None + points: int = 0 diff --git a/bot/exts/levels/rules/README.md b/bot/exts/levels/rules/README.md new file mode 100644 index 0000000..e202aeb --- /dev/null +++ b/bot/exts/levels/rules/README.md @@ -0,0 +1,26 @@ +# Rules format +Each rule should be in the following format: +```toml +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 +``` + +Notes: +- `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 +type = "message" +message_content = '''\b((?:p|P)ython)\b''' +points = 1 +``` + +```toml +type = "reaction" +reaction_content = ["🦀", "rust"] +points = -1 +``` \ No newline at end of file diff --git a/bot/exts/levels/rules/python_mention.toml b/bot/exts/levels/rules/python_mention.toml new file mode 100644 index 0000000..87297c3 --- /dev/null +++ b/bot/exts/levels/rules/python_mention.toml @@ -0,0 +1,3 @@ +interaction_type = "message" +message_content = '''\b((?:p|P)ython)\b''' +points = 1 \ No newline at end of file diff --git a/bot/exts/levels/rules/rust_mention.toml b/bot/exts/levels/rules/rust_mention.toml new file mode 100644 index 0000000..f560596 --- /dev/null +++ b/bot/exts/levels/rules/rust_mention.toml @@ -0,0 +1,3 @@ +interaction_type = "reaction" +reaction_content = ['🦀', "rust"] +points = -1 \ No newline at end of file diff --git a/bot/exts/levels/rules/slorb_mention.toml b/bot/exts/levels/rules/slorb_mention.toml new file mode 100644 index 0000000..feb1590 --- /dev/null +++ b/bot/exts/levels/rules/slorb_mention.toml @@ -0,0 +1,3 @@ +interaction_type = "message" +message_content = '''(!slorb)''' +points = 3 \ No newline at end of file From fd993bf5d4b4de10021f0bb8a1f575d0dff3fd65 Mon Sep 17 00:00:00 2001 From: Janine vN Date: Tue, 31 Mar 2026 01:30:04 -0400 Subject: [PATCH 2/8] Add Levels Roles --- bot/constants.py | 11 +++++++++++ 1 file changed, 11 insertions(+) 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() From 8c8d10adc3cc14b9cc1f25792087e37e7b64bca5 Mon Sep 17 00:00:00 2001 From: Janine vN Date: Tue, 31 Mar 2026 12:09:12 -0400 Subject: [PATCH 3/8] Allow multiple triggers per rule Adjusts the parsing and rule structure to allow multiple triggers for a rule, each with a potentially different point value assigned. --- bot/exts/levels/_cog.py | 52 +++++++++++++++-------- bot/exts/levels/rules/README.md | 20 +++++++++ bot/exts/levels/rules/blazing_fast.toml | 9 ++++ bot/exts/levels/rules/java.toml | 4 ++ bot/exts/levels/rules/python_mention.toml | 1 + bot/exts/levels/rules/rust_mention.toml | 3 +- bot/exts/levels/rules/slorb_mention.toml | 1 + 7 files changed, 71 insertions(+), 19 deletions(-) create mode 100644 bot/exts/levels/rules/blazing_fast.toml create mode 100644 bot/exts/levels/rules/java.toml diff --git a/bot/exts/levels/_cog.py b/bot/exts/levels/_cog.py index 66b473e..336a499 100644 --- a/bot/exts/levels/_cog.py +++ b/bot/exts/levels/_cog.py @@ -62,8 +62,8 @@ def __init__(self, bot: SirRobin): self.rules_folder_path = Path("./bot/exts/levels/rules/") self.active_rules_num = 3 - self.active_reaction_rules = [] - self.active_message_rules = [] + self.active_reaction_rule_triggers = [] + self.active_message_rule_triggers = [] async def cog_load(self) -> None: @@ -77,10 +77,10 @@ async def cog_load(self) -> None: await self.levels_cache.update(init_threshold_dict) if await self.running.get("value", False): - await self._cycle_rules_task().start() - await self._calculate_point_thresholds_task().start() - - + logger.debug("Starting Rules and Point Renormalization tasks") + print(self._cycle_rules_task.is_running()) + await self._cycle_rules_task.start() + await self._calculate_point_thresholds_task.start() async def _load_rules(self): """Load and parse levels rules for usage. @@ -95,8 +95,9 @@ async def _load_rules(self): rule_name = toml_file.stem try: - rule = LevelRules(name=rule_name, **rule_dict) - except TypeError: + rule_triggers = [RuleTrigger(**rule_trigger) for rule_trigger in rule_dict["rule"]] + rule = LevelRules(rule_name, rule_triggers) + except (TypeError, KeyError) as e: logger.info(f"{toml_file} not properly formatted, skipping.") continue @@ -118,8 +119,17 @@ async def _cycle_rules_task(self): 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_reaction_rules = [rule for rule in self.rules_active if rule.interaction_type=="reaction"] - self.active_message_rules = [rule for rule in self.rules_active if rule.interaction_type=="message"] + + 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): @@ -192,11 +202,11 @@ async def on_message(self, msg: discord.Message) -> None: total_points = 0 rule_matches = 0 - for rule in self.active_message_rules: - re_pattern = rule.message_content + 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.points + total_points += rule_trigger.points rule_matches += 1 # Only update points if they've matched any rules @@ -227,9 +237,9 @@ async def on_reaction_add(self, reaction: discord.Reaction, user: discord.Member total_points = 0 rule_matches = 0 - for rule in self.active_reaction_rules: - if emoji_name in rule.reaction_content: - total_points += rule.points + 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 @@ -312,10 +322,16 @@ async def status(self, ctx: commands.Context): # Please see ./rules/README.md for how to format rules + @dataclass -class LevelRules: - name: str +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 index e202aeb..c066010 100644 --- a/bot/exts/levels/rules/README.md +++ b/bot/exts/levels/rules/README.md @@ -1,26 +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 ``` \ No newline at end of file diff --git a/bot/exts/levels/rules/blazing_fast.toml b/bot/exts/levels/rules/blazing_fast.toml new file mode 100644 index 0000000..9785277 --- /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 \ No newline at end of file diff --git a/bot/exts/levels/rules/java.toml b/bot/exts/levels/rules/java.toml new file mode 100644 index 0000000..aeae3ff --- /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 \ No newline at end of file diff --git a/bot/exts/levels/rules/python_mention.toml b/bot/exts/levels/rules/python_mention.toml index 87297c3..36b9e90 100644 --- a/bot/exts/levels/rules/python_mention.toml +++ b/bot/exts/levels/rules/python_mention.toml @@ -1,3 +1,4 @@ +[[rule]] interaction_type = "message" message_content = '''\b((?:p|P)ython)\b''' points = 1 \ No newline at end of file diff --git a/bot/exts/levels/rules/rust_mention.toml b/bot/exts/levels/rules/rust_mention.toml index f560596..b5e5489 100644 --- a/bot/exts/levels/rules/rust_mention.toml +++ b/bot/exts/levels/rules/rust_mention.toml @@ -1,3 +1,4 @@ +[[rule]] interaction_type = "reaction" reaction_content = ['🦀', "rust"] -points = -1 \ No newline at end of file +points = 1 \ No newline at end of file diff --git a/bot/exts/levels/rules/slorb_mention.toml b/bot/exts/levels/rules/slorb_mention.toml index feb1590..ad92d85 100644 --- a/bot/exts/levels/rules/slorb_mention.toml +++ b/bot/exts/levels/rules/slorb_mention.toml @@ -1,3 +1,4 @@ +[[rule]] interaction_type = "message" message_content = '''(!slorb)''' points = 3 \ No newline at end of file From 37f227da20b2310c74eaedf36f61f66aeff3da59 Mon Sep 17 00:00:00 2001 From: L3viathan Date: Tue, 31 Mar 2026 19:45:17 +0200 Subject: [PATCH 4/8] Levels: Wordle (2 pts for success, -1 point for no correct letters) --- bot/exts/levels/rules/wordle.toml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 bot/exts/levels/rules/wordle.toml 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 From 9400fd23bc1f92bf9efbed8dbcbdf0b907a22302 Mon Sep 17 00:00:00 2001 From: L3viathan Date: Tue, 31 Mar 2026 19:54:26 +0200 Subject: [PATCH 5/8] Levels: funny numbers, Python (Monty) --- bot/exts/levels/rules/monty_python.toml | 19 +++++++++++++++++++ bot/exts/levels/rules/numbers.toml | 9 +++++++++ 2 files changed, 28 insertions(+) create mode 100644 bot/exts/levels/rules/monty_python.toml create mode 100644 bot/exts/levels/rules/numbers.toml 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 From 0a77645898588d8afa7508df55c9e19f49bc2138 Mon Sep 17 00:00:00 2001 From: L3viathan Date: Tue, 31 Mar 2026 19:59:53 +0200 Subject: [PATCH 6/8] Levels: xkcd --- bot/exts/levels/rules/xkcd.toml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 bot/exts/levels/rules/xkcd.toml 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 From a818b3935c03be8a50fbdf14b411ca5e1ee0c02d Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Wed, 1 Apr 2026 00:48:04 +0100 Subject: [PATCH 7/8] Linting & file ending fixes - Line & file endings fix - Fix lint B007 by explicitly setting level to assign variable --- bot/exts/levels/_cog.py | 107 +++++++++++----------- bot/exts/levels/rules/README.md | 4 +- bot/exts/levels/rules/blazing_fast.toml | 2 +- bot/exts/levels/rules/java.toml | 2 +- bot/exts/levels/rules/python_mention.toml | 2 +- bot/exts/levels/rules/rust_mention.toml | 2 +- bot/exts/levels/rules/slorb_mention.toml | 2 +- 7 files changed, 63 insertions(+), 58 deletions(-) diff --git a/bot/exts/levels/_cog.py b/bot/exts/levels/_cog.py index 336a499..a118b02 100644 --- a/bot/exts/levels/_cog.py +++ b/bot/exts/levels/_cog.py @@ -1,20 +1,20 @@ -from dataclasses import dataclass -from pathlib import Path import random import re import tomllib +from dataclasses import dataclass +from pathlib import Path from typing import Literal -from async_rediscache import RedisCache 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 -from pydis_core.utils.logging import get_logger logger = get_logger(__name__) ELEVATED_ROLES = (constants.Roles.admins, constants.Roles.moderation_team, constants.Roles.events_lead) @@ -55,7 +55,7 @@ class Levels(commands.Cog): def __init__(self, bot: SirRobin): self.bot = bot - + self.rules_all = [] self.rules_pool = [] self.rules_active = [] @@ -64,27 +64,27 @@ def __init__(self, bot: SirRobin): 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 = {role: 0 for role in shuffled_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") - print(self._cycle_rules_task.is_running()) await self._cycle_rules_task.start() await self._calculate_point_thresholds_task.start() - async def _load_rules(self): - """Load and parse levels rules for usage. - + 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. """ @@ -97,19 +97,20 @@ async def _load_rules(self): try: rule_triggers = [RuleTrigger(**rule_trigger) for rule_trigger in rule_dict["rule"]] rule = LevelRules(rule_name, rule_triggers) - except (TypeError, KeyError) as e: + 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): - """Change which rules are currently active. - + 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. """ @@ -121,20 +122,21 @@ async def _cycle_rules_task(self): self.active_message_rule_triggers = [ - rule for rule in self.rules_active + 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 + 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): - """Calculate point thresholds based on number of roles, aiming for even deciles based on scores. - + 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() @@ -149,15 +151,15 @@ async def _calculate_point_thresholds_task(self): 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)) + 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): + 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): @@ -169,20 +171,23 @@ async def _update_points(self, user_id: int, points: int): 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): + + 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(role) + role = guild.get_role(level_to_assign) user = await members.get_or_fetch_member(guild, user_id) if role in user.roles: return @@ -199,7 +204,7 @@ async def on_message(self, msg: discord.Message) -> None: return if len(self.active_message_rules) == 0: return - + total_points = 0 rule_matches = 0 for rule_trigger in self.active_message_rule_triggers: @@ -208,7 +213,7 @@ async def on_message(self, msg: discord.Message) -> None: 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 @@ -216,10 +221,11 @@ async def on_message(self, msg: discord.Message) -> None: 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. + """ + 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. """ @@ -241,17 +247,17 @@ async def on_reaction_add(self, reaction: discord.Reaction, user: discord.Member 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""" + """Levels group command.""" if not ctx.invoked_subcommand: await self.bot.invoke_help_command(ctx) @@ -259,9 +265,9 @@ async def levels_command_group(self, ctx: commands.Context) -> None: @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""" + """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.") @@ -271,55 +277,55 @@ async def points(self, ctx: commands.Context) -> None: @levels_command_group.command() @commands.has_any_role(*ELEVATED_ROLES) - async def shuffle_role_order(self, ctx: commands.Context): + 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)) + 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): + 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): + 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): + 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 @@ -334,4 +340,3 @@ class RuleTrigger: class LevelRules: name: str rule_triggers: list[RuleTrigger] - diff --git a/bot/exts/levels/rules/README.md b/bot/exts/levels/rules/README.md index c066010..6bf51b0 100644 --- a/bot/exts/levels/rules/README.md +++ b/bot/exts/levels/rules/README.md @@ -8,7 +8,7 @@ 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. +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: @@ -43,4 +43,4 @@ 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 -``` \ No newline at end of file +``` diff --git a/bot/exts/levels/rules/blazing_fast.toml b/bot/exts/levels/rules/blazing_fast.toml index 9785277..31a7498 100644 --- a/bot/exts/levels/rules/blazing_fast.toml +++ b/bot/exts/levels/rules/blazing_fast.toml @@ -6,4 +6,4 @@ points = -1 [[rule]] interaction_type = "message" message_content = '''(?:🚀)+.*((?:b|B)lazing(?:ly)*\sfast).*(?:🚀)+''' -points = -2 \ No newline at end of file +points = -2 diff --git a/bot/exts/levels/rules/java.toml b/bot/exts/levels/rules/java.toml index aeae3ff..3b0c716 100644 --- a/bot/exts/levels/rules/java.toml +++ b/bot/exts/levels/rules/java.toml @@ -1,4 +1,4 @@ [[rule]] interaction_type = "message" message_content = '''\b((?:j|J)ava)''' -points = -2 \ No newline at end of file +points = -2 diff --git a/bot/exts/levels/rules/python_mention.toml b/bot/exts/levels/rules/python_mention.toml index 36b9e90..79d56e6 100644 --- a/bot/exts/levels/rules/python_mention.toml +++ b/bot/exts/levels/rules/python_mention.toml @@ -1,4 +1,4 @@ [[rule]] interaction_type = "message" message_content = '''\b((?:p|P)ython)\b''' -points = 1 \ No newline at end of file +points = 1 diff --git a/bot/exts/levels/rules/rust_mention.toml b/bot/exts/levels/rules/rust_mention.toml index b5e5489..b9a2189 100644 --- a/bot/exts/levels/rules/rust_mention.toml +++ b/bot/exts/levels/rules/rust_mention.toml @@ -1,4 +1,4 @@ [[rule]] interaction_type = "reaction" reaction_content = ['🦀', "rust"] -points = 1 \ No newline at end of file +points = 1 diff --git a/bot/exts/levels/rules/slorb_mention.toml b/bot/exts/levels/rules/slorb_mention.toml index ad92d85..ad03b08 100644 --- a/bot/exts/levels/rules/slorb_mention.toml +++ b/bot/exts/levels/rules/slorb_mention.toml @@ -1,4 +1,4 @@ [[rule]] interaction_type = "message" message_content = '''(!slorb)''' -points = 3 \ No newline at end of file +points = 3 From dc5cc8a8707d431f57ccfb2b4c5f79bfc290e828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonathan=20Oberl=C3=A4nder?= Date: Wed, 1 Apr 2026 09:05:39 +0200 Subject: [PATCH 8/8] Levels: make quantifier lazy --- bot/exts/levels/rules/blazing_fast.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/levels/rules/blazing_fast.toml b/bot/exts/levels/rules/blazing_fast.toml index 31a7498..62a9582 100644 --- a/bot/exts/levels/rules/blazing_fast.toml +++ b/bot/exts/levels/rules/blazing_fast.toml @@ -5,5 +5,5 @@ points = -1 [[rule]] interaction_type = "message" -message_content = '''(?:🚀)+.*((?:b|B)lazing(?:ly)*\sfast).*(?:🚀)+''' +message_content = '''(?:🚀)+.*((?:b|B)lazing(?:ly)*\sfast).*?(?:🚀)+''' points = -2