Skip to content

Commit c8eaad3

Browse files
fix(levels): bug fixes, type safety, and performance improvements (#188)
* fix: correct mismatched rules_anti_active attribute name in __init__ * perf: compute all_message_rule_triggers once in _load_rules, not each cycle * fix: derive fallback thresholds from LEVEL_ROLES length instead of hardcoding * fix: only log levels cache init when cache was actually empty * fix: guard _update_role_assignment against None user or unresolvable role * fix: hoist zero-point early return in _update_points, removing duplicate check * refactor: accept discord.Member instead of raw user_id in points_award and role_reset commands * fix: convert LEVEL_ROLES frozenset to sorted list before passing to random.sample * fix: guard against None level_to_assign when levels_cache is empty * fix: default current_points to 0 in points_award for members with no cache entry * perf: break all_message_rule_triggers penalty loop early once threshold is reached * perf: cache sorted level thresholds in memory; use operator.itemgetter over lambda * fix: correct spelling mistakes across _cog.py and constants.py * fix: resolve type errors - annotate list attrs, guard None, cast Redis values, ignore cache.update arg-type * refactor: validate RuleTrigger at load time using a pydantic discriminated union Switch MessageRuleTrigger and ReactionRuleTrigger to pydantic BaseModel with a Literal interaction_type discriminator field. _load_rules now calls TypeAdapter.validate_python, which handles dispatch and validation in one step. This removes all None-guards and type: ignore comments from on_message and on_reaction_add. * Revert "fix: hoist zero-point early return in _update_points, removing duplicate check" This reverts commit d4fc24c.
1 parent 1d937e3 commit c8eaad3

2 files changed

Lines changed: 96 additions & 68 deletions

File tree

bot/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ class _Roles(EnvConfig, env_prefix="ROLE_"):
177177
levels_champion: int = 1488464806855053433
178178
levels_mythical_python_charmer: int = 1488466097412771853
179179
levels_supernova_wonder: int = 1488466164106395718
180-
levels_ascenion_20: int = 1488466329672351876
180+
levels_ascension_20: int = 1488466329672351876
181181

182182

183183
Roles = _Roles()

bot/exts/levels/_cog.py

Lines changed: 95 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1+
import operator
12
import random
23
import re
34
import tomllib
4-
from dataclasses import dataclass
55
from pathlib import Path
6-
from typing import Literal
6+
from typing import Annotated, Literal, cast
77

88
import discord
99
from async_rediscache import RedisCache
1010
from discord.ext import commands, tasks
11+
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
1112
from pydis_core.utils.logging import get_logger
1213

1314
from bot import constants
@@ -38,7 +39,7 @@
3839
constants.Roles.levels_champion,
3940
constants.Roles.levels_mythical_python_charmer,
4041
constants.Roles.levels_supernova_wonder,
41-
constants.Roles.levels_ascenion_20,
42+
constants.Roles.levels_ascension_20,
4243
})
4344

4445
class Levels(commands.Cog):
@@ -58,18 +59,20 @@ def __init__(self, bot: SirRobin):
5859

5960
self.rules_folder_path = Path("./bot/exts/levels/rules/")
6061

61-
self.rules_all = []
62-
self.rules_pool = []
63-
self.rules_active = [] # Active rules earn the points
64-
self.rule_anti_active = [] # Anti-active rules will halve the current points
62+
self.rules_all: list[LevelRules] = []
63+
self.rules_pool: list[LevelRules] = []
64+
self.rules_active: list[LevelRules] = [] # Active rules earn the points
65+
self.rules_anti_active: list[LevelRules] = [] # Anti-active rules will halve the current points
6566

6667
self.active_rules_num = 3
6768
self.anti_active_rules_num = 1
6869

69-
self.active_reaction_rule_triggers = []
70-
self.active_message_rule_triggers = []
71-
self.anti_active_message_rule_triggers = []
72-
self.anti_active_reaction_rule_triggers = []
70+
self.active_reaction_rule_triggers: list[ReactionRuleTrigger] = []
71+
self.active_message_rule_triggers: list[MessageRuleTrigger] = []
72+
self.anti_active_message_rule_triggers: list[MessageRuleTrigger] = []
73+
self.anti_active_reaction_rule_triggers: list[ReactionRuleTrigger] = []
74+
self.all_message_rule_triggers: list[MessageRuleTrigger] = []
75+
self.sorted_level_thresholds: list[tuple[int, int]] = []
7376

7477

7578
async def cog_load(self) -> None:
@@ -78,10 +81,12 @@ async def cog_load(self) -> None:
7881

7982
# Fill in cache with data for later functions to use
8083
if await self.levels_cache.length() == 0:
81-
shuffled_roles = random.sample(LEVEL_ROLES, len(LEVEL_ROLES))
84+
shuffled_roles = random.sample(sorted(LEVEL_ROLES), len(LEVEL_ROLES))
8285
init_threshold_dict = dict.fromkeys(shuffled_roles, 0)
83-
await self.levels_cache.update(init_threshold_dict)
84-
logger.info("Filled levels cache with initial thresholds")
86+
await self.levels_cache.update(init_threshold_dict) # type: ignore[arg-type]
87+
logger.info("Filled levels cache with initial thresholds")
88+
89+
await self._refresh_sorted_thresholds()
8590

8691
if await self.running.get("value", False):
8792
logger.debug("Starting Rules and Point Renormalization tasks")
@@ -95,7 +100,7 @@ async def _load_rules(self) -> None:
95100
Load and parse levels rules for usage.
96101
97102
If a rule file does not comply with the format
98-
and throws and error, it is skipped over.
103+
and throws an error, it is skipped over.
99104
"""
100105
total_files_loaded = 0
101106
for toml_file in self.rules_folder_path.glob("*.toml"):
@@ -104,15 +109,19 @@ async def _load_rules(self) -> None:
104109

105110
rule_name = toml_file.stem
106111
try:
107-
rule_triggers = [RuleTrigger(**rule_trigger) for rule_trigger in rule_dict["rule"]]
108-
rule = LevelRules(rule_name, rule_triggers)
109-
except (TypeError, KeyError):
112+
rule_triggers = _rule_trigger_adapter.validate_python(rule_dict["rule"])
113+
rule = LevelRules(name=rule_name, rule_triggers=rule_triggers)
114+
except (KeyError, ValidationError):
110115
logger.info(f"{toml_file} not properly formatted, skipping.")
111116
continue
112117

113118
self.rules_all.append(rule)
114119
total_files_loaded += 1
115120

121+
self.all_message_rule_triggers = [
122+
rule_trigger for rule in self.rules_all
123+
for rule_trigger in rule.rule_triggers if rule_trigger.interaction_type == "message"
124+
]
116125
logger.info(f"Total rules loaded: {total_files_loaded}")
117126

118127
@tasks.loop(minutes=42.0)
@@ -121,7 +130,7 @@ async def _cycle_rules_task(self) -> None:
121130
Change which rules are currently active and anti-active.
122131
123132
Rules will statistically be used before a repeat is seen.
124-
This is not a guarnatee though.
133+
This is not a guarantee though.
125134
"""
126135
if len(self.rules_pool) < (self.active_rules_num + self.anti_active_rules_num):
127136
# If pool is empty, reshuffle completely to avoid activating same rule twice
@@ -134,29 +143,22 @@ async def _cycle_rules_task(self) -> None:
134143

135144
self.active_message_rule_triggers = [
136145
rule_trigger for rule in self.rules_active
137-
for rule_trigger in rule.rule_triggers if rule_trigger.interaction_type=="message"
146+
for rule_trigger in rule.rule_triggers if rule_trigger.interaction_type == "message"
138147
]
139148
self.active_reaction_rule_triggers = [
140149
rule_trigger for rule in self.rules_active
141-
for rule_trigger in rule.rule_triggers if rule_trigger.interaction_type=="reaction"
150+
for rule_trigger in rule.rule_triggers if rule_trigger.interaction_type == "reaction"
142151
]
143152

144153
self.anti_active_message_rule_triggers = [
145154
rule_trigger for rule in self.rules_anti_active
146-
for rule_trigger in rule.rule_triggers if rule_trigger.interaction_type=="message"
155+
for rule_trigger in rule.rule_triggers if rule_trigger.interaction_type == "message"
147156
]
148157
self.anti_active_reaction_rule_triggers = [
149158
rule_trigger for rule in self.rules_anti_active
150-
for rule_trigger in rule.rule_triggers if rule_trigger.interaction_type=="reaction"
159+
for rule_trigger in rule.rule_triggers if rule_trigger.interaction_type == "reaction"
151160
]
152161

153-
self.all_message_rule_triggers = [
154-
rule_trigger for rule in self.rules_all
155-
for rule_trigger in rule.rule_triggers if rule_trigger.interaction_type=="message"
156-
]
157-
# [rule for rule in self.rules_active if rule.interaction_type=="reaction"]
158-
# self.active_message_rule_triggers = [rule for rule in self.rules_active if rule.interaction_type=="message"]
159-
160162
@tasks.loop(minutes=90.0)
161163
async def _calculate_point_thresholds_task(self) -> None:
162164
"""
@@ -175,15 +177,22 @@ async def _calculate_point_thresholds_task(self) -> None:
175177
]
176178
else:
177179
# At the start of the event, just use multiples of 10 up to 100
178-
thresholds = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
180+
thresholds = [10 * i for i in range(1, len(LEVEL_ROLES) + 1)]
179181

180182
levels = await self.levels_cache.to_dict()
181183
new_levels = dict(zip(levels.keys(), thresholds, strict=False))
182-
await self.levels_cache.update(new_levels)
184+
await self.levels_cache.update(new_levels) # type: ignore[arg-type]
185+
await self._refresh_sorted_thresholds()
183186
logger.debug(f"Renormalizing score thresholds. Total scores: {len(all_scores)}")
184187
logger.debug(f"New thresholds: {thresholds}")
185188

186189

190+
async def _refresh_sorted_thresholds(self) -> None:
191+
"""Refresh the in-memory sorted threshold list from the levels cache."""
192+
levels = await self.levels_cache.to_dict()
193+
self.sorted_level_thresholds = sorted(levels.items(), key=operator.itemgetter(1))
194+
195+
187196
async def _update_points(self, user_id: int, points: int, halve_points: bool=False) -> None:
188197
"""Updates user's score and ensures correct role is assigned."""
189198
logger.debug(f"User {user_id} getting {points} points, halving override: {halve_points}.")
@@ -192,8 +201,7 @@ async def _update_points(self, user_id: int, points: int, halve_points: bool=Fal
192201
else:
193202
if points == 0 and not halve_points:
194203
return
195-
196-
current_points = await self.user_points_cache.get(user_id)
204+
current_points = cast(int, await self.user_points_cache.get(user_id, default=0))
197205
new_point_total = current_points + points
198206
if halve_points:
199207
new_point_total = new_point_total // 2
@@ -205,18 +213,32 @@ async def _update_points(self, user_id: int, points: int, halve_points: bool=Fal
205213

206214
async def _update_role_assignment(self, user_id: int) -> None:
207215
"""Updates user's role based on current points and role-point thresholds."""
208-
user_points = await self.user_points_cache.get(user_id)
209-
levels = await self.levels_cache.to_dict()
210-
level_to_assign = None
216+
user_points = cast(int, await self.user_points_cache.get(user_id, default=0))
217+
role_id_to_assign = None
211218

212-
for role, point_threshold in sorted(levels.items(), key=lambda item: item[1]):
213-
level_to_assign = role
219+
for role_id, point_threshold in self.sorted_level_thresholds:
220+
role_id_to_assign = role_id
214221
if point_threshold >= user_points:
215222
break
216223

224+
if role_id_to_assign is None:
225+
logger.error("levels_cache is empty, cannot assign a role.")
226+
return
227+
217228
guild = self.bot.get_guild(constants.Bot.guild)
218-
role = guild.get_role(level_to_assign)
229+
if guild is None:
230+
logger.error("Could not find guild, cannot assign a role.")
231+
return
232+
233+
role = guild.get_role(role_id_to_assign)
219234
user = await members.get_or_fetch_member(guild, user_id)
235+
if user is None:
236+
logger.debug(f"Could not find member {user_id} to assign role, skipping.")
237+
return
238+
if role is None:
239+
logger.error(f"Could not resolve role {role_id_to_assign} to assign to {user_id}.")
240+
return
241+
220242
roles_to_remove = [
221243
user_role for user_role in user.roles
222244
if user_role.id in LEVEL_ROLES and user_role != role
@@ -244,16 +266,14 @@ async def on_message(self, msg: discord.Message) -> None:
244266
total_points = 0
245267
rule_matches = 0
246268
for rule_trigger in self.active_message_rule_triggers:
247-
re_pattern = rule_trigger.message_content
248-
match = re.search(re_pattern, msg.content)
269+
match = re.search(rule_trigger.message_content, msg.content)
249270
if match:
250271
total_points += rule_trigger.points
251272
rule_matches += 1
252273

253274
anti_active_rule_matches = 0
254275
for anti_active_rule_trigger in self.anti_active_message_rule_triggers:
255-
re_pattern = anti_active_rule_trigger.message_content
256-
match = re.search(re_pattern, msg.content)
276+
match = re.search(anti_active_rule_trigger.message_content, msg.content)
257277
if match:
258278
anti_active_rule_matches += 1
259279
rule_matches += 1
@@ -268,18 +288,19 @@ async def on_message(self, msg: discord.Message) -> None:
268288

269289
total_rule_matches = 0
270290
for rule_trigger in self.all_message_rule_triggers:
271-
re_pattern = rule_trigger.message_content
272-
match = re.search(re_pattern, msg.content)
291+
match = re.search(rule_trigger.message_content, msg.content)
273292
if match:
274293
total_rule_matches += 1
294+
if total_rule_matches >= 3:
295+
break
275296
if total_rule_matches >= 3:
276297
await self._update_points(user_id, -5)
277298

278299

279300
@commands.Cog.listener()
280301
async def on_reaction_add(self, reaction: discord.Reaction, user: discord.Member) -> None:
281302
"""
282-
Listens for reactions and checks for against active reaction rules.
303+
Listens for reactions and checks against active reaction rules.
283304
284305
It will only listen for reactions added to messages within the bot's message cache.
285306
"""
@@ -344,10 +365,10 @@ async def shuffle_role_order(self, ctx: commands.Context) -> None:
344365
levels = await self.levels_cache.to_dict()
345366
thresholds = levels.values()
346367

347-
role_order = random.sample(LEVEL_ROLES, len(LEVEL_ROLES))
368+
role_order = random.sample(sorted(LEVEL_ROLES), len(LEVEL_ROLES))
348369
updated_ordering = dict(zip(role_order, thresholds, strict=False))
349370

350-
await self.levels_cache.update(updated_ordering)
371+
await self.levels_cache.update(updated_ordering) # type: ignore[arg-type]
351372
logger.info(f"Roles have been re-shuffled per request of {ctx.author.name}")
352373

353374
@levels_command_group.command()
@@ -386,36 +407,43 @@ async def status(self, ctx: commands.Context) -> None:
386407
if current_state:
387408
await ctx.reply(":white_check_mark: Levels is currently running.")
388409
else:
389-
await ctx.reply(":x: Levels is current **not** running.")
410+
await ctx.reply(":x: Levels is currently **not** running.")
390411

391412
@levels_command_group.command()
392413
@commands.has_any_role(*ELEVATED_ROLES)
393-
async def points_award(self, ctx: commands.Context, user_id: int, point_offset: int) -> None:
414+
async def points_award(self, ctx: commands.Context, member: discord.Member, point_offset: int) -> None:
394415
"""Edits the given user's current points value by the given point_offset."""
395-
current_points = await self.user_points_cache.get(user_id)
396-
user = await members.get_or_fetch_member(ctx.guild, user_id)
397-
await self._update_points(user_id, point_offset)
398-
await ctx.reply(f"Awarded {user} {point_offset} points. They now have {current_points+point_offset} points.")
416+
member_id = member.id
417+
current_points = cast(int, await self.user_points_cache.get(member_id, default=0))
418+
await self._update_points(member_id, point_offset)
419+
await ctx.reply(f"Awarded {member} {point_offset} points. They now have {current_points+point_offset} points.")
399420

400421
@levels_command_group.command()
401422
@commands.has_any_role(*ELEVATED_ROLES)
402-
async def role_reset(self, ctx: commands.Context, user_id: int) -> None:
423+
async def role_reset(self, ctx: commands.Context, member: discord.Member) -> None:
403424
"""Reset a given user's 'level' roles. Role will be re-applied at the next rule trigger."""
404-
await self._update_role_assignment(user_id)
405-
guild = self.bot.get_guild(constants.Bot.guild)
406-
user = await members.get_or_fetch_member(guild, user_id)
407-
await ctx.reply(f"Reset {user}'s roles.")
425+
member_id = member.id
426+
await self._update_role_assignment(member_id)
427+
await ctx.reply(f"Reset {member}'s roles.")
408428

409429
# Please see ./rules/README.md for how to format rules
410430

411-
@dataclass
412-
class RuleTrigger:
413-
interaction_type: Literal["message", "reaction"]
414-
reaction_content: list[str] | None = None
415-
message_content: str | None = None
431+
class MessageRuleTrigger(BaseModel):
432+
interaction_type: Literal["message"]
433+
message_content: str
416434
points: int = 0
417435

418-
@dataclass
419-
class LevelRules:
436+
class ReactionRuleTrigger(BaseModel):
437+
interaction_type: Literal["reaction"]
438+
reaction_content: list[str]
439+
points: int = 0
440+
441+
RuleTrigger = Annotated[
442+
MessageRuleTrigger | ReactionRuleTrigger,
443+
Field(discriminator="interaction_type"),
444+
]
445+
_rule_trigger_adapter = TypeAdapter(list[RuleTrigger])
446+
447+
class LevelRules(BaseModel):
420448
name: str
421449
rule_triggers: list[RuleTrigger]

0 commit comments

Comments
 (0)