Skip to content
201 changes: 200 additions & 1 deletion cogs/utility.py
Original file line number Diff line number Diff line change
Expand Up @@ -1408,7 +1408,7 @@ def _parse_level(name):

@permissions.command(name="override")
@checks.has_permissions(PermissionLevel.OWNER)
async def permissions_override(self, ctx, command_name: str.lower, *, level_name: str):
async def permissions_override(self, ctx, command_name: str.lower, *, level_name: str = None):
"""
Change a permission level for a specific command.

Expand All @@ -1422,8 +1422,16 @@ async def permissions_override(self, ctx, command_name: str.lower, *, level_name
- `{prefix}perms remove override reply`
- `{prefix}perms remove override plugin enabled`

You can also override multiple commands at once using:
- `{prefix}perms override bulk`

You can retrieve a single or all command level override(s), see`{prefix}help permissions get`.
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing space before {prefix}. The text should read "see {prefix}help permissions get" instead of "see{prefix}help permissions get".

Suggested change
You can retrieve a single or all command level override(s), see`{prefix}help permissions get`.
You can retrieve a single or all command level override(s), see `{prefix}help permissions get`.

Copilot uses AI. Check for mistakes.
"""
if command_name == "bulk":
Comment on lines +1426 to +1430
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded check for "bulk" prevents overriding a command named "bulk". If a plugin or custom command named "bulk" exists, users would not be able to override its permissions using the normal syntax. Consider using a less common reserved keyword (e.g., "--bulk" or "@@bulk") or implementing this as a separate command flag to avoid potential conflicts.

Suggested change
- `{prefix}perms override bulk`
You can retrieve a single or all command level override(s), see`{prefix}help permissions get`.
"""
if command_name == "bulk":
- `{prefix}perms override @@bulk`
You can retrieve a single or all command level override(s), see`{prefix}help permissions get`.
"""
if command_name == "@@bulk":

Copilot uses AI. Check for mistakes.
return await self._bulk_override_flow(ctx)

if level_name is None:
raise commands.MissingRequiredArgument(DummyParam("level_name"))

command = self.bot.get_command(command_name)
if command is None:
Expand Down Expand Up @@ -1458,6 +1466,197 @@ async def permissions_override(self, ctx, command_name: str.lower, *, level_name
)
return await ctx.send(embed=embed)

async def _bulk_override_flow(self, ctx):
message = None
embed = discord.Embed(
title="Bulk Override",
description=(
"Please list the commands you want to override. "
"You can list multiple commands separated by spaces or newlines.\n"
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The help text doesn't mention that commas can be used as separators, but the code handles them (line 1494 replaces commas with spaces). Update the description to mention that commas, spaces, and newlines can all be used as separators.

Suggested change
"You can list multiple commands separated by spaces or newlines.\n"
"You can list multiple commands separated by commas, spaces, or newlines.\n"

Copilot uses AI. Check for mistakes.
"Example: `reply, block, unblock`.\n"
),
color=self.bot.main_color,
)
await ctx.send(embed=embed)

try:
msg = await self.bot.wait_for(
"message",
check=lambda m: m.author == ctx.author and m.channel == ctx.channel,
timeout=120.0,
)

except asyncio.TimeoutError:
return await ctx.send(
embed=discord.Embed(title="Error", description="Timed out.", color=self.bot.error_color)
)

raw_commands = msg.content.replace(",", " ").replace("\n", " ").split(" ")
# Filter empty strings from split
raw_commands = [c for c in raw_commands if c.strip()]
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filter condition c.strip() checks if the string is non-empty after stripping, but doesn't actually strip the whitespace from the strings that are kept. This means command names with leading or trailing whitespace will be preserved, which could cause command lookups to fail. Consider changing to raw_commands = [c.strip() for c in raw_commands if c.strip()] to actually strip whitespace from the command names.

Copilot uses AI. Check for mistakes.

# Strip prefix from commands if present
prefixes = [self.bot.prefix, f"<@{self.bot.user.id}>", f"<@!{self.bot.user.id}>"]
if self.bot.prefix:
for i, cmd in enumerate(raw_commands):
for p in prefixes:
if cmd.startswith(p):
raw_commands[i] = cmd[len(p) :]
break
Comment on lines +1499 to +1505
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prefixes list includes self.bot.prefix which could be None or an empty string, but the prefix stripping only executes if self.bot.prefix is truthy. If self.bot.prefix is falsy, the mention prefixes (which are always valid) will not be stripped. The condition should check if the prefixes list has valid entries, or the list should only include non-empty prefixes.

Copilot uses AI. Check for mistakes.

# Filter empty strings again after stripping prefixes
raw_commands = [c for c in raw_commands if c.strip()]
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filter condition c.strip() checks if the string is non-empty after stripping, but doesn't actually strip the whitespace from the strings that are kept. This means command names with leading or trailing whitespace (from prefix stripping) will be preserved, which could cause command lookups to fail. Consider changing to raw_commands = [c.strip() for c in raw_commands if c.strip()] to actually strip whitespace from the command names.

Copilot uses AI. Check for mistakes.

found_commands = []
invalid_commands = []

for cmd_name in raw_commands:
cmd = self.bot.get_command(cmd_name)
if cmd:
found_commands.append(cmd)
else:
invalid_commands.append(cmd_name)

if invalid_commands:
description = f"The following commands were not found:\n`{', '.join(invalid_commands)}`\n\n"
if found_commands:
found_list = ", ".join(c.qualified_name for c in found_commands)
found_list = utils.return_or_truncate(found_list, 1000)
description += f"The following commands **were** found:\n`{found_list}`\n\n"

description += "Do you want to continue with the valid commands?"

embed = discord.Embed(
title="Invalid Commands Found",
description=description,
color=self.bot.error_color,
)
view = discord.ui.View()
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The View instance needs to initialize a value attribute before it's used. The AcceptButton and DenyButton callbacks set self.view.value, but this View doesn't have a value attribute initialized. This will cause an AttributeError when a button is clicked. Initialize the view's value attribute by either creating a custom View class (like ConfirmThreadCreationView) or by adding view.value = None after creating the View instance.

Suggested change
view = discord.ui.View()
view = discord.ui.View()
view.value = None

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

View instances don't have an explicit timeout set. Discord.py's default View timeout is 180 seconds, but for consistency with the message wait timeout (120 seconds on line 1486) and better user experience, consider setting an explicit timeout for the Views. For example: discord.ui.View(timeout=120.0) to match the initial message wait timeout.

Suggested change
view = discord.ui.View()
view = discord.ui.View(timeout=120.0)

Copilot uses AI. Check for mistakes.
view.add_item(utils.AcceptButton(custom_id="continue", emoji="✅"))
view.add_item(utils.DenyButton(custom_id="abort", emoji="❌"))

message = await ctx.send(embed=embed, view=view)
timed_out = await view.wait()

if timed_out or not view.value:
return await message.edit(
embed=discord.Embed(
title="Operation Aborted",
description="No changes have been applied.",
color=self.bot.error_color,
),
view=None,
)

if not found_commands:
return await ctx.send(
embed=discord.Embed(
title="Error",
description="No valid commands provided. Aborting.",
color=self.bot.error_color,
)
)

# Expand subcommands
final_commands = set()

def add_command_recursive(cmd):
final_commands.add(cmd)
if hasattr(cmd, "commands"):
for sub in cmd.commands:
add_command_recursive(sub)

for cmd in found_commands:
add_command_recursive(cmd)

embed = discord.Embed(
title="Select Permission Level",
description=(
f"Found {len(final_commands)} commands (including subcommands).\n"
"What permission level should these commands be set to?"
),
color=self.bot.main_color,
)

class LevelSelect(discord.ui.Select):
def __init__(self):
options = [
discord.SelectOption(label="Owner", value="OWNER"),
discord.SelectOption(label="Administrator", value="ADMINISTRATOR"),
discord.SelectOption(label="Moderator", value="MODERATOR"),
discord.SelectOption(label="Supporter", value="SUPPORTER"),
discord.SelectOption(label="Regular", value="REGULAR"),
]
super().__init__(placeholder="Select permission level...", options=options)

async def callback(self, interaction: discord.Interaction):
self.view.value = self.values[0]
self.view.stop()
await interaction.response.defer()
Comment on lines +1594 to +1595
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interaction response is deferred after stopping the view. The order should be reversed: defer the interaction response before stopping the view to ensure the interaction is properly acknowledged. Swap lines 1594 and 1595.

Suggested change
self.view.stop()
await interaction.response.defer()
await interaction.response.defer()
self.view.stop()

Copilot uses AI. Check for mistakes.

view = discord.ui.View()
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The View instance needs to initialize a value attribute before it's used. The LevelSelect callback sets self.view.value, but this View doesn't have a value attribute initialized. This will cause an AttributeError when the select menu is used. Initialize the view's value attribute by either creating a custom View class (like ConfirmThreadCreationView) or by adding view.value = None after creating the View instance.

Suggested change
view = discord.ui.View()
view = discord.ui.View()
view.value = None

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

View instances don't have an explicit timeout set. Discord.py's default View timeout is 180 seconds, but for consistency with the message wait timeout (120 seconds on line 1486) and better user experience, consider setting an explicit timeout for the Views. For example: discord.ui.View(timeout=120.0) to match the initial message wait timeout.

Suggested change
view = discord.ui.View()
view = discord.ui.View(timeout=120.0)

Copilot uses AI. Check for mistakes.
view.add_item(LevelSelect())

if message:
await message.edit(embed=embed, view=view)
else:
message = await ctx.send(embed=embed, view=view)
timed_out = await view.wait()

if timed_out or view.value is None:
return await message.edit(
embed=discord.Embed(title="Error", description="Timed out.", color=self.bot.error_color),
view=None,
)

level_name = view.value
level = self._parse_level(level_name)

# Confirmation
command_list_str = ", ".join(
f"`{c.qualified_name}`" for c in sorted(final_commands, key=lambda x: x.qualified_name)
)

command_list_str = utils.return_or_truncate(command_list_str, 2048)

embed = discord.Embed(
title="Confirm Bulk Override",
description=f"**Level:** {level.name}\n\n**Commands:**\n{command_list_str}",
color=self.bot.main_color,
)

view = discord.ui.View()
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The View instance needs to initialize a value attribute before it's used. The AcceptButton and DenyButton callbacks set self.view.value, but this View doesn't have a value attribute initialized. This will cause an AttributeError when a button is clicked. Initialize the view's value attribute by either creating a custom View class (like ConfirmThreadCreationView) or by adding view.value = None after creating the View instance.

Suggested change
view = discord.ui.View()
view = discord.ui.View()
view.value = None

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

View instances don't have an explicit timeout set. Discord.py's default View timeout is 180 seconds, but for consistency with the message wait timeout (120 seconds on line 1486) and better user experience, consider setting an explicit timeout for the Views. For example: discord.ui.View(timeout=120.0) to match the initial message wait timeout.

Suggested change
view = discord.ui.View()
view = discord.ui.View(timeout=120.0)

Copilot uses AI. Check for mistakes.
view.add_item(utils.AcceptButton(custom_id="confirm", emoji="✅"))
view.add_item(utils.DenyButton(custom_id="cancel", emoji="❌"))

await message.edit(embed=embed, view=view)
timed_out = await view.wait()

if timed_out or not view.value:
return await message.edit(
embed=discord.Embed(
title="Operation Aborted",
description="No changes have been applied.",
color=self.bot.error_color,
),
view=None,
)

# Apply changes
for cmd in final_commands:
self.bot.config["override_command_level"][cmd.qualified_name] = level.name

Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing logging for bulk override operations. The single command override (line 1453-1457) logs changes with logger.info, but the bulk override silently applies changes without logging. Consider adding logging to track what commands were updated and to which permission level, similar to the single override pattern.

Suggested change
logger.info(
"Bulk override: set permission level %s for %d commands: %s",
level.name,
len(final_commands),
", ".join(cmd.qualified_name for cmd in final_commands),
)

Copilot uses AI. Check for mistakes.
await self.bot.config.update()

await message.edit(
embed=discord.Embed(
title="Success",
description=f"Successfully updated permissions for {len(final_commands)} commands.",
color=self.bot.main_color,
),
view=None,
)

@permissions.command(name="add", usage="[command/level] [name] [user/role]")
@checks.has_permissions(PermissionLevel.OWNER)
async def permissions_add(
Expand Down
Loading