1+ import operator
12import random
23import re
34import tomllib
4- from dataclasses import dataclass
55from pathlib import Path
6- from typing import Literal
6+ from typing import Annotated , Literal , cast
77
88import discord
99from async_rediscache import RedisCache
1010from discord .ext import commands , tasks
11+ from pydantic import BaseModel , Field , TypeAdapter , ValidationError
1112from pydis_core .utils .logging import get_logger
1213
1314from bot import constants
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
4445class 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