Skip to content

Commit 2becd56

Browse files
committed
Add API commands to get level/xp, dynamic command restrictions and cooldowns based on level.
1 parent f1c8541 commit 2becd56

File tree

6 files changed

+322
-8
lines changed

6 files changed

+322
-8
lines changed

levelup/commands/admin.py

Lines changed: 196 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,10 @@
44
from time import perf_counter
55

66
import discord
7+
from discord.ext.commands import cooldowns
78
from redbot.core import commands
89
from redbot.core.i18n import Translator, cog_i18n
9-
from redbot.core.utils.chat_formatting import (
10-
box,
11-
humanize_number,
12-
humanize_timedelta,
13-
pagify,
14-
)
10+
from redbot.core.utils.chat_formatting import box, humanize_number, humanize_timedelta, pagify, error, warning, info
1511

1612
from ..abc import MixinMeta
1713
from ..common import const, utils
@@ -29,6 +25,200 @@ async def levelset(self, ctx: commands.Context):
2925
"""Configure LevelUp Settings"""
3026
pass
3127

28+
@levelset.group(name="bypass")
29+
async def levelset_bypass(self, ctx: commands.Context):
30+
"""Set roles/members that bypass level requirement and cooldowns set"""
31+
pass
32+
33+
@levelset_bypass.command(name="member")
34+
async def levelset_bypass_member(self, ctx: commands.Context, member: t.Optional[discord.Member] = None):
35+
"""
36+
Add a member to the bypass list.
37+
38+
Run with no arguments to see all the bypass members
39+
Run with a member already in the list to remove them from the list
40+
"""
41+
conf = self.db.get_conf(ctx.guild)
42+
if member is None:
43+
if not conf.cmd_bypass_member:
44+
await ctx.reply(
45+
info("No members configured for bypassing command restrictions."),
46+
delete_after=30,
47+
mention_author=False,
48+
)
49+
return
50+
text = "# Bypass Members:\n"
51+
for member_id in conf.cmd_bypass_member.copy():
52+
member = ctx.guild.get_member(member_id)
53+
if member is None: # member left, remove from conf
54+
conf.cmd_bypass_member.remove(member_id)
55+
continue
56+
text += f"- {member.mention}\n"
57+
pages = list(pagify(text))
58+
await ctx.send_interactive(pages)
59+
return
60+
if member.id not in conf.cmd_bypass_member:
61+
conf.cmd_bypass_member.append(member.id)
62+
else:
63+
conf.cmd_bypass_member.remove(member.id)
64+
await ctx.reply(
65+
info(f"Member {member.mention} removed from command bypasses."), delete_after=30, mention_author=False
66+
)
67+
await ctx.tick()
68+
69+
@levelset_bypass.command(name="role")
70+
async def levelset_bypass_role(self, ctx: commands.Context, role: t.Optional[discord.Role] = None):
71+
"""
72+
Add a role to the bypass list.
73+
74+
Run with no arguments to see all the bypass roles
75+
Run with a role already in the list to remove it from the list
76+
"""
77+
conf = self.db.get_conf(ctx.guild)
78+
if role is None:
79+
if not conf.cmd_bypass_roles:
80+
await ctx.reply(
81+
info("No roles configured for bypassing command restrictions."),
82+
delete_after=30,
83+
mention_author=False,
84+
)
85+
return
86+
text = "# Bypass Roles:\n"
87+
for role_id in conf.cmd_bypass_roles.copy():
88+
role = ctx.guild.get_role(role_id)
89+
if role is None: # role gone, remove from conf
90+
conf.cmd_bypass_roles.remove(role_id)
91+
continue
92+
text += f"- {role.mention}\n"
93+
pages = list(pagify(text))
94+
await ctx.send_interactive(pages)
95+
return
96+
if role.id not in conf.cmd_bypass_roles:
97+
conf.cmd_bypass_roles.append(role.id)
98+
else:
99+
conf.cmd_bypass_roles.remove(role.id)
100+
await ctx.reply(
101+
info(f"Role {role.mention} removed from command bypasses."),
102+
delete_after=30,
103+
mention_author=False,
104+
)
105+
await ctx.tick()
106+
107+
@levelset.group(name="cooldowns")
108+
async def levelset_cooldowns(self, ctx: commands.Context):
109+
"""Manage per level command cooldowns"""
110+
pass
111+
112+
@levelset_cooldowns.command(name="add")
113+
async def levelset_cooldowns_add(self, ctx: commands.Context, level: int, cooldown: int, *, command: str):
114+
"""
115+
Add a cooldown for a command based on level
116+
Multiple cooldown levels can be set, the cooldown will be applied to members at the specified level and under
117+
118+
**Warning:** This will override any default cooldowns for the command
119+
120+
Example:
121+
[p]lset cooldowns add 5 15 mycommand
122+
[p]lset cooldowns add 10 5 mycommand
123+
Members who are level [0, 5] will have a cooldown of 15 seconds for mycommand (including members at level 5)
124+
Members who are level (5, 10] will have a cooldown of 5 seconds
125+
Members above level 10 will have no cooldown
126+
"""
127+
if self.bot.get_command(command) is None:
128+
return await ctx.reply(error(f"Invalid command: `{command}`"), delete_after=30, mention_author=False)
129+
conf = self.db.get_conf(ctx.guild)
130+
command_cooldowns = conf.cmd_cooldowns.get(command, {})
131+
command_cooldowns[level] = cooldown
132+
conf.cmd_cooldowns[command] = command_cooldowns
133+
self.save()
134+
await ctx.tick()
135+
136+
@levelset_cooldowns.command(name="del")
137+
async def levelset_cooldowns_del(self, ctx: commands.Context, level: int, *, command: str):
138+
"""Delete a cooldown for a specific command and level"""
139+
if self.bot.get_command(command) is None:
140+
return await ctx.reply(error(f"Invalid command: `{command}`"), delete_after=30, mention_author=False)
141+
conf = self.db.get_conf(ctx.guild)
142+
command_cooldowns = conf.cmd_cooldowns.get(command, {})
143+
if not command_cooldowns:
144+
return await ctx.reply(
145+
warning(f"No cooldowns are set for `{command}`"), delete_after=30, mention_author=False
146+
)
147+
if level not in command_cooldowns:
148+
return await ctx.reply(
149+
warning(f"There is no cooldown for level {level}"), delete_after=30, mention_author=False
150+
)
151+
del command_cooldowns[level]
152+
conf.cmd_cooldowns[command] = command_cooldowns
153+
if command_cooldowns == {}:
154+
del conf.cmd_cooldowns[command]
155+
156+
self.save()
157+
await ctx.tick()
158+
159+
@levelset_cooldowns.command(name="list")
160+
async def levelset_cooldowns_list(self, ctx: commands.Context):
161+
"""List cooldowns for all commands"""
162+
conf = self.db.get_conf(ctx.guild)
163+
cmds = conf.cmd_cooldowns
164+
if not cmds:
165+
await ctx.send(info("No commands configured."))
166+
return
167+
168+
msg = f"# Cooldowns for {ctx.guild.name}\n"
169+
for cmd, cooldowns in cmds.items():
170+
msg += f"- `{cmd}`:\n"
171+
for level, cooldown in cooldowns.items():
172+
msg += f" - Level `{level}`: `{humanize_timedelta(seconds=cooldown)}`\n"
173+
174+
for page in pagify(msg):
175+
await ctx.send(page)
176+
177+
@levelset.group(name="lvlreq")
178+
async def levelset_lvlreq(self, ctx: commands.Context):
179+
"""Manage level requirement for commands"""
180+
pass
181+
182+
@levelset_lvlreq.command(name="add")
183+
async def levelset_lvlreq_add(self, ctx: commands.Context, level: int, *, command: str):
184+
"""Add a level requirement to a command."""
185+
if self.bot.get_command(command) is None:
186+
return await ctx.reply(error(f"Invalid command: `{command}`"), delete_after=30, mention_author=False)
187+
conf = self.db.get_conf(ctx.guild)
188+
conf.cmd_requirements[command] = level
189+
self.save()
190+
await ctx.tick()
191+
192+
@levelset_lvlreq.command(name="del")
193+
async def levelset_lvlreq_del(self, ctx: commands.Context, *, command: str):
194+
"""Delete a level requirement for a command."""
195+
conf = self.db.get_conf(ctx.guild)
196+
if command not in conf.cmd_requirements:
197+
return await ctx.reply(
198+
warning(f"No level requirement was set for `{command}`"),
199+
delete_after=30,
200+
mention_author=False,
201+
)
202+
del conf.cmd_requirements[command]
203+
self.save()
204+
await ctx.tick()
205+
206+
@levelset_lvlreq.command(name="list")
207+
async def levelset_lvlreq_list(self, ctx: commands.Context):
208+
"""List all command level requirements"""
209+
conf = self.db.get_conf(ctx.guild)
210+
cmds = conf.cmd_requirements
211+
if not cmds:
212+
await ctx.send(info("No commands configured."))
213+
return
214+
215+
msg = f"# Command Level Requirements for {ctx.guild.name}\n"
216+
for cmd, level in cmds.items():
217+
msg += f"- `{cmd}`: `{level}`\n"
218+
219+
for page in pagify(msg):
220+
await ctx.send(page)
221+
32222
@levelset.command(name="view")
33223
@commands.bot_has_permissions(embed_links=True)
34224
async def view_settings(self, ctx: commands.Context):

levelup/common/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,13 @@ class GuildSettings(Base):
281281
cooldown: int = 60 # Only gives XP every 60 seconds
282282
min_length: int = 0 # Minimum length of message to be considered eligible for XP gain
283283

284+
# command checks and cooldown based on level
285+
cmd_requirements: t.Dict[str, int] = {} # command name -> level requirement
286+
# command name -> Dict[level, cooldown], levels at specified level and under will be under that cooldown
287+
cmd_cooldowns: t.Dict[str, t.Dict[int, int]] = {}
288+
cmd_bypass_roles: t.List[int] = [] # allow roles to bypass req level and cooldown
289+
cmd_bypass_member: t.List[int] = [] # allow members to bypass req level and cooldown
290+
284291
# Voice
285292
voicexp: int = 2 # XP per minute in voice
286293
ignore_muted: bool = True # Ignore XP while being muted in voice

levelup/main.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ async def cog_load(self) -> None:
131131

132132
async def cog_unload(self) -> None:
133133
self.bot.tree.remove_command(view_profile_context)
134+
self.bot.remove_before_invoke_hook(self.level_check)
135+
self.bot.remove_before_invoke_hook(self.cooldown_check)
134136
self.stop_levelup_tasks()
135137

136138
async def start_api(self) -> bool:
@@ -245,6 +247,10 @@ async def initialize(self) -> None:
245247
if voice_initialized := await self.initialize_voice_states():
246248
log.info(f"Initialized {voice_initialized} voice states")
247249

250+
# add checks
251+
self.bot.before_invoke(self.level_check)
252+
self.bot.before_invoke(self.cooldown_check)
253+
248254
self.start_levelup_tasks()
249255
self.custom_fonts.mkdir(exist_ok=True)
250256
self.custom_backgrounds.mkdir(exist_ok=True)

levelup/shared/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
from .levelups import LevelUps
33
from .profile import ProfileFormatting
44
from .weeklyreset import WeeklyReset
5+
from .checks import Checks
56

67

7-
class SharedFunctions(LevelUps, ProfileFormatting, WeeklyReset, metaclass=CompositeMetaClass):
8+
class SharedFunctions(LevelUps, ProfileFormatting, WeeklyReset, Checks, metaclass=CompositeMetaClass):
89
"""
910
Subclass all shared metaclassed parts of the cog
1011

levelup/shared/checks.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import logging
2+
import bisect
3+
import typing as t
4+
5+
import discord
6+
from redbot.core.i18n import Translator
7+
from redbot.core.utils.chat_formatting import warning
8+
from redbot.core import commands
9+
10+
from ..abc import MixinMeta
11+
12+
log = logging.getLogger("red.vrt.levelup.shared.checks")
13+
_ = Translator("LevelUp", __file__)
14+
15+
16+
class Checks(MixinMeta):
17+
def __init__(self):
18+
self.last_invoked: t.Dict[t.Tuple[int, str], float] = {} # (member id, command) -> last usage time
19+
20+
async def level_check(self, ctx: commands.Context) -> bool:
21+
if not ctx.guild:
22+
return True
23+
member = ctx.author
24+
if isinstance(member, discord.User):
25+
return True
26+
conf = self.db.get_conf(member.guild)
27+
command = ctx.command.qualified_name
28+
if command not in conf.cmd_requirements:
29+
return True
30+
31+
# allow help command
32+
if ctx.command.name == "help" or ctx.invoked_with == "help":
33+
return True
34+
35+
# check bypasses
36+
bypass_roles = set([r.id for r in member.roles]) & set(conf.cmd_bypass_roles)
37+
if member.id in conf.cmd_bypass_member or len(bypass_roles) >= 1:
38+
return True
39+
40+
profile = conf.get_profile(member)
41+
level = profile.level
42+
req_level = conf.cmd_requirements[command]
43+
if level < req_level:
44+
await ctx.reply(
45+
warning(f"You need level `{req_level}` to use `{ctx.command}`. (current level: {level})"),
46+
delete_after=30,
47+
mention_author=False,
48+
)
49+
raise commands.CheckFailure()
50+
return True
51+
52+
async def cooldown_check(self, ctx: commands.Context) -> bool:
53+
if not ctx.guild:
54+
return True
55+
member = ctx.author
56+
if isinstance(member, discord.User):
57+
return True
58+
conf = self.db.get_conf(member.guild)
59+
command = ctx.command.qualified_name
60+
if command not in conf.cmd_cooldowns:
61+
return True
62+
63+
# check bypasses
64+
bypass_roles = set([r.id for r in member.roles]) & set(conf.cmd_bypass_roles)
65+
if member.id in conf.cmd_bypass_member or len(bypass_roles) >= 1:
66+
return True
67+
68+
cooldowns = conf.cmd_cooldowns[command]
69+
levels = sorted(cooldowns.keys())
70+
profile = conf.get_profile(member)
71+
level = profile.level
72+
73+
cooldown_level = bisect.bisect_left(levels, level)
74+
if cooldown_level == len(
75+
levels
76+
): # user has a higher level then all of the specified cooldowns, so no cooldown is applied
77+
return True
78+
cooldown = cooldowns[levels[cooldown_level]]
79+
80+
key = (member.id, command)
81+
now = ctx.message.created_at.timestamp()
82+
last = self.last_invoked.get(key, 0)
83+
retry_after = last + cooldown - now
84+
85+
if retry_after > 0:
86+
bucket_cooldown = commands.Cooldown(1, cooldown)
87+
raise commands.CommandOnCooldown(bucket_cooldown, retry_after, commands.BucketType.member)
88+
self.last_invoked[key] = now
89+
# override any built in cooldowns for the command
90+
ctx.command.reset_cooldown(ctx)
91+
92+
return True

levelup/shared/profile.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,22 @@ async def remove_xp(self, member: discord.Member, xp: int) -> int:
5252
self.save()
5353
return int(profile.xp)
5454

55+
async def get_xp(self, member: discord.Member) -> int:
56+
"""Get the XP for a member"""
57+
if not isinstance(member, discord.Member):
58+
raise TypeError("member must be a discord.Member")
59+
conf = self.db.get_conf(member.guild)
60+
profile = conf.get_profile(member)
61+
return int(profile.xp)
62+
63+
async def get_level(self, member: discord.Member) -> int:
64+
"""Get the level for a member"""
65+
if not isinstance(member, discord.Member):
66+
raise TypeError("member must be a discord.Member")
67+
conf = self.db.get_conf(member.guild)
68+
profile = conf.get_profile(member)
69+
return profile.level
70+
5571
async def get_profile_background(
5672
self, user_id: int, profile: Profile, try_return_url: bool = False
5773
) -> t.Union[bytes, str]:
@@ -108,7 +124,9 @@ async def get_banner(self, user_id: int) -> t.Optional[str]:
108124
return f"https://cdn.discordapp.com/banners/{user_id}/{banner_id}?size=1024"
109125

110126
async def get_user_profile(
111-
self, member: discord.Member, reraise: bool = False
127+
self,
128+
member: discord.Member,
129+
reraise: bool = False,
112130
) -> t.Union[discord.Embed, discord.File]:
113131
"""
114132
Get a user's profile as an embed or file

0 commit comments

Comments
 (0)