Skip to content

Commit e60baa3

Browse files
Merge branch 'main' into docs-updates
2 parents 7b7d944 + 5ceb83f commit e60baa3

5 files changed

Lines changed: 438 additions & 119 deletions

File tree

.pre-commit-config.yaml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ repos:
1111
- id: hadolint
1212

1313
- repo: https://github.com/renovatebot/pre-commit-hooks
14-
rev: 39.205.1
14+
rev: 39.227.2
1515
hooks:
1616
- id: renovate-config-validator
1717
args: [--strict]
1818
stages: [manual]
1919

2020
- repo: https://github.com/python-jsonschema/check-jsonschema
21-
rev: 0.31.3
21+
rev: 0.32.1
2222
hooks:
2323
- id: check-github-workflows
2424

@@ -29,7 +29,7 @@ repos:
2929
- id: gitlint-ci
3030

3131
- repo: https://github.com/gitleaks/gitleaks
32-
rev: v8.24.0
32+
rev: v8.24.2
3333
hooks:
3434
- id: gitleaks
3535

@@ -74,15 +74,15 @@ repos:
7474
stages: [manual]
7575

7676
- repo: https://github.com/astral-sh/ruff-pre-commit
77-
rev: v0.11.0
77+
rev: v0.11.2
7878
hooks:
7979
- id: ruff
8080
args: [--fix]
8181

8282
- id: ruff-format
8383

8484
- repo: https://github.com/astral-sh/uv-pre-commit
85-
rev: 0.6.6
85+
rev: 0.6.11
8686
hooks:
8787
- id: uv-lock
8888
always_run: true

cogs/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from typing import TYPE_CHECKING
99

10+
from .add_users_to_threads_and_channels import AddUsersToThreadsAndChannelsCommandCog
1011
from .annual_handover_and_reset import (
1112
AnnualRolesResetCommandCog,
1213
AnnualYearChannelsIncrementCommandCog,
@@ -46,6 +47,7 @@
4647
from utils import TeXBot, TeXBotBaseCog
4748

4849
__all__: "Sequence[str]" = (
50+
"AddUsersToThreadsAndChannelsCommandCog",
4951
"AnnualRolesResetCommandCog",
5052
"AnnualYearChannelsIncrementCommandCog",
5153
"ArchiveCommandCog",
@@ -84,6 +86,7 @@
8486
def setup(bot: "TeXBot") -> None:
8587
"""Add all the cogs to the bot, at bot startup."""
8688
cogs: Iterable[type[TeXBotBaseCog]] = (
89+
AddUsersToThreadsAndChannelsCommandCog,
8790
AnnualRolesResetCommandCog,
8891
AnnualYearChannelsIncrementCommandCog,
8992
ArchiveCommandCog,
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
"""Contains Cog classes for adding users and roles to threads."""
2+
3+
import logging
4+
from collections.abc import Iterable
5+
from typing import TYPE_CHECKING
6+
7+
import discord
8+
9+
from config import settings
10+
from exceptions import GuestRoleDoesNotExistError, GuildDoesNotExistError
11+
from utils import TeXBotBaseCog
12+
from utils.error_capture_decorators import capture_guild_does_not_exist_error
13+
14+
if TYPE_CHECKING:
15+
from collections.abc import Sequence
16+
from collections.abc import Set as AbstractSet
17+
from logging import Logger
18+
from typing import Final
19+
20+
from utils import TeXBotApplicationContext, TeXBotAutocompleteContext
21+
22+
23+
__all__: "Sequence[str]" = ("AddUsersToThreadsAndChannelsCommandCog",)
24+
25+
26+
logger: "Final[Logger]" = logging.getLogger("TeX-Bot")
27+
28+
29+
class AddUsersToThreadsAndChannelsCommandCog(TeXBotBaseCog):
30+
"""Cog for adding users to threads."""
31+
32+
@staticmethod
33+
async def autocomplete_get_members(
34+
ctx: "TeXBotAutocompleteContext",
35+
) -> "AbstractSet[discord.OptionChoice] | AbstractSet[str]":
36+
"""Autocomplete callable that generates the set of available selectable members."""
37+
try:
38+
main_guild: discord.Guild = ctx.bot.main_guild
39+
guest_role: discord.Role = await ctx.bot.guest_role
40+
except (GuildDoesNotExistError, GuestRoleDoesNotExistError):
41+
return set()
42+
43+
members: set[discord.Member] = {
44+
member
45+
for member in main_guild.members
46+
if not member.bot and guest_role in member.roles
47+
}
48+
49+
if not ctx.value or ctx.value.startswith("@"):
50+
return {
51+
discord.OptionChoice(name=f"@{member.name}", value=str(member.id))
52+
for member in members
53+
}
54+
55+
return {
56+
discord.OptionChoice(name=member.name, value=str(member.id)) for member in members
57+
}
58+
59+
@staticmethod
60+
async def autocomplete_get_roles(
61+
ctx: "TeXBotAutocompleteContext",
62+
) -> "AbstractSet[discord.OptionChoice] | AbstractSet[str]":
63+
"""Autocomplete callable that generates the set of available selectable roles."""
64+
try:
65+
main_guild: discord.Guild = ctx.bot.main_guild
66+
except GuildDoesNotExistError:
67+
return set()
68+
69+
if not ctx.value or ctx.value.startswith("@"):
70+
return {
71+
discord.OptionChoice(name=f"@{role.name}", value=str(role.id))
72+
for role in main_guild.roles
73+
}
74+
75+
return {
76+
discord.OptionChoice(name=role.name, value=str(role.id))
77+
for role in main_guild.roles
78+
}
79+
80+
async def add_users_or_roles_silently(
81+
self,
82+
users_or_roles: discord.Member
83+
| discord.Role
84+
| Iterable[discord.Member]
85+
| Iterable[discord.Role],
86+
channel_or_thread: discord.Thread | discord.TextChannel,
87+
) -> None:
88+
"""Add a user or role to a thread without pinging them."""
89+
if isinstance(users_or_roles, Iterable):
90+
user_or_role: discord.Role | discord.Member
91+
for user_or_role in users_or_roles:
92+
await self.add_users_or_roles_silently(
93+
users_or_roles=user_or_role, channel_or_thread=channel_or_thread
94+
)
95+
return
96+
97+
if isinstance(channel_or_thread, discord.Thread):
98+
message: discord.Message = await channel_or_thread.send(
99+
content=f"Adding {users_or_roles!r} to thread...",
100+
silent=True,
101+
)
102+
await message.edit(content=f"{users_or_roles.mention}")
103+
await message.delete(delay=1)
104+
return
105+
106+
await channel_or_thread.set_permissions(
107+
target=users_or_roles,
108+
read_messages=True,
109+
send_messages=True,
110+
reason=f"User {self.bot.user} used TeX-Bot slash-command `add_users_to_channel`.",
111+
)
112+
113+
async def add_users_or_roles_with_ping(
114+
self,
115+
users_or_roles: discord.Member
116+
| discord.Role
117+
| Iterable[discord.Member]
118+
| Iterable[discord.Role],
119+
channel_or_thread: discord.Thread | discord.TextChannel,
120+
) -> None:
121+
"""Add a user or role to a thread and ping them."""
122+
if isinstance(users_or_roles, Iterable):
123+
user_or_role: discord.Role | discord.Member
124+
for user_or_role in users_or_roles:
125+
await self.add_users_or_roles_with_ping(
126+
users_or_roles=user_or_role, channel_or_thread=channel_or_thread
127+
)
128+
return
129+
130+
if isinstance(channel_or_thread, discord.Thread):
131+
if isinstance(users_or_roles, discord.Member):
132+
try:
133+
await channel_or_thread.add_user(user=users_or_roles)
134+
except discord.NotFound:
135+
logger.debug(
136+
"User: %s has blocked the bot and "
137+
"therefore could not be added to thread: %s.",
138+
users_or_roles,
139+
channel_or_thread,
140+
)
141+
return
142+
143+
member: discord.Member
144+
for member in users_or_roles.members:
145+
try:
146+
await channel_or_thread.add_user(member)
147+
except discord.NotFound:
148+
logger.debug(
149+
"User: %s has blocked the bot and "
150+
"therefore could not be added to thread: %s.",
151+
member,
152+
channel_or_thread,
153+
)
154+
return
155+
156+
await channel_or_thread.set_permissions(
157+
target=users_or_roles,
158+
read_messages=True,
159+
send_messages=True,
160+
reason=f"User {self.bot.user} used TeX-Bot slash-command `add_users_to_channel`.",
161+
)
162+
163+
await channel_or_thread.send(
164+
content=f"{users_or_roles.mention} has been added to the channel.",
165+
)
166+
167+
@TeXBotBaseCog.listener()
168+
@capture_guild_does_not_exist_error
169+
async def on_thread_create(self, thread: discord.Thread) -> None:
170+
"""Add users to a thread when it is created."""
171+
# NOTE: Shortcut accessors are placed at the top of the function, so that the exceptions they raise are displayed before any further errors may be sent
172+
committee_role: discord.Role = await self.bot.committee_role
173+
committee_elect_role: discord.Role = await self.bot.committee_elect_role
174+
175+
if (
176+
thread.parent is None
177+
or thread.parent.category is None
178+
or "committee" not in thread.parent.category.name.lower()
179+
or not settings["AUTO_ADD_COMMITTEE_TO_THREADS"]
180+
):
181+
return
182+
183+
await self.add_users_or_roles_silently(
184+
users_or_roles=(committee_role, committee_elect_role), channel_or_thread=thread
185+
)
186+
187+
@discord.slash_command( # type: ignore[no-untyped-call, misc]
188+
name="add_users_to_channel",
189+
description="Adds selected users to a channel or thread.",
190+
)
191+
@discord.option( # type: ignore[no-untyped-call, misc]
192+
name="user",
193+
description="The user to add to the channel.",
194+
input_type=str,
195+
autocomplete=discord.utils.basic_autocomplete(autocomplete_get_members), # type: ignore[arg-type]
196+
required=True,
197+
parameter_name="user_id_str",
198+
)
199+
@discord.option( # type: ignore[no-untyped-call, misc]
200+
name="silent",
201+
description="Whether the users being added should be pinged or not.",
202+
input_type=bool,
203+
required=False,
204+
parameter_name="silent",
205+
)
206+
async def add_user_to_channel( # type: ignore[misc]
207+
self,
208+
ctx: "TeXBotApplicationContext",
209+
user_id_str: str,
210+
silent: bool, # noqa: FBT001
211+
) -> None:
212+
"""Add users or roles to a channel."""
213+
if not isinstance(ctx.channel, (discord.TextChannel, discord.Thread)):
214+
await self.command_send_error(
215+
ctx=ctx,
216+
message="This command currently only supports text channels or threads.",
217+
)
218+
return
219+
220+
try:
221+
user_to_add: discord.Member = await self.bot.get_member_from_str_id(user_id_str)
222+
except ValueError:
223+
logger.debug("User ID: %s is not a valid ID.", user_id_str)
224+
await ctx.respond(content=f"The user: {user_id_str} is not valid.")
225+
return
226+
227+
if silent:
228+
await self.add_users_or_roles_silently(user_to_add, ctx.channel)
229+
else:
230+
await self.add_users_or_roles_with_ping(user_to_add, ctx.channel)
231+
232+
await ctx.respond(
233+
content=(
234+
f"Successfully added {user_to_add.mention} "
235+
f"to the channel: {ctx.channel.mention}."
236+
)
237+
)
238+
239+
@discord.slash_command( # type: ignore[no-untyped-call, misc]
240+
name="add_role_to_channel",
241+
description="Adds the selected role and it's users to a channel or thread.",
242+
)
243+
@discord.option( # type: ignore[no-untyped-call, misc]
244+
name="role",
245+
description="The role to add to the channel.",
246+
input_type=str,
247+
autocomplete=discord.utils.basic_autocomplete(autocomplete_get_roles), # type: ignore[arg-type]
248+
required=True,
249+
parameter_name="role_id_str",
250+
)
251+
@discord.option( # type: ignore[no-untyped-call, misc]
252+
name="silent",
253+
description="Whether the users being added should be pinged or not.",
254+
input_type=bool,
255+
required=False,
256+
parameter_name="silent",
257+
)
258+
async def add_role_to_channel( # type: ignore[misc]
259+
self,
260+
ctx: "TeXBotApplicationContext",
261+
role_id_str: str,
262+
silent: bool, # noqa: FBT001
263+
) -> None:
264+
"""Command to add a role to a channel."""
265+
if not isinstance(ctx.channel, discord.Thread) and not isinstance(
266+
ctx.channel, discord.TextChannel
267+
):
268+
await self.command_send_error(
269+
ctx=ctx, message="This command can only be used in a text channel or thread."
270+
)
271+
return
272+
273+
main_guild: discord.Guild = ctx.bot.main_guild
274+
275+
try:
276+
role_id: int = int(role_id_str)
277+
except ValueError:
278+
logger.debug("Role ID: %s is not a valid ID.", role_id_str)
279+
await ctx.respond(content=f"The role: {role_id_str} is not valid.")
280+
return
281+
282+
role_to_add: discord.Role | None = discord.utils.get(main_guild.roles, id=role_id)
283+
284+
if role_to_add is None:
285+
await self.command_send_error(
286+
ctx=ctx,
287+
message=f"The role: <@{role_id}> is not valid or couldn't be found.",
288+
)
289+
return
290+
291+
if silent:
292+
await self.add_users_or_roles_silently(role_to_add, ctx.channel)
293+
else:
294+
await self.add_users_or_roles_with_ping(role_to_add, ctx.channel)
295+
296+
await ctx.respond(
297+
content=f"Role {role_to_add.mention} has been added to the channel.",
298+
ephemeral=True,
299+
)

config.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,22 @@ def _setup_strike_performed_manually_warning_location(cls) -> None:
678678
raw_strike_performed_manually_warning_location
679679
)
680680

681+
@classmethod
682+
def _setup_auto_add_committee_to_threads(cls) -> None:
683+
raw_auto_add_committee_to_threads: str = str(
684+
os.getenv("AUTO_ADD_COMMITTEE_TO_THREADS", "True")
685+
).lower()
686+
687+
if raw_auto_add_committee_to_threads not in TRUE_VALUES | FALSE_VALUES:
688+
INVALID_AUTO_ADD_COMMITTEE_TO_THREADS_MESSAGE: Final[str] = (
689+
"AUTO_ADD_COMMITTEE_TO_THREADS must be a boolean value."
690+
)
691+
raise ImproperlyConfiguredError(INVALID_AUTO_ADD_COMMITTEE_TO_THREADS_MESSAGE)
692+
693+
cls._settings["AUTO_ADD_COMMITTEE_TO_THREADS"] = (
694+
raw_auto_add_committee_to_threads in TRUE_VALUES
695+
)
696+
681697
@classmethod
682698
def _setup_env_variables(cls) -> None:
683699
"""
@@ -715,6 +731,7 @@ def _setup_env_variables(cls) -> None:
715731
cls._setup_statistics_roles()
716732
cls._setup_moderation_document_url()
717733
cls._setup_strike_performed_manually_warning_location()
734+
cls._setup_auto_add_committee_to_threads()
718735

719736
cls._is_env_variables_setup = True
720737

0 commit comments

Comments
 (0)