Skip to content

Commit b9ae30b

Browse files
authored
Implement permissions system (#50)
1 parent 9e50f32 commit b9ae30b

8 files changed

+144
-39
lines changed

bot/cogs/config.py

+7-4
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
import msgspec
88
from async_lru import alru_cache
99
from discord.ext import commands
10-
from libs.utils import GuildContext, is_manager
10+
from libs.utils import GuildContext
11+
from libs.utils.checks import bot_check_permissions, check_permissions
1112

1213
if TYPE_CHECKING:
1314
from rodhaj import Rodhaj
@@ -119,7 +120,8 @@ async def get_guild_config(self, guild_id: int) -> Optional[GuildConfig]:
119120
config = GuildConfig(bot=self.bot, **dict(rows))
120121
return config
121122

122-
@is_manager()
123+
@check_permissions(manage_guild=True)
124+
@bot_check_permissions(manage_channels=True, manage_webhooks=True)
123125
@commands.guild_only()
124126
@commands.hybrid_group(name="config")
125127
async def config(self, ctx: GuildContext) -> None:
@@ -253,8 +255,8 @@ async def setup(self, ctx: GuildContext, *, flags: SetupFlags) -> None:
253255
return
254256

255257
query = """
256-
INSERT INTO guild_config (id, category_id, ticket_channel_id, logging_channel_id, logging_broadcast_url, ticket_broadcast_url)
257-
VALUES ($1, $2, $3, $4, $5, $6);
258+
INSERT INTO guild_config (id, category_id, ticket_channel_id, logging_channel_id, logging_broadcast_url, ticket_broadcast_url, prefix)
259+
VALUES ($1, $2, $3, $4, $5, $6, $7);
258260
"""
259261
try:
260262
await self.pool.execute(
@@ -265,6 +267,7 @@ async def setup(self, ctx: GuildContext, *, flags: SetupFlags) -> None:
265267
logging_channel.id,
266268
lgc_webhook.url,
267269
tc_webhook.url,
270+
[],
268271
)
269272
except asyncpg.UniqueViolationError:
270273
await ticket_channel.delete(reason=delete_reason)

bot/cogs/tickets.py

+14-22
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from __future__ import annotations
22

3-
from functools import lru_cache
43
from typing import TYPE_CHECKING, Annotated, NamedTuple, Optional, Union
54

65
import asyncpg
@@ -14,6 +13,7 @@
1413
get_partial_ticket,
1514
safe_content,
1615
)
16+
from libs.utils.checks import bot_check_permissions
1717
from libs.utils.embeds import Embed, LoggingEmbed
1818

1919
from .config import GuildWebhookDispatcher
@@ -23,7 +23,6 @@
2323
from rodhaj import Rodhaj
2424

2525

26-
STAFF_ROLE = 1184257456419913798
2726
TICKET_EMOJI = "\U0001f3ab" # U+1F3AB Ticket
2827

2928

@@ -101,15 +100,6 @@ def add_status_checklist(
101100
) -> StatusChecklist:
102101
return self.in_progress_tickets.setdefault(author_id, status)
103102

104-
#### Determining staff
105-
106-
@lru_cache(maxsize=64)
107-
def get_staff(self, guild: discord.Guild) -> Optional[list[discord.Member]]:
108-
mod_role = guild.get_role(STAFF_ROLE)
109-
if mod_role is None:
110-
return None
111-
return [member for member in mod_role.members]
112-
113103
### Conditions for closing tickets
114104

115105
async def can_close_ticket(self, ctx: RoboContext):
@@ -128,22 +118,17 @@ async def can_close_ticket(self, ctx: RoboContext):
128118
return False
129119

130120
async def can_admin_close_ticket(self, ctx: RoboContext) -> bool:
131-
guild_id = self.bot.transprogrammer_guild_id
132-
guild = self.bot.get_guild(guild_id) or (await self.bot.fetch_guild(guild_id))
133-
staff_members = self.get_staff(guild)
134-
135-
if staff_members is None:
136-
return False
137-
138-
# TODO: Add the hierarchy system here
139-
staff_ids = [member.id for member in staff_members]
121+
# More than likely it will be closed through the threads
122+
# That means, it must be done in a guild. Thus, we know that
123+
# it will always be discord.Member
124+
perms = ctx.channel.permissions_for(ctx.author) # type: ignore
140125
from_ticket_channel = (
141126
isinstance(ctx.channel, discord.Thread)
142127
and ctx.partial_config is not None
143128
and ctx.channel.parent_id == ctx.partial_config.ticket_channel_id
144129
)
145130

146-
if ctx.author.id in staff_ids and from_ticket_channel is True:
131+
if perms.manage_threads and from_ticket_channel is True:
147132
return True
148133
return False
149134

@@ -319,11 +304,18 @@ def get_solved_tag(
319304

320305
### Feature commands
321306

307+
# This command requires the manage_threads permissions for the bot
322308
@is_ticket_or_dm()
309+
@bot_check_permissions(manage_threads=True)
323310
@commands.cooldown(1, 20, commands.BucketType.channel)
324311
@commands.hybrid_command(name="close", aliases=["solved", "closed", "resolved"])
325312
async def close(self, ctx: RoboContext) -> None:
326-
"""Closes the thread"""
313+
"""Closes a ticket
314+
315+
If someone requests to close the ticket
316+
and has Manage Threads permissions, then they can
317+
also close the ticket.
318+
"""
327319
query = """
328320
DELETE FROM tickets
329321
WHERE thread_id = $1 AND owner_id = $2;

bot/libs/utils/checks.py

+54-11
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
from __future__ import annotations
22

33
import os
4-
from typing import Callable, TypeVar
4+
from typing import TYPE_CHECKING, Callable, TypeVar
55

6+
import discord
67
from discord import app_commands
78
from discord.ext import commands
89

9-
T = TypeVar("T")
10+
# Although commands.HybridCommand (and it's group version) can be bound here for Type T,
11+
# it doesn't make sense as they are just subclasses of commands.Command and co.
12+
T = TypeVar("T", commands.Command, commands.Group)
13+
14+
if TYPE_CHECKING:
15+
from libs.utils.context import RoboContext
1016

1117

12-
# For time's sake I might as well take these from RDanny
13-
# There is really no used of creating my own system when there is one out there already
1418
async def check_guild_permissions(
15-
ctx: commands.Context, perms: dict[str, bool], *, check=all
19+
ctx: RoboContext, perms: dict[str, bool], *, check=all
1620
) -> bool:
1721
is_owner = await ctx.bot.is_owner(ctx.author)
1822
if is_owner:
@@ -27,9 +31,48 @@ async def check_guild_permissions(
2731
)
2832

2933

30-
def hybrid_permissions_check(**perms: bool) -> Callable[[T], T]:
31-
async def pred(ctx: commands.Context):
32-
return await check_guild_permissions(ctx, perms)
34+
async def check_bot_permissions(
35+
ctx: RoboContext, perms: dict[str, bool], *, check=all
36+
) -> bool:
37+
is_owner = await ctx.bot.is_owner(ctx.author)
38+
if is_owner:
39+
return True
40+
41+
if ctx.guild is None:
42+
return False
43+
44+
bot_resolved_perms = ctx.me.guild_permissions # type: ignore
45+
return check(
46+
getattr(bot_resolved_perms, name, None) == value
47+
for name, value in perms.items()
48+
)
49+
50+
51+
def check_permissions(**perms: bool) -> Callable[[T], T]:
52+
async def pred(ctx: RoboContext):
53+
# Usually means this is in the context of a DM
54+
if (
55+
isinstance(ctx.me, discord.ClientUser)
56+
or isinstance(ctx.author, discord.User)
57+
or ctx.guild is None
58+
):
59+
return False
60+
guild_perms = await check_guild_permissions(ctx, perms)
61+
can_run = ctx.me.top_role > ctx.author.top_role
62+
return guild_perms and can_run
63+
64+
def decorator(func: T) -> T:
65+
func.extras["permissions"] = perms
66+
commands.check(pred)(func)
67+
app_commands.default_permissions(**perms)(func)
68+
return func
69+
70+
return decorator
71+
72+
73+
def bot_check_permissions(**perms: bool) -> Callable[[T], T]:
74+
async def pred(ctx: RoboContext):
75+
return await check_bot_permissions(ctx, perms)
3376

3477
def decorator(func: T) -> T:
3578
commands.check(pred)(func)
@@ -40,17 +83,17 @@ def decorator(func: T) -> T:
4083

4184

4285
def is_manager():
43-
return hybrid_permissions_check(manage_guild=True)
86+
return check_permissions(manage_guild=True)
4487

4588

4689
def is_mod():
47-
return hybrid_permissions_check(
90+
return check_permissions(
4891
ban_members=True, manage_messages=True, kick_members=True, moderate_members=True
4992
)
5093

5194

5295
def is_admin():
53-
return hybrid_permissions_check(administrator=True)
96+
return check_permissions(administrator=True)
5497

5598

5699
def is_docker() -> bool:

bot/libs/utils/help.py

+37-2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,28 @@
1313
# Light Orange (255, 199, 184) - Used for command pages
1414

1515

16+
def process_perms_name(
17+
command: Union[commands.Group, commands.Command]
18+
) -> Optional[str]:
19+
merge_list = []
20+
if (
21+
all(isinstance(parent, commands.Group) for parent in command.parents)
22+
and len(command.parents) > 0
23+
):
24+
# See https://stackoverflow.com/a/27638751
25+
merge_list = [
26+
next(iter(parent.extras["permissions"])) for parent in command.parents
27+
]
28+
29+
if "permissions" in command.extras:
30+
merge_list.extend([*command.extras["permissions"]])
31+
32+
perms_set = sorted(set(merge_list))
33+
if len(perms_set) == 0:
34+
return None
35+
return ", ".join(name.replace("_", " ").title() for name in perms_set)
36+
37+
1638
class GroupHelpPageSource(menus.ListPageSource):
1739
def __init__(
1840
self,
@@ -27,10 +49,15 @@ def __init__(
2749
self.title: str = f"{self.group.qualified_name} Commands"
2850
self.description: str = self.group.description
2951

52+
def _process_description(self, group: Union[commands.Group, commands.Cog]):
53+
if isinstance(group, commands.Group) and "permissions" in group.extras:
54+
return f"{self.description}\n\n**Required Permissions**: {process_perms_name(group)}"
55+
return self.description
56+
3057
async def format_page(self, menu: RoboPages, commands: list[commands.Command]):
3158
embed = discord.Embed(
3259
title=self.title,
33-
description=self.description,
60+
description=self._process_description(self.group),
3461
colour=discord.Colour.from_rgb(197, 184, 255),
3562
)
3663

@@ -271,8 +298,16 @@ async def send_cog_help(self, cog):
271298
)
272299
await menu.start()
273300

274-
def common_command_formatting(self, embed_like, command):
301+
def common_command_formatting(
302+
self,
303+
embed_like: Union[discord.Embed, GroupHelpPageSource],
304+
command: commands.Command,
305+
):
275306
embed_like.title = self.get_command_signature(command)
307+
processed_perms = process_perms_name(command)
308+
if isinstance(embed_like, discord.Embed) and processed_perms is not None:
309+
embed_like.add_field(name="Required Permissions", value=processed_perms)
310+
276311
if command.description:
277312
embed_like.description = f"{command.description}\n\n{command.help}"
278313
else:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- Revision Version: V3
2+
-- Revises: V2
3+
-- Creation Date: 2024-01-23 08:41:08.795638 UTC
4+
-- Reason: perms and config changes
5+
6+
-- Remove this column as it was never used
7+
ALTER TABLE IF EXISTS guild_config DROP COLUMN locked;
8+
9+
-- Also in lieu with permissions based commands,
10+
-- we don't need to store perms levels on users
11+
ALTER TABLE IF EXISTS user_config DROP COLUMN permission_level;

bot/migrations/V4__custom_prefix.sql

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-- Revision Version: V4
2+
-- Revises: V3
3+
-- Creation Date: 2024-01-24 02:54:39.500620 UTC
4+
-- Reason: custom prefix support
5+
6+
-- Allow for custom prefixes to be stored. This is simply setup work
7+
-- for another feature
8+
ALTER TABLE IF EXISTS guild_config ADD COLUMN prefix TEXT[];
9+

bot/rodhaj.py

+3
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ def __init__(
4848
activity=discord.Activity(
4949
type=discord.ActivityType.watching, name="a game"
5050
),
51+
allowed_mentions=discord.AllowedMentions(
52+
everyone=False, replied_user=False
53+
),
5154
command_prefix=["r>", "?", "!"],
5255
help_command=RodhajHelp(),
5356
intents=intents,

permissions.md

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Required permissions for Rodhaj
2+
3+
This document serves to provide the necessary permissions
4+
that Rodhaj requires. Currently these are the required
5+
permissions:
6+
7+
- Manage Threads
8+
- Manage Channels
9+
- Manage Webhooks

0 commit comments

Comments
 (0)