From 33c6a01f76b73c47863835c17e714f6e512e6f52 Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Mon, 5 Aug 2024 21:05:38 +0200 Subject: [PATCH 01/31] Update dependencies --- requirements.txt | 30 +++++++++++++----------- script/requirements.txt | 52 +++++++++++++++++++---------------------- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/requirements.txt b/requirements.txt index 79e5b44..2e61730 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,30 +1,34 @@ # -# This file is autogenerated by pip-compile with Python 3.9 +# This file is autogenerated by pip-compile with Python 3.10 # by the following command: # # pip-compile # -aiohttp==3.7.4.post0 +aiohappyeyeballs==2.3.4 + # via aiohttp +aiohttp==3.10.1 # via discord-py -async-timeout==3.0.1 +aiosignal==1.3.1 # via aiohttp -attrs==21.2.0 +async-timeout==4.0.3 # via aiohttp -chardet==4.0.0 +attrs==24.1.0 # via aiohttp -discord==1.7.3 +discord==2.3.2 # via -r requirements.in -discord-py==1.7.3 +discord-py==2.4.0 # via discord -idna==3.3 +frozenlist==1.4.1 + # via + # aiohttp + # aiosignal +idna==3.7 # via yarl -multidict==5.2.0 +multidict==6.0.5 # via # aiohttp # yarl -python-dotenv==0.19.1 +python-dotenv==1.0.1 # via -r requirements.in -typing-extensions==4.7.1 - # via aiohttp -yarl==1.7.2 +yarl==1.9.4 # via aiohttp diff --git a/script/requirements.txt b/script/requirements.txt index 5c8073e..e7b1c32 100644 --- a/script/requirements.txt +++ b/script/requirements.txt @@ -1,38 +1,38 @@ # -# This file is autogenerated by pip-compile with Python 3.9 +# This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile script/requirements.in +# pip-compile # aiohttp==3.7.4.post0 # via - # -r script/../requirements.txt + # -r ../requirements.txt # discord-py async-timeout==3.0.1 # via - # -r script/../requirements.txt + # -r ../requirements.txt # aiohttp attrs==21.2.0 # via - # -r script/../requirements.txt + # -r ../requirements.txt # aiohttp build==1.0.3 # via pip-tools chardet==4.0.0 # via - # -r script/../requirements.txt + # -r ../requirements.txt # aiohttp click==8.1.7 # via pip-tools discord==1.7.3 - # via -r script/../requirements.txt + # via -r ../requirements.txt discord-py==1.7.3 # via - # -r script/../requirements.txt + # -r ../requirements.txt # discord flake8==6.1.0 # via - # -r script/requirements.in + # -r requirements.in # flake8-builtins # flake8-commas # flake8-comprehensions @@ -41,44 +41,42 @@ flake8==6.1.0 # flake8-mutable # flake8-tuple flake8-builtins==2.1.0 - # via -r script/requirements.in + # via -r requirements.in flake8-commas==2.1.0 - # via -r script/requirements.in + # via -r requirements.in flake8-comprehensions==3.14.0 - # via -r script/requirements.in + # via -r requirements.in flake8-debugger==4.1.2 - # via -r script/requirements.in + # via -r requirements.in flake8-isort==6.1.0 - # via -r script/requirements.in + # via -r requirements.in flake8-mutable==1.2.0 - # via -r script/requirements.in + # via -r requirements.in flake8-todo==0.7 - # via -r script/requirements.in + # via -r requirements.in flake8-tuple==0.4.1 - # via -r script/requirements.in + # via -r requirements.in idna==3.3 # via - # -r script/../requirements.txt + # -r ../requirements.txt # yarl -importlib-metadata==6.8.0 - # via build isort==5.12.0 # via flake8-isort mccabe==0.7.0 # via flake8 multidict==5.2.0 # via - # -r script/../requirements.txt + # -r ../requirements.txt # aiohttp # yarl mypy==1.5.1 - # via -r script/requirements.in + # via -r requirements.in mypy-extensions==1.0.0 # via mypy packaging==23.1 # via build pip-tools==7.3.0 - # via -r script/requirements.in + # via -r requirements.in pycodestyle==2.11.0 # via # flake8 @@ -89,7 +87,7 @@ pyflakes==3.1.0 pyproject-hooks==1.0.0 # via build python-dotenv==0.19.1 - # via -r script/../requirements.txt + # via -r ../requirements.txt six==1.16.0 # via flake8-tuple tomli==2.0.1 @@ -100,17 +98,15 @@ tomli==2.0.1 # pyproject-hooks typing-extensions==4.7.1 # via - # -r script/../requirements.txt + # -r ../requirements.txt # aiohttp # mypy wheel==0.41.2 # via pip-tools yarl==1.7.2 # via - # -r script/../requirements.txt + # -r ../requirements.txt # aiohttp -zipp==3.16.2 - # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip From 91c6bece3d956a844818324abc55d585b4f75143 Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Mon, 5 Aug 2024 22:23:15 +0200 Subject: [PATCH 02/31] Separate out bot --- main.py | 154 ++--------------------------------------------- src/__init__.py | 0 src/bot.py | 126 ++++++++++++++++++++++++++++++++++++++ src/constants.py | 19 ++++++ 4 files changed, 151 insertions(+), 148 deletions(-) create mode 100644 src/__init__.py create mode 100644 src/bot.py create mode 100644 src/constants.py diff --git a/main.py b/main.py index 74fd403..61503a3 100644 --- a/main.py +++ b/main.py @@ -1,163 +1,21 @@ import os import sys import logging -import textwrap -from typing import Tuple, AsyncGenerator -import discord # type: ignore[import] -import discord.utils # type: ignore[import] +from discord import Intents from dotenv import load_dotenv +from src.bot import BotClient + logger = logging.getLogger('srbot') logger.setLevel(logging.INFO) handler = logging.StreamHandler(sys.stdout) handler.setLevel(logging.INFO) logger.addHandler(handler) -intents = discord.Intents.default() +intents = Intents.default() intents.members = True # Listen to member joins -client = discord.Client(intents=intents) - -# name of the category for new welcome channels to go. -WELCOME_CATEGORY_NAME = "welcome" - -# Name of the channel to announce welcome messages to. -ANNOUNCE_CHANNEL_NAME = "say-hello" - -# prefix used to identify the channels to listen to passwords in. -CHANNEL_PREFIX = "welcome-" - -# prefix of the role to give the user once the password succeeds -ROLE_PREFIX = "team-" - -# role to give user if they have correctly entered *any* password -VERIFIED_ROLE = "verified" - -SPECIAL_TEAM = "SRZ" -SPECIAL_ROLE = "unverified-volunteer" - -PASSWORDS_CHANNEL_NAME = "role-passwords" - - -@client.event -async def on_ready() -> None: - logger.info(f"{client.user} has connected to Discord!") - - -@client.event -async def on_member_join(member: discord.Member) -> None: - name = member.display_name - logger.info(f"Member {name} joined") - guild: discord.Guild = member.guild - join_channel_category = discord.utils.get(guild.categories, name=WELCOME_CATEGORY_NAME) - # Create a new channel with that user able to write - channel: discord.TextChannel = await guild.create_text_channel( - f'{CHANNEL_PREFIX}{name}', - category=join_channel_category, - reason="User joined server, creating welcome channel.", - overwrites={ - guild.default_role: discord.PermissionOverwrite( - read_messages=False, - send_messages=False), - member: discord.PermissionOverwrite(read_messages=True, send_messages=True), - guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True), - }, - ) - await channel.send(textwrap.dedent( - f"""Welcome {member.mention}! - To gain access, you must send a message in this channel with the password for your group. - - *Don't have the password? it should have been sent with this join link to your team leader* - """, - )) - logger.info(f"Created welcome channel for '{name}'") - - -@client.event -async def on_member_remove(member: discord.Member) -> None: - name = member.display_name - logger.info(f"Member '{name}' left") - join_channel_category: discord.CategoryChannel = discord.utils.get( - member.guild.categories, - name=WELCOME_CATEGORY_NAME, - ) - channel: discord.TextChannel - for channel in join_channel_category.channels: - # If the only user able to see it is the bot, then delete it. - if channel.overwrites.keys() == {member.guild.me}: - await channel.delete() - logger.info(f"Deleted channel '{channel.name}', because it has no users.") - - -@client.event -async def on_message(message: discord.Message) -> None: - channel: discord.TextChannel = message.channel - if not channel.name.startswith(CHANNEL_PREFIX): - return - - chosen_team = "" - async for team_name, password in load_passwords(message.guild): - if password in message.content.lower(): - logger.info( - f"'{message.author.name}' entered the correct password for {team_name}", - ) - # Password was correct! - chosen_team = team_name - - if chosen_team: - if chosen_team == SPECIAL_TEAM: - role_name = SPECIAL_ROLE - else: - # Add them to the 'verified' role. - # This doesn't happen in special cases because we expect a second - # step (outside of this bot) before verifying them. - role: discord.Role = discord.utils.get(message.guild.roles, name=VERIFIED_ROLE) - await message.author.add_roles(role, reason="A correct password was entered.") - - role_name = f"{ROLE_PREFIX}{chosen_team}" - - # Add them to that specific role - specific_role = discord.utils.get(message.guild.roles, name=role_name) - await message.author.add_roles( - specific_role, - reason="Correct password for this role was entered.", - ) - logger.info(f"gave user '{message.author.name}' the {role_name} role.") - - if chosen_team != SPECIAL_TEAM: - announce_channel: discord.TextChannel = discord.utils.get( - message.guild.channels, - name=ANNOUNCE_CHANNEL_NAME, - ) - await announce_channel.send( - f"Welcome {message.author.mention} from team {chosen_team}", - ) - logger.info(f"Sent welcome announcement for '{message.author.name}'") - - await channel.delete() - logger.info(f"deleted channel '{channel.name}' because verification has completed.") - - -async def load_passwords(guild: discord.Guild) -> AsyncGenerator[Tuple[str, str], None]: - """ - Returns a mapping from role name to the password for that role. - - Reads from the first message of the channel named {PASSWORDS_CHANNEL_NAME}. - The format should be as follows: - ``` - teamname:password - ``` - """ - channel: discord.TextChannel = discord.utils.get( - guild.channels, - name=PASSWORDS_CHANNEL_NAME, - ) - message: discord.Message - async for message in channel.history(limit=100, oldest_first=True): - content: str = message.content.replace('`', '').strip() - team, password = content.split(':') - yield team.strip(), password.strip() - load_dotenv() -client.run(os.getenv('DISCORD_TOKEN')) +bot = BotClient(logger=logger, intents=intents) +bot.run(os.getenv('DISCORD_TOKEN')) diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/bot.py b/src/bot.py new file mode 100644 index 0000000..b9b2cb5 --- /dev/null +++ b/src/bot.py @@ -0,0 +1,126 @@ +import textwrap + +import discord +import logging + +from .constants import * +from typing import Tuple, AsyncGenerator + + +class BotClient(discord.Client): + logger: logging.Logger + + def __init__(self, logger: logging.Logger, *, loop=None, **options): + super().__init__(loop=loop, **options) + self.logger = logger + + async def on_ready(self) -> None: + self.logger.info(f"{self.user} has connected to Discord!") + + async def on_member_join(self, member: discord.Member) -> None: + name = member.display_name + self.logger.info(f"Member {name} joined") + guild: discord.Guild = member.guild + join_channel_category = discord.utils.get(guild.categories, name=WELCOME_CATEGORY_NAME) + # Create a new channel with that user able to write + channel: discord.TextChannel = await guild.create_text_channel( + f'{CHANNEL_PREFIX}{name}', + category=join_channel_category, + reason="User joined server, creating welcome channel.", + overwrites={ + guild.default_role: discord.PermissionOverwrite( + read_messages=False, + send_messages=False), + member: discord.PermissionOverwrite(read_messages=True, send_messages=True), + guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True), + }, + ) + await channel.send(textwrap.dedent( + f"""Welcome {member.mention}! + To gain access, you must send a message in this channel with the password for your group. + + *Don't have the password? it should have been sent with this join link to your team leader* + """, + )) + self.logger.info(f"Created welcome channel for '{name}'") + + async def on_member_remove(self, member: discord.Member) -> None: + name = member.display_name + self.logger.info(f"Member '{name}' left") + join_channel_category: discord.CategoryChannel = discord.utils.get( + member.guild.categories, + name=WELCOME_CATEGORY_NAME, + ) + channel: discord.TextChannel + for channel in join_channel_category.channels: + # If the only user able to see it is the bot, then delete it. + if channel.overwrites.keys() == {member.guild.me}: + await channel.delete() + self.logger.info(f"Deleted channel '{channel.name}', because it has no users.") + + async def on_message(self, message: discord.Message) -> None: + channel: discord.TextChannel = message.channel + if not channel.name.startswith(CHANNEL_PREFIX): + return + + chosen_team = "" + async for team_name, password in self.load_passwords(message.guild): + if password in message.content.lower(): + self.logger.info( + f"'{message.author.name}' entered the correct password for {team_name}", + ) + # Password was correct! + chosen_team = team_name + + if chosen_team: + if chosen_team == SPECIAL_TEAM: + role_name = SPECIAL_ROLE + else: + # Add them to the 'verified' role. + # This doesn't happen in special cases because we expect a second + # step (outside of this bot) before verifying them. + role: discord.Role = discord.utils.get(message.guild.roles, name=VERIFIED_ROLE) + await message.author.add_roles(role, reason="A correct password was entered.") + + role_name = f"{ROLE_PREFIX}{chosen_team}" + + # Add them to that specific role + specific_role = discord.utils.get(message.guild.roles, name=role_name) + await message.author.add_roles( + specific_role, + reason="Correct password for this role was entered.", + ) + self.logger.info(f"gave user '{message.author.name}' the {role_name} role.") + + if chosen_team != SPECIAL_TEAM: + announce_channel: discord.TextChannel = discord.utils.get( + message.guild.channels, + name=ANNOUNCE_CHANNEL_NAME, + ) + await announce_channel.send( + f"Welcome {message.author.mention} from team {chosen_team}", + ) + self.logger.info(f"Sent welcome announcement for '{message.author.name}'") + + await channel.delete() + self.logger.info(f"deleted channel '{channel.name}' because verification has completed.") + + async def load_passwords(self, guild: discord.Guild) -> AsyncGenerator[Tuple[str, str], None]: + """ + Returns a mapping from role name to the password for that role. + + Reads from the first message of the channel named {PASSWORDS_CHANNEL_NAME}. + The format should be as follows: + ``` + teamname:password + ``` + """ + channel: discord.TextChannel = discord.utils.get( + guild.channels, + name=PASSWORDS_CHANNEL_NAME, + ) + message: discord.Message + async for message in channel.history(limit=100, oldest_first=True): + content: str = message.content.replace('`', '').strip() + team, password = content.split(':') + yield team.strip(), password.strip() diff --git a/src/constants.py b/src/constants.py new file mode 100644 index 0000000..de56ee5 --- /dev/null +++ b/src/constants.py @@ -0,0 +1,19 @@ +# name of the category for new welcome channels to go. +WELCOME_CATEGORY_NAME = "welcome" + +# Name of the channel to announce welcome messages to. +ANNOUNCE_CHANNEL_NAME = "say-hello" + +# prefix used to identify the channels to listen to passwords in. +CHANNEL_PREFIX = "welcome-" + +# prefix of the role to give the user once the password succeeds +ROLE_PREFIX = "team-" + +# role to give user if they have correctly entered *any* password +VERIFIED_ROLE = "verified" + +SPECIAL_TEAM = "SRZ" +SPECIAL_ROLE = "unverified-volunteer" + +PASSWORDS_CHANNEL_NAME = "role-passwords" From 0c9bc11d6cad4b07d02cdb73e843ed807700b074 Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Mon, 5 Aug 2024 22:27:18 +0200 Subject: [PATCH 03/31] Move main.py into src --- Dockerfile | 2 +- src/bot.py | 2 +- main.py => src/main.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename main.py => src/main.py (93%) diff --git a/Dockerfile b/Dockerfile index 1a8aa9b..5df50f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,6 @@ WORKDIR /usr/src/app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY main.py . +COPY src/* . CMD [ "python", "main.py" ] diff --git a/src/bot.py b/src/bot.py index b9b2cb5..01ad9db 100644 --- a/src/bot.py +++ b/src/bot.py @@ -3,7 +3,7 @@ import discord import logging -from .constants import * +from constants import * from typing import Tuple, AsyncGenerator diff --git a/main.py b/src/main.py similarity index 93% rename from main.py rename to src/main.py index 61503a3..e85466e 100644 --- a/main.py +++ b/src/main.py @@ -5,7 +5,7 @@ from discord import Intents from dotenv import load_dotenv -from src.bot import BotClient +from bot import BotClient logger = logging.getLogger('srbot') logger.setLevel(logging.INFO) From 8aecd921ac80d6dadd2ebfe2442f895a610b826d Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Tue, 6 Aug 2024 15:44:07 +0200 Subject: [PATCH 04/31] Fix scripts --- script/linting/lint | 2 +- script/typing/check | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/script/linting/lint b/script/linting/lint index b002679..04001c1 100755 --- a/script/linting/lint +++ b/script/linting/lint @@ -2,4 +2,4 @@ if [ -z "$FLAKE8" ]; then FLAKE8=flake8 fi -exec "$FLAKE8" main.py "$@" +exec "$FLAKE8" src/main.py "$@" diff --git a/script/typing/check b/script/typing/check index 8d70210..6d0c767 100755 --- a/script/typing/check +++ b/script/typing/check @@ -2,4 +2,4 @@ if [ -z "$MYPY" ]; then MYPY=mypy fi -exec "$MYPY" main.py +exec "$MYPY" src/main.py From 9b968dd2d25c771f0db05fc885fde297acc504e3 Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Tue, 6 Aug 2024 22:44:17 +0200 Subject: [PATCH 05/31] Fix lint errors --- requirements.in | 2 +- requirements.txt | 4 +- script/linting/lint | 2 +- script/typing/check | 2 +- setup.cfg | 2 +- src/bot.py | 118 +++++++++++++++++++++++++++++++------------- src/main.py | 18 ++++--- 7 files changed, 102 insertions(+), 46 deletions(-) diff --git a/requirements.in b/requirements.in index 733ecc5..85de664 100644 --- a/requirements.in +++ b/requirements.in @@ -1,2 +1,2 @@ python-dotenv -discord +discord.py diff --git a/requirements.txt b/requirements.txt index 2e61730..af0553f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,10 +14,8 @@ async-timeout==4.0.3 # via aiohttp attrs==24.1.0 # via aiohttp -discord==2.3.2 - # via -r requirements.in discord-py==2.4.0 - # via discord + # via -r requirements.in frozenlist==1.4.1 # via # aiohttp diff --git a/script/linting/lint b/script/linting/lint index 04001c1..cb1abc3 100755 --- a/script/linting/lint +++ b/script/linting/lint @@ -2,4 +2,4 @@ if [ -z "$FLAKE8" ]; then FLAKE8=flake8 fi -exec "$FLAKE8" src/main.py "$@" +exec "$FLAKE8" src/**.py "$@" diff --git a/script/typing/check b/script/typing/check index 6d0c767..bd61e9c 100755 --- a/script/typing/check +++ b/script/typing/check @@ -2,4 +2,4 @@ if [ -z "$MYPY" ]; then MYPY=mypy fi -exec "$MYPY" src/main.py +exec "$MYPY" src/**.py diff --git a/setup.cfg b/setup.cfg index 1f9b202..d3f12e2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,7 +15,7 @@ ignore = W503 # try to keep it below 85, but this allows us to push it a bit when needed. -max_line_length = 100 +max_line_length = 120 [isort] diff --git a/src/bot.py b/src/bot.py index 01ad9db..b42feac 100644 --- a/src/bot.py +++ b/src/bot.py @@ -1,31 +1,84 @@ +import os +import asyncio +import logging import textwrap +from typing import Tuple, AsyncGenerator import discord -import logging -from constants import * -from typing import Tuple, AsyncGenerator +from src.constants import ( + ROLE_PREFIX, + SPECIAL_ROLE, + SPECIAL_TEAM, + VERIFIED_ROLE, + CHANNEL_PREFIX, + ANNOUNCE_CHANNEL_NAME, + WELCOME_CATEGORY_NAME, + PASSWORDS_CHANNEL_NAME, +) class BotClient(discord.Client): logger: logging.Logger - - def __init__(self, logger: logging.Logger, *, loop=None, **options): - super().__init__(loop=loop, **options) + guild: discord.Object + verified_role: discord.Role + special_role: discord.Role + welcome_category: discord.CategoryChannel + announce_channel: discord.TextChannel + passwords_channel: discord.TextChannel + + def __init__( + self, + logger: logging.Logger, + *, + loop: asyncio.AbstractEventLoop | None = None, + intents: discord.Intents = discord.Intents.none(), + ): + super().__init__(loop=loop, intents=intents) self.logger = logger + guild_id = os.getenv('DISCORD_GUILD_ID') + if guild_id is None or not guild_id.isnumeric(): + logger.error("Invalid guild ID") + exit(1) + self.guild = discord.Object(id=int()) async def on_ready(self) -> None: self.logger.info(f"{self.user} has connected to Discord!") + guild = self.get_guild(self.guild.id) + if guild is None: + logging.error(f"Guild {self.guild.id} not found!") + exit(1) + + verified_role = discord.utils.get(guild.roles, name=VERIFIED_ROLE) + special_role = discord.utils.get(guild.roles, name=SPECIAL_ROLE) + welcome_category = discord.utils.get(guild.categories, name=WELCOME_CATEGORY_NAME) + announce_channel = discord.utils.get(guild.text_channels, name=ANNOUNCE_CHANNEL_NAME) + passwords_channel = discord.utils.get(guild.text_channels, name=PASSWORDS_CHANNEL_NAME) + + if ( + verified_role is None + or special_role is None + or welcome_category is None + or announce_channel is None + or passwords_channel is None + ): + logging.error("Roles and channels are not set up") + exit(1) + else: + self.verified_role = verified_role + self.special_role = special_role + self.welcome_category = welcome_category + self.announce_channel = announce_channel + self.passwords_channel = passwords_channel async def on_member_join(self, member: discord.Member) -> None: name = member.display_name self.logger.info(f"Member {name} joined") guild: discord.Guild = member.guild - join_channel_category = discord.utils.get(guild.categories, name=WELCOME_CATEGORY_NAME) # Create a new channel with that user able to write channel: discord.TextChannel = await guild.create_text_channel( f'{CHANNEL_PREFIX}{name}', - category=join_channel_category, + category=self.welcome_category, reason="User joined server, creating welcome channel.", overwrites={ guild.default_role: discord.PermissionOverwrite( @@ -47,20 +100,19 @@ async def on_member_join(self, member: discord.Member) -> None: async def on_member_remove(self, member: discord.Member) -> None: name = member.display_name self.logger.info(f"Member '{name}' left") - join_channel_category: discord.CategoryChannel = discord.utils.get( - member.guild.categories, - name=WELCOME_CATEGORY_NAME, - ) - channel: discord.TextChannel - for channel in join_channel_category.channels: + for channel in self.welcome_category.channels: # If the only user able to see it is the bot, then delete it. if channel.overwrites.keys() == {member.guild.me}: await channel.delete() self.logger.info(f"Deleted channel '{channel.name}', because it has no users.") async def on_message(self, message: discord.Message) -> None: + if not isinstance(message.channel, discord.TextChannel): + return channel: discord.TextChannel = message.channel - if not channel.name.startswith(CHANNEL_PREFIX): + if channel is None or message.guild is None or not channel.name.startswith(CHANNEL_PREFIX): + return + if isinstance(message.author, discord.User): return chosen_team = "" @@ -79,31 +131,35 @@ async def on_message(self, message: discord.Message) -> None: # Add them to the 'verified' role. # This doesn't happen in special cases because we expect a second # step (outside of this bot) before verifying them. - role: discord.Role = discord.utils.get(message.guild.roles, name=VERIFIED_ROLE) - await message.author.add_roles(role, reason="A correct password was entered.") + + await message.author.add_roles( + self.verified_role, + reason="A correct password was entered.", + ) role_name = f"{ROLE_PREFIX}{chosen_team}" # Add them to that specific role specific_role = discord.utils.get(message.guild.roles, name=role_name) - await message.author.add_roles( - specific_role, - reason="Correct password for this role was entered.", - ) - self.logger.info(f"gave user '{message.author.name}' the {role_name} role.") + if specific_role is None: + self.logger.error(f"Specified role '{chosen_team}' does not exist") + else: + await message.author.add_roles( + specific_role, + reason="Correct password for this role was entered.", + ) + self.logger.info(f"gave user '{message.author.name}' the {role_name} role.") if chosen_team != SPECIAL_TEAM: - announce_channel: discord.TextChannel = discord.utils.get( - message.guild.channels, - name=ANNOUNCE_CHANNEL_NAME, - ) - await announce_channel.send( + await self.announce_channel.send( f"Welcome {message.author.mention} from team {chosen_team}", ) self.logger.info(f"Sent welcome announcement for '{message.author.name}'") await channel.delete() - self.logger.info(f"deleted channel '{channel.name}' because verification has completed.") + self.logger.info( + f"deleted channel '{channel.name}' because verification has completed.", + ) async def load_passwords(self, guild: discord.Guild) -> AsyncGenerator[Tuple[str, str], None]: """ @@ -115,12 +171,8 @@ async def load_passwords(self, guild: discord.Guild) -> AsyncGenerator[Tuple[str teamname:password ``` """ - channel: discord.TextChannel = discord.utils.get( - guild.channels, - name=PASSWORDS_CHANNEL_NAME, - ) message: discord.Message - async for message in channel.history(limit=100, oldest_first=True): + async for message in self.passwords_channel.history(limit=100, oldest_first=True): content: str = message.content.replace('`', '').strip() team, password = content.split(':') yield team.strip(), password.strip() diff --git a/src/main.py b/src/main.py index e85466e..9e48628 100644 --- a/src/main.py +++ b/src/main.py @@ -2,12 +2,12 @@ import sys import logging -from discord import Intents from dotenv import load_dotenv +from discord import Intents -from bot import BotClient +from src.bot import BotClient -logger = logging.getLogger('srbot') +logger = logging.getLogger("srbot") logger.setLevel(logging.INFO) handler = logging.StreamHandler(sys.stdout) handler.setLevel(logging.INFO) @@ -16,6 +16,12 @@ intents = Intents.default() intents.members = True # Listen to member joins -load_dotenv() -bot = BotClient(logger=logger, intents=intents) -bot.run(os.getenv('DISCORD_TOKEN')) +if __name__ == "__main__": + load_dotenv() + token = os.getenv("DISCORD_TOKEN") + if token is None: + print("No token provided.", file=sys.stderr) + exit(1) + + bot = BotClient(logger=logger, intents=intents) + bot.run(token) From 0a128c1cb3ab78e479dcff2f03c7844831c12957 Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Fri, 16 Aug 2024 10:56:54 +0200 Subject: [PATCH 06/31] Bump Python version to 3.10 --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 02ae39e..55f6a3b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: "3.10" - name: Cache dependencies uses: actions/cache@v3 @@ -51,7 +51,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: "3.10" - name: Cache dependencies uses: actions/cache@v3 From 4ec2e467857f040d3492a752e1259d90e17197ee Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Fri, 16 Aug 2024 23:09:33 +0200 Subject: [PATCH 07/31] Update dev dependencies --- script/requirements.txt | 82 +++++++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 40 deletions(-) diff --git a/script/requirements.txt b/script/requirements.txt index e7b1c32..1cf361f 100644 --- a/script/requirements.txt +++ b/script/requirements.txt @@ -2,37 +2,37 @@ # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile +# pip-compile script/requirements.in # -aiohttp==3.7.4.post0 +aiohappyeyeballs==2.3.4 # via - # -r ../requirements.txt + # -r script/../requirements.txt + # aiohttp +aiohttp==3.10.1 + # via + # -r script/../requirements.txt # discord-py -async-timeout==3.0.1 +aiosignal==1.3.1 # via - # -r ../requirements.txt + # -r script/../requirements.txt # aiohttp -attrs==21.2.0 +async-timeout==4.0.3 # via - # -r ../requirements.txt + # -r script/../requirements.txt # aiohttp -build==1.0.3 - # via pip-tools -chardet==4.0.0 +attrs==24.1.0 # via - # -r ../requirements.txt + # -r script/../requirements.txt # aiohttp +build==1.0.3 + # via pip-tools click==8.1.7 # via pip-tools -discord==1.7.3 - # via -r ../requirements.txt -discord-py==1.7.3 - # via - # -r ../requirements.txt - # discord +discord-py==2.4.0 + # via -r script/../requirements.txt flake8==6.1.0 # via - # -r requirements.in + # -r script/requirements.in # flake8-builtins # flake8-commas # flake8-comprehensions @@ -41,42 +41,47 @@ flake8==6.1.0 # flake8-mutable # flake8-tuple flake8-builtins==2.1.0 - # via -r requirements.in + # via -r script/requirements.in flake8-commas==2.1.0 - # via -r requirements.in + # via -r script/requirements.in flake8-comprehensions==3.14.0 - # via -r requirements.in + # via -r script/requirements.in flake8-debugger==4.1.2 - # via -r requirements.in + # via -r script/requirements.in flake8-isort==6.1.0 - # via -r requirements.in + # via -r script/requirements.in flake8-mutable==1.2.0 - # via -r requirements.in + # via -r script/requirements.in flake8-todo==0.7 - # via -r requirements.in + # via -r script/requirements.in flake8-tuple==0.4.1 - # via -r requirements.in -idna==3.3 + # via -r script/requirements.in +frozenlist==1.4.1 # via - # -r ../requirements.txt + # -r script/../requirements.txt + # aiohttp + # aiosignal +idna==3.7 + # via + # -r script/../requirements.txt # yarl isort==5.12.0 # via flake8-isort mccabe==0.7.0 # via flake8 -multidict==5.2.0 +multidict==6.0.5 # via - # -r ../requirements.txt + # -r script/../requirements.txt # aiohttp # yarl mypy==1.5.1 - # via -r requirements.in + # via -r script/requirements.in mypy-extensions==1.0.0 # via mypy packaging==23.1 # via build pip-tools==7.3.0 - # via -r requirements.in + # via -r script/requirements.in pycodestyle==2.11.0 # via # flake8 @@ -86,8 +91,8 @@ pyflakes==3.1.0 # via flake8 pyproject-hooks==1.0.0 # via build -python-dotenv==0.19.1 - # via -r ../requirements.txt +python-dotenv==1.0.1 + # via -r script/../requirements.txt six==1.16.0 # via flake8-tuple tomli==2.0.1 @@ -97,15 +102,12 @@ tomli==2.0.1 # pip-tools # pyproject-hooks typing-extensions==4.7.1 - # via - # -r ../requirements.txt - # aiohttp - # mypy + # via mypy wheel==0.41.2 # via pip-tools -yarl==1.7.2 +yarl==1.9.4 # via - # -r ../requirements.txt + # -r script/../requirements.txt # aiohttp # The following packages are considered to be unsafe in a requirements file: From 4260f801842434306542f276e5e4d316fbaafd47 Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Tue, 6 Aug 2024 13:39:19 +0200 Subject: [PATCH 08/31] Add command for creating teams --- example.env | 3 ++- src/bot.py | 14 ++++++++++++- src/commands/__init__.py | 0 src/commands/new_team.py | 44 ++++++++++++++++++++++++++++++++++++++++ src/constants.py | 2 ++ 5 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 src/commands/__init__.py create mode 100644 src/commands/new_team.py diff --git a/example.env b/example.env index c578026..d7ef59b 100644 --- a/example.env +++ b/example.env @@ -1 +1,2 @@ -DISCORD_TOKEN=BLAHBLAH \ No newline at end of file +DISCORD_TOKEN=BLAHBLAH +DISCORD_GUILD_ID= diff --git a/src/bot.py b/src/bot.py index b42feac..4ef6fe7 100644 --- a/src/bot.py +++ b/src/bot.py @@ -2,10 +2,13 @@ import asyncio import logging import textwrap -from typing import Tuple, AsyncGenerator import discord +from discord import app_commands + +from typing import Tuple, AsyncGenerator + from src.constants import ( ROLE_PREFIX, SPECIAL_ROLE, @@ -17,6 +20,8 @@ PASSWORDS_CHANNEL_NAME, ) +from src.commands.new_team import new_team + class BotClient(discord.Client): logger: logging.Logger @@ -36,11 +41,18 @@ def __init__( ): super().__init__(loop=loop, intents=intents) self.logger = logger + self.tree = app_commands.CommandTree(self) guild_id = os.getenv('DISCORD_GUILD_ID') if guild_id is None or not guild_id.isnumeric(): logger.error("Invalid guild ID") exit(1) self.guild = discord.Object(id=int()) + self.tree.add_command(new_team, guild=self.guild) + + async def setup_hook(self): + # This copies the global commands over to your guild. + self.tree.copy_global_to(guild=self.guild) + await self.tree.sync(guild=self.guild) async def on_ready(self) -> None: self.logger.info(f"{self.user} has connected to Discord!") diff --git a/src/commands/__init__.py b/src/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/commands/new_team.py b/src/commands/new_team.py new file mode 100644 index 0000000..78c4d4a --- /dev/null +++ b/src/commands/new_team.py @@ -0,0 +1,44 @@ +import discord +from discord import app_commands +from discord.app_commands import locale_str + +from src.constants import TEAM_CATEGORY_NAME, PASSWORDS_CHANNEL_NAME + +REASON = "Created via command" + + +@discord.app_commands.command( + name=locale_str('team_new', en='new team', de='team hinzufügen'), + description='Creates a role and channel for a team', +) +@app_commands.describe( + tla='Three Letter Acronym (e.g. SRZ)', + name='Name of the team', + password="Password required for joining the team", +) +async def new_team(interaction: discord.Interaction, tla: str, name: str, password: str): + guild: discord.Guild = interaction.guild + category = discord.utils.get(guild.categories, name=TEAM_CATEGORY_NAME) + role = await guild.create_role( + reason=REASON, + name=f"Team {tla.upper()}", + ) + channel = await guild.create_text_channel( + reason=REASON, + name=f"Team {tla.upper()}", + topic=name, + category=category, + overwrites={ + guild.default_role: discord.PermissionOverwrite( + read_messages=False, + send_messages=False), + role: discord.PermissionOverwrite() + } + ) + await _save_password(guild, tla, password) + await interaction.response.send_message(f"Team {tla.upper()} created!") + + +async def _save_password(guild: discord.Guild, tla: str, password: str): + channel: discord.TextChannel = discord.utils.get(guild.channels, name=PASSWORDS_CHANNEL_NAME) + await channel.send(f"```{tla}:{password}```") diff --git a/src/constants.py b/src/constants.py index de56ee5..bda2c7c 100644 --- a/src/constants.py +++ b/src/constants.py @@ -17,3 +17,5 @@ SPECIAL_ROLE = "unverified-volunteer" PASSWORDS_CHANNEL_NAME = "role-passwords" + +TEAM_CATEGORY_NAME = "team channels" From 0394b6880a88e7c63a9a6f6c32e7e421567ca8ca Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Tue, 6 Aug 2024 13:49:34 +0200 Subject: [PATCH 09/31] Only allow server admins to create a team --- src/commands/new_team.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/commands/new_team.py b/src/commands/new_team.py index 78c4d4a..55c90ae 100644 --- a/src/commands/new_team.py +++ b/src/commands/new_team.py @@ -1,5 +1,5 @@ import discord -from discord import app_commands +from discord import app_commands, interactions from discord.app_commands import locale_str from src.constants import TEAM_CATEGORY_NAME, PASSWORDS_CHANNEL_NAME @@ -10,13 +10,16 @@ @discord.app_commands.command( name=locale_str('team_new', en='new team', de='team hinzufügen'), description='Creates a role and channel for a team', + extras={ + 'default_member_permissions': 0, + }, ) @app_commands.describe( tla='Three Letter Acronym (e.g. SRZ)', name='Name of the team', password="Password required for joining the team", ) -async def new_team(interaction: discord.Interaction, tla: str, name: str, password: str): +async def new_team(interaction: discord.interactions.Interaction, tla: str, name: str, password: str): guild: discord.Guild = interaction.guild category = discord.utils.get(guild.categories, name=TEAM_CATEGORY_NAME) role = await guild.create_role( From 02cd03480cb733ceeea395e283016689bd796807 Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Tue, 6 Aug 2024 14:28:59 +0200 Subject: [PATCH 10/31] Handle teams already existing --- src/commands/new_team.py | 14 ++++++++++---- src/constants.py | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/commands/new_team.py b/src/commands/new_team.py index 55c90ae..7bd8329 100644 --- a/src/commands/new_team.py +++ b/src/commands/new_team.py @@ -2,7 +2,7 @@ from discord import app_commands, interactions from discord.app_commands import locale_str -from src.constants import TEAM_CATEGORY_NAME, PASSWORDS_CHANNEL_NAME +from src.constants import TEAM_CATEGORY_NAME, PASSWORDS_CHANNEL_NAME, ROLE_PREFIX REASON = "Created via command" @@ -22,13 +22,19 @@ async def new_team(interaction: discord.interactions.Interaction, tla: str, name: str, password: str): guild: discord.Guild = interaction.guild category = discord.utils.get(guild.categories, name=TEAM_CATEGORY_NAME) + role_name = f"{ROLE_PREFIX}{tla.upper()}" + + if discord.utils.get(guild.roles, name=role_name) is not None: + await interaction.response.send_message(f"{role_name} already exists", ephemeral=True) + return + role = await guild.create_role( reason=REASON, - name=f"Team {tla.upper()}", + name=role_name, ) channel = await guild.create_text_channel( reason=REASON, - name=f"Team {tla.upper()}", + name=f"team-{tla}", topic=name, category=category, overwrites={ @@ -39,7 +45,7 @@ async def new_team(interaction: discord.interactions.Interaction, tla: str, name } ) await _save_password(guild, tla, password) - await interaction.response.send_message(f"Team {tla.upper()} created!") + await interaction.response.send_message(f"<@&{role.id}> and <#{channel.id}> created!", ephemeral=True) async def _save_password(guild: discord.Guild, tla: str, password: str): diff --git a/src/constants.py b/src/constants.py index bda2c7c..2c6f41c 100644 --- a/src/constants.py +++ b/src/constants.py @@ -8,7 +8,7 @@ CHANNEL_PREFIX = "welcome-" # prefix of the role to give the user once the password succeeds -ROLE_PREFIX = "team-" +ROLE_PREFIX = "Team " # role to give user if they have correctly entered *any* password VERIFIED_ROLE = "verified" From 417be06eb1039a400714c22a27a0ef1e41e132fc Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Tue, 6 Aug 2024 14:34:35 +0200 Subject: [PATCH 11/31] Clean up role names --- src/constants.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/constants.py b/src/constants.py index 2c6f41c..ee583b3 100644 --- a/src/constants.py +++ b/src/constants.py @@ -11,10 +11,10 @@ ROLE_PREFIX = "Team " # role to give user if they have correctly entered *any* password -VERIFIED_ROLE = "verified" +VERIFIED_ROLE = "Verified" SPECIAL_TEAM = "SRZ" -SPECIAL_ROLE = "unverified-volunteer" +SPECIAL_ROLE = "Unverified Volunteer" PASSWORDS_CHANNEL_NAME = "role-passwords" From 15074b113e5d6dc90016288cea82a7b667e7b58f Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Tue, 6 Aug 2024 14:55:11 +0200 Subject: [PATCH 12/31] Fix capitalisation of channel category names These show in all caps on desktop but in their original case on mobile --- src/constants.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/constants.py b/src/constants.py index ee583b3..806e785 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,5 +1,5 @@ # name of the category for new welcome channels to go. -WELCOME_CATEGORY_NAME = "welcome" +WELCOME_CATEGORY_NAME = "Welcome" # Name of the channel to announce welcome messages to. ANNOUNCE_CHANNEL_NAME = "say-hello" @@ -18,4 +18,4 @@ PASSWORDS_CHANNEL_NAME = "role-passwords" -TEAM_CATEGORY_NAME = "team channels" +TEAM_CATEGORY_NAME = "Team Channels" From d26d9b4da5005326d9248e2afdc8e25f87090c42 Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Wed, 7 Aug 2024 17:46:11 +0200 Subject: [PATCH 13/31] Create join command --- src/bot.py | 78 +++++++------------------------------------ src/commands/join.py | 79 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 66 deletions(-) create mode 100644 src/commands/join.py diff --git a/src/bot.py b/src/bot.py index 4ef6fe7..373bd9c 100644 --- a/src/bot.py +++ b/src/bot.py @@ -2,13 +2,11 @@ import asyncio import logging import textwrap +from typing import Tuple, AsyncGenerator import discord - from discord import app_commands -from typing import Tuple, AsyncGenerator - from src.constants import ( ROLE_PREFIX, SPECIAL_ROLE, @@ -19,13 +17,13 @@ WELCOME_CATEGORY_NAME, PASSWORDS_CHANNEL_NAME, ) - +from src.commands.join import join from src.commands.new_team import new_team class BotClient(discord.Client): logger: logging.Logger - guild: discord.Object + guild: discord.Guild | discord.Object verified_role: discord.Role special_role: discord.Role welcome_category: discord.CategoryChannel @@ -46,10 +44,11 @@ def __init__( if guild_id is None or not guild_id.isnumeric(): logger.error("Invalid guild ID") exit(1) - self.guild = discord.Object(id=int()) + self.guild = discord.Object(id=int(guild_id)) self.tree.add_command(new_team, guild=self.guild) + self.tree.add_command(join, guild=self.guild) - async def setup_hook(self): + async def setup_hook(self) -> None: # This copies the global commands over to your guild. self.tree.copy_global_to(guild=self.guild) await self.tree.sync(guild=self.guild) @@ -60,6 +59,7 @@ async def on_ready(self) -> None: if guild is None: logging.error(f"Guild {self.guild.id} not found!") exit(1) + self.guild = guild verified_role = discord.utils.get(guild.roles, name=VERIFIED_ROLE) special_role = discord.utils.get(guild.roles, name=SPECIAL_ROLE) @@ -118,62 +118,7 @@ async def on_member_remove(self, member: discord.Member) -> None: await channel.delete() self.logger.info(f"Deleted channel '{channel.name}', because it has no users.") - async def on_message(self, message: discord.Message) -> None: - if not isinstance(message.channel, discord.TextChannel): - return - channel: discord.TextChannel = message.channel - if channel is None or message.guild is None or not channel.name.startswith(CHANNEL_PREFIX): - return - if isinstance(message.author, discord.User): - return - - chosen_team = "" - async for team_name, password in self.load_passwords(message.guild): - if password in message.content.lower(): - self.logger.info( - f"'{message.author.name}' entered the correct password for {team_name}", - ) - # Password was correct! - chosen_team = team_name - - if chosen_team: - if chosen_team == SPECIAL_TEAM: - role_name = SPECIAL_ROLE - else: - # Add them to the 'verified' role. - # This doesn't happen in special cases because we expect a second - # step (outside of this bot) before verifying them. - - await message.author.add_roles( - self.verified_role, - reason="A correct password was entered.", - ) - - role_name = f"{ROLE_PREFIX}{chosen_team}" - - # Add them to that specific role - specific_role = discord.utils.get(message.guild.roles, name=role_name) - if specific_role is None: - self.logger.error(f"Specified role '{chosen_team}' does not exist") - else: - await message.author.add_roles( - specific_role, - reason="Correct password for this role was entered.", - ) - self.logger.info(f"gave user '{message.author.name}' the {role_name} role.") - - if chosen_team != SPECIAL_TEAM: - await self.announce_channel.send( - f"Welcome {message.author.mention} from team {chosen_team}", - ) - self.logger.info(f"Sent welcome announcement for '{message.author.name}'") - - await channel.delete() - self.logger.info( - f"deleted channel '{channel.name}' because verification has completed.", - ) - - async def load_passwords(self, guild: discord.Guild) -> AsyncGenerator[Tuple[str, str], None]: + async def load_passwords(self) -> AsyncGenerator[Tuple[str, str], None]: """ Returns a mapping from role name to the password for that role. @@ -185,6 +130,7 @@ async def load_passwords(self, guild: discord.Guild) -> AsyncGenerator[Tuple[str """ message: discord.Message async for message in self.passwords_channel.history(limit=100, oldest_first=True): - content: str = message.content.replace('`', '').strip() - team, password = content.split(':') - yield team.strip(), password.strip() + if message.content.startswith('```'): + content: str = message.content.replace('`', '').strip() + team, password = content.split(':') + yield team.strip(), password.strip() diff --git a/src/commands/join.py b/src/commands/join.py new file mode 100644 index 0000000..57877bd --- /dev/null +++ b/src/commands/join.py @@ -0,0 +1,79 @@ +from typing import TYPE_CHECKING + +import discord +from discord import app_commands + +if TYPE_CHECKING: + from src.bot import BotClient + +from src.constants import CHANNEL_PREFIX, SPECIAL_TEAM, SPECIAL_ROLE, ROLE_PREFIX + +REASON = "A correct password was entered." + + +@discord.app_commands.command( + name='join', + description='Join a team using a password', +) +@app_commands.describe( + password="Your team's password", +) +async def join(interaction: discord.Interaction["BotClient"], password: str) -> None: + member: discord.User | discord.Member | None = interaction.user + if member is None or isinstance(member, discord.User): + return + + channel: discord.TextChannel = interaction.channel + if channel is None or not channel.name.startswith(CHANNEL_PREFIX): + return + + chosen_team = await find_team(interaction.client, member, password) + if chosen_team: + if chosen_team == SPECIAL_TEAM: + role_name = SPECIAL_ROLE + else: + # Add them to the 'verified' role. + # This doesn't happen in special cases because we expect a second + # step (outside of this bot) before verifying them. + + await member.add_roles( + interaction.client.verified_role, + reason="A correct password was entered.", + ) + + role_name = f"{ROLE_PREFIX}{chosen_team}" + + # Add them to that specific role + specific_role = discord.utils.get(interaction.client.guild.roles, name=role_name) + if specific_role is None: + interaction.client.logger.error(f"Specified role '{chosen_team}' does not exist") + else: + await member.add_roles( + specific_role, + reason="Correct password for this role was entered.", + ) + interaction.client.logger.info(f"gave user '{member.name}' the {role_name} role.") + + if chosen_team != SPECIAL_TEAM: + await interaction.client.announce_channel.send( + f"Welcome {member.mention} from team {chosen_team}", + ) + interaction.client.logger.info(f"Sent welcome announcement for '{member.name}'") + + await interaction.response.defer() + await channel.delete() + interaction.client.logger.info( + f"deleted channel '{channel.name}' because verification has completed.", + ) + else: + interaction.response.send_message("Incorrect password.", ephemeral=True) + + +async def find_team(client: "BotClient", member: discord.Member, entered: str) -> str: + async for team_name, password in client.load_passwords(): + if password in entered.lower(): + client.logger.info( + f"'{member.name}' entered the correct password for {team_name}", + ) + # Password was correct! + return team_name From df3f6cf9e2fcef132d74fa65bace2fca87ff8928 Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Wed, 7 Aug 2024 18:38:35 +0200 Subject: [PATCH 14/31] Add missing await --- src/commands/join.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/commands/join.py b/src/commands/join.py index 57877bd..4b35b9b 100644 --- a/src/commands/join.py +++ b/src/commands/join.py @@ -6,7 +6,12 @@ if TYPE_CHECKING: from src.bot import BotClient -from src.constants import CHANNEL_PREFIX, SPECIAL_TEAM, SPECIAL_ROLE, ROLE_PREFIX +from src.constants import ( + ROLE_PREFIX, + SPECIAL_ROLE, + SPECIAL_TEAM, + CHANNEL_PREFIX, +) REASON = "A correct password was entered." @@ -66,7 +71,7 @@ async def join(interaction: discord.Interaction["BotClient"], password: str) -> f"deleted channel '{channel.name}' because verification has completed.", ) else: - interaction.response.send_message("Incorrect password.", ephemeral=True) + await interaction.response.send_message("Incorrect password.", ephemeral=True) async def find_team(client: "BotClient", member: discord.Member, entered: str) -> str: From a78df6a1935b1734201543edfcac64ee79110a3a Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Wed, 7 Aug 2024 18:39:03 +0200 Subject: [PATCH 15/31] Add volunteers to team channels --- src/bot.py | 7 ++++-- src/commands/new_team.py | 48 ++++++++++++++++++++++++++++------------ src/constants.py | 2 ++ 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/bot.py b/src/bot.py index 373bd9c..fa5dfc3 100644 --- a/src/bot.py +++ b/src/bot.py @@ -8,11 +8,10 @@ from discord import app_commands from src.constants import ( - ROLE_PREFIX, SPECIAL_ROLE, - SPECIAL_TEAM, VERIFIED_ROLE, CHANNEL_PREFIX, + VOLUNTEER_ROLE, ANNOUNCE_CHANNEL_NAME, WELCOME_CATEGORY_NAME, PASSWORDS_CHANNEL_NAME, @@ -26,6 +25,7 @@ class BotClient(discord.Client): guild: discord.Guild | discord.Object verified_role: discord.Role special_role: discord.Role + volunteer_role: discord.Role welcome_category: discord.CategoryChannel announce_channel: discord.TextChannel passwords_channel: discord.TextChannel @@ -63,6 +63,7 @@ async def on_ready(self) -> None: verified_role = discord.utils.get(guild.roles, name=VERIFIED_ROLE) special_role = discord.utils.get(guild.roles, name=SPECIAL_ROLE) + volunteer_role = discord.utils.get(guild.roles, name=VOLUNTEER_ROLE) welcome_category = discord.utils.get(guild.categories, name=WELCOME_CATEGORY_NAME) announce_channel = discord.utils.get(guild.text_channels, name=ANNOUNCE_CHANNEL_NAME) passwords_channel = discord.utils.get(guild.text_channels, name=PASSWORDS_CHANNEL_NAME) @@ -70,6 +71,7 @@ async def on_ready(self) -> None: if ( verified_role is None or special_role is None + or volunteer_role is None or welcome_category is None or announce_channel is None or passwords_channel is None @@ -79,6 +81,7 @@ async def on_ready(self) -> None: else: self.verified_role = verified_role self.special_role = special_role + self.volunteer_role = volunteer_role self.welcome_category = welcome_category self.announce_channel = announce_channel self.passwords_channel = passwords_channel diff --git a/src/commands/new_team.py b/src/commands/new_team.py index 7bd8329..835c051 100644 --- a/src/commands/new_team.py +++ b/src/commands/new_team.py @@ -1,14 +1,22 @@ +from typing import TYPE_CHECKING + import discord -from discord import app_commands, interactions -from discord.app_commands import locale_str +from discord import app_commands + +if TYPE_CHECKING: + from src.bot import BotClient -from src.constants import TEAM_CATEGORY_NAME, PASSWORDS_CHANNEL_NAME, ROLE_PREFIX +from src.constants import ( + ROLE_PREFIX, + TEAM_CATEGORY_NAME, + PASSWORDS_CHANNEL_NAME, +) REASON = "Created via command" @discord.app_commands.command( - name=locale_str('team_new', en='new team', de='team hinzufügen'), + name='team_new', description='Creates a role and channel for a team', extras={ 'default_member_permissions': 0, @@ -19,8 +27,12 @@ name='Name of the team', password="Password required for joining the team", ) -async def new_team(interaction: discord.interactions.Interaction, tla: str, name: str, password: str): - guild: discord.Guild = interaction.guild +async def new_team(interaction: discord.interactions.Interaction["BotClient"], tla: str, name: str, password: str) -> None: + guild: discord.Guild | None = interaction.guild + if guild is None: + await interaction.response.send_message("No guild found", ephemeral=True) + return + category = discord.utils.get(guild.categories, name=TEAM_CATEGORY_NAME) role_name = f"{ROLE_PREFIX}{tla.upper()}" @@ -34,20 +46,28 @@ async def new_team(interaction: discord.interactions.Interaction, tla: str, name ) channel = await guild.create_text_channel( reason=REASON, - name=f"team-{tla}", + name=f"team-{tla.lower()}", topic=name, category=category, overwrites={ guild.default_role: discord.PermissionOverwrite( - read_messages=False, - send_messages=False), - role: discord.PermissionOverwrite() + read_messages=False, + send_messages=False), + interaction.client.volunteer_role: discord.PermissionOverwrite( + read_messages=True, + send_messages=True, + ), + role: discord.PermissionOverwrite( + read_messages=True, + send_messages=True, + ) } ) await _save_password(guild, tla, password) - await interaction.response.send_message(f"<@&{role.id}> and <#{channel.id}> created!", ephemeral=True) + await interaction.response.send_message(f"{role.mention} and {channel.mention} created!", ephemeral=True) -async def _save_password(guild: discord.Guild, tla: str, password: str): - channel: discord.TextChannel = discord.utils.get(guild.channels, name=PASSWORDS_CHANNEL_NAME) - await channel.send(f"```{tla}:{password}```") +async def _save_password(guild: discord.Guild, tla: str, password: str) -> None: + channel: discord.TextChannel | None = discord.utils.get(guild.text_channels, name=PASSWORDS_CHANNEL_NAME) + if channel is not None: + await channel.send(f"```\n{tla}:{password}\n```") diff --git a/src/constants.py b/src/constants.py index 806e785..81349da 100644 --- a/src/constants.py +++ b/src/constants.py @@ -16,6 +16,8 @@ SPECIAL_TEAM = "SRZ" SPECIAL_ROLE = "Unverified Volunteer" +VOLUNTEER_ROLE = "Blueshirt" + PASSWORDS_CHANNEL_NAME = "role-passwords" TEAM_CATEGORY_NAME = "Team Channels" From b435e2adebcbe33daebefe30f856457717c0db20 Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Wed, 7 Aug 2024 20:11:03 +0200 Subject: [PATCH 16/31] Fix type errors in join command --- src/commands/join.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/commands/join.py b/src/commands/join.py index 4b35b9b..569d3bb 100644 --- a/src/commands/join.py +++ b/src/commands/join.py @@ -28,6 +28,10 @@ async def join(interaction: discord.Interaction["BotClient"], password: str) -> if member is None or isinstance(member, discord.User): return + if interaction.guild is None or not isinstance(interaction.channel, discord.TextChannel): + return + guild: discord.Guild = interaction.guild + channel: discord.TextChannel = interaction.channel if channel is None or not channel.name.startswith(CHANNEL_PREFIX): return @@ -49,7 +53,7 @@ async def join(interaction: discord.Interaction["BotClient"], password: str) -> role_name = f"{ROLE_PREFIX}{chosen_team}" # Add them to that specific role - specific_role = discord.utils.get(interaction.client.guild.roles, name=role_name) + specific_role = discord.utils.get(guild.roles, name=role_name) if specific_role is None: interaction.client.logger.error(f"Specified role '{chosen_team}' does not exist") else: @@ -74,7 +78,7 @@ async def join(interaction: discord.Interaction["BotClient"], password: str) -> await interaction.response.send_message("Incorrect password.", ephemeral=True) -async def find_team(client: "BotClient", member: discord.Member, entered: str) -> str: +async def find_team(client: "BotClient", member: discord.Member, entered: str) -> str | None: async for team_name, password in client.load_passwords(): if password in entered.lower(): client.logger.info( @@ -82,3 +86,4 @@ async def find_team(client: "BotClient", member: discord.Member, entered: str) - ) # Password was correct! return team_name + return None From bc11b625b02bfdb5023976ea3ecc5f2781f611f9 Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Wed, 7 Aug 2024 22:45:17 +0200 Subject: [PATCH 17/31] Use a subcommand for new team command --- src/bot.py | 3 ++- src/commands/{new_team.py => team.py} | 20 ++++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) rename src/commands/{new_team.py => team.py} (88%) diff --git a/src/bot.py b/src/bot.py index fa5dfc3..9ebc600 100644 --- a/src/bot.py +++ b/src/bot.py @@ -6,6 +6,7 @@ import discord from discord import app_commands +from discord.app_commands import locale_str from src.constants import ( SPECIAL_ROLE, @@ -17,7 +18,7 @@ PASSWORDS_CHANNEL_NAME, ) from src.commands.join import join -from src.commands.new_team import new_team +from src.commands.team import new_team class BotClient(discord.Client): diff --git a/src/commands/new_team.py b/src/commands/team.py similarity index 88% rename from src/commands/new_team.py rename to src/commands/team.py index 835c051..0ac6259 100644 --- a/src/commands/new_team.py +++ b/src/commands/team.py @@ -2,6 +2,8 @@ import discord from discord import app_commands +from discord.app_commands import locale_str +import discord.ext.commands as commands if TYPE_CHECKING: from src.bot import BotClient @@ -15,19 +17,25 @@ REASON = "Created via command" -@discord.app_commands.command( - name='team_new', +@app_commands.guild_only() +class Team(app_commands.Group): + pass + + +group = Team() + + +@group.command( + name='new', description='Creates a role and channel for a team', - extras={ - 'default_member_permissions': 0, - }, ) @app_commands.describe( tla='Three Letter Acronym (e.g. SRZ)', name='Name of the team', password="Password required for joining the team", ) -async def new_team(interaction: discord.interactions.Interaction["BotClient"], tla: str, name: str, password: str) -> None: +async def new_team(interaction: discord.interactions.Interaction["BotClient"], tla: str, name: str, + password: str) -> None: guild: discord.Guild | None = interaction.guild if guild is None: await interaction.response.send_message("No guild found", ephemeral=True) From c665b70aeb70765bac378ea828409ec8a9c5bf80 Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Wed, 7 Aug 2024 22:46:18 +0200 Subject: [PATCH 18/31] Disallow non-admins from creating teams --- src/commands/team.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/team.py b/src/commands/team.py index 0ac6259..da43c75 100644 --- a/src/commands/team.py +++ b/src/commands/team.py @@ -18,6 +18,7 @@ @app_commands.guild_only() +@app_commands.default_permissions() class Team(app_commands.Group): pass From dccc152673ec0e96e9c13207650c92fe6864fb79 Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Wed, 7 Aug 2024 22:54:08 +0200 Subject: [PATCH 19/31] Adjust README to match current setup --- README.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f7730d6..5cc4c24 100644 --- a/README.md +++ b/README.md @@ -17,19 +17,21 @@ For development, see `script/requirements.txt` # Discord server set-up instructions -- ensure the `everyone` role cannot see any channels by default. -- Create a role named `verified` which can see the base channels (i.e. #general) -- Create a role named `unverified-volunteer` which can see the volunteer onboarding channel. +- ensure the `@everyone` role cannot see any channels by default. +- Create a role named `Verified` which can see the base channels (i.e. #general) +- Create a role named `Unverified Volunteer` which can see the volunteer onboarding channel. +- Create a role named `Blueshirt`. - Create a new channel category called 'welcome', block all users from reading this category in its permissions. -- Create another channel, visible only to the admins, named '#role-passwords', enter in it 1 message per role in the form `role : password`. Special case: for the `unverified-volunteer` role, please use the role name `team-SRZ`. -- Create each role named `team-{role}`. +- Create another channel, visible only to the admins, named '#role-passwords', enter in it 1 message per role in the form `role : password`. Special case: for the `Unverified Volunteer` role, please use the role name `Team SRZ`. +- Create each role named `Team {role}`. -And voila, any new users should automatically get their role assigned once they enter the correct password. +And voilà, any new users should automatically get their role assigned once they enter the correct password. ## Install instructions 1. Set up discord to the correct settings (see above) 2. Register a discord bot. -3. Add an .env file with `DISCORD_TOKEN=` +3. Copy `.env` and fill it out with the application token and guild ID. In order to get the guild ID, you will need to enable developer mode in Discord's settings. Once enabled, right click the guild (server) in the sidebar and click `Copy Server ID`. 4. `pip install -r requirements.txt` -5. `python main.py` +5. `python src/main.py` +6. In the server settings, ensure the `/join` command cannot be used by the `Verified` role From 994e1860cd0e73631bc1201c4bef61ae6859acb0 Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Wed, 7 Aug 2024 23:26:10 +0200 Subject: [PATCH 20/31] Include who triggered team creation in audit log reason --- src/commands/team.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/commands/team.py b/src/commands/team.py index da43c75..e83edac 100644 --- a/src/commands/team.py +++ b/src/commands/team.py @@ -2,8 +2,6 @@ import discord from discord import app_commands -from discord.app_commands import locale_str -import discord.ext.commands as commands if TYPE_CHECKING: from src.bot import BotClient @@ -14,7 +12,7 @@ PASSWORDS_CHANNEL_NAME, ) -REASON = "Created via command" +REASON = "Created via command by " @app_commands.guild_only() @@ -50,11 +48,11 @@ async def new_team(interaction: discord.interactions.Interaction["BotClient"], t return role = await guild.create_role( - reason=REASON, + reason=REASON + interaction.user.name, name=role_name, ) channel = await guild.create_text_channel( - reason=REASON, + reason=REASON + interaction.user.name, name=f"team-{tla.lower()}", topic=name, category=category, From 8c289a23d44bfa74b89209cf6aea4e5c513559d6 Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Sat, 10 Aug 2024 11:15:30 +0200 Subject: [PATCH 21/31] Add command for deleting teams --- src/bot.py | 8 +++++--- src/commands/team.py | 46 ++++++++++++++++++++++++++++++++++++++++---- src/commands/ui.py | 19 ++++++++++++++++++ 3 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 src/commands/ui.py diff --git a/src/bot.py b/src/bot.py index 9ebc600..bca39a6 100644 --- a/src/bot.py +++ b/src/bot.py @@ -6,7 +6,6 @@ import discord from discord import app_commands -from discord.app_commands import locale_str from src.constants import ( SPECIAL_ROLE, @@ -18,7 +17,7 @@ PASSWORDS_CHANNEL_NAME, ) from src.commands.join import join -from src.commands.team import new_team +from src.commands.team import Team, new_team, delete_team class BotClient(discord.Client): @@ -46,7 +45,10 @@ def __init__( logger.error("Invalid guild ID") exit(1) self.guild = discord.Object(id=int(guild_id)) - self.tree.add_command(new_team, guild=self.guild) + team = Team() + team.add_command(new_team) + team.add_command(delete_team) + self.tree.add_command(team, guild=self.guild) self.tree.add_command(join, guild=self.guild) async def setup_hook(self) -> None: diff --git a/src/commands/team.py b/src/commands/team.py index e83edac..2ca53d5 100644 --- a/src/commands/team.py +++ b/src/commands/team.py @@ -3,6 +3,8 @@ import discord from discord import app_commands +from src.commands.ui import TeamDeleteConfirm + if TYPE_CHECKING: from src.bot import BotClient @@ -12,7 +14,7 @@ PASSWORDS_CHANNEL_NAME, ) -REASON = "Created via command by " +TEAM_CREATED_REASON = "Created via command by " @app_commands.guild_only() @@ -48,11 +50,11 @@ async def new_team(interaction: discord.interactions.Interaction["BotClient"], t return role = await guild.create_role( - reason=REASON + interaction.user.name, + reason=TEAM_CREATED_REASON + interaction.user.name, name=role_name, ) channel = await guild.create_text_channel( - reason=REASON + interaction.user.name, + reason=TEAM_CREATED_REASON + interaction.user.name, name=f"team-{tla.lower()}", topic=name, category=category, @@ -77,4 +79,40 @@ async def new_team(interaction: discord.interactions.Interaction["BotClient"], t async def _save_password(guild: discord.Guild, tla: str, password: str) -> None: channel: discord.TextChannel | None = discord.utils.get(guild.text_channels, name=PASSWORDS_CHANNEL_NAME) if channel is not None: - await channel.send(f"```\n{tla}:{password}\n```") + await channel.send(f"```\n{tla.upper()}:{password}\n```") + + +@group.command( + name='delete', + description='Deletes a role and channel for a team', +) +@app_commands.describe( + tla='Three Letter Acronym (e.g. SRZ)', +) +async def delete_team(interaction: discord.interactions.Interaction["BotClient"], tla: str) -> None: + guild: discord.Guild | None = interaction.guild + role: discord.Role | None = discord.utils.get(guild.roles, name=f"{ROLE_PREFIX}{tla.upper()}") + channel: discord.TextChannel | None = discord.utils.get(guild.text_channels, name=f"team-{tla.lower()}") + if guild is None: + return + + if role is None or channel is None: + await interaction.response.send_message(f"Team {tla.upper()} does not exist", ephemeral=True) + return + + view = TeamDeleteConfirm(guild, tla) + + await interaction.response.send_message(f"Are you sure you want to delete Team {tla.upper()}\n\n" + f"This will kick all of its members.", view=view, ephemeral=True) + await view.wait() + if view.value: + await interaction.edit_original_response(content=f"_Deleting Team {tla.upper()}..._", view=None) + guild: discord.Guild | None = interaction.guild + reason = f"Team removed by {interaction.user.name}" + if channel is not None and role is not None: + for member in role.members: + await member.send(f"Your {guild.name} has been removed.") + await member.kick(reason=reason) + await channel.delete(reason=reason) + await role.delete(reason=reason) + await interaction.edit_original_response(content=f"Team {tla.upper()} has been deleted") diff --git a/src/commands/ui.py b/src/commands/ui.py new file mode 100644 index 0000000..5975f9a --- /dev/null +++ b/src/commands/ui.py @@ -0,0 +1,19 @@ +import discord + + +class TeamDeleteConfirm(discord.ui.View): + def __init__(self, guild: discord.Guild, tla: str): + super().__init__() + self.guild: discord.Guild = guild + self.tla: str = tla + self.value: bool = False + + @discord.ui.button(label='Delete', style=discord.ButtonStyle.red) + async def confirm(self, interaction: discord.Interaction, item) -> None: + self.value = True + self.stop() + + @discord.ui.button(label='Cancel', style=discord.ButtonStyle.grey) + async def cancel(self, interaction: discord.Interaction, item) -> None: + await interaction.response.defer(ephemeral=True) + self.stop() From 3b1f0c91aa060484038042d6d9e8e284521d9d06 Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Sat, 10 Aug 2024 11:34:16 +0200 Subject: [PATCH 22/31] Fix kick message --- src/commands/team.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/team.py b/src/commands/team.py index 2ca53d5..1a6cdd2 100644 --- a/src/commands/team.py +++ b/src/commands/team.py @@ -111,7 +111,7 @@ async def delete_team(interaction: discord.interactions.Interaction["BotClient"] reason = f"Team removed by {interaction.user.name}" if channel is not None and role is not None: for member in role.members: - await member.send(f"Your {guild.name} has been removed.") + await member.send(f"Your {guild.name} team has been removed.") await member.kick(reason=reason) await channel.delete(reason=reason) await role.delete(reason=reason) From 19d8c2fc0c9abd19f4fb5fca8fcc6db8acb0869c Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Sat, 10 Aug 2024 11:40:49 +0200 Subject: [PATCH 23/31] Add command for creating voice channels --- src/bot.py | 3 ++- src/commands/team.py | 58 +++++++++++++++++++++++++++++++++----------- src/constants.py | 1 + 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/bot.py b/src/bot.py index bca39a6..c7509ed 100644 --- a/src/bot.py +++ b/src/bot.py @@ -17,7 +17,7 @@ PASSWORDS_CHANNEL_NAME, ) from src.commands.join import join -from src.commands.team import Team, new_team, delete_team +from src.commands.team import Team, new_team, delete_team, create_voice class BotClient(discord.Client): @@ -48,6 +48,7 @@ def __init__( team = Team() team.add_command(new_team) team.add_command(delete_team) + team.add_command(create_voice) self.tree.add_command(team, guild=self.guild) self.tree.add_command(join, guild=self.guild) diff --git a/src/commands/team.py b/src/commands/team.py index 1a6cdd2..95aba9c 100644 --- a/src/commands/team.py +++ b/src/commands/team.py @@ -11,7 +11,7 @@ from src.constants import ( ROLE_PREFIX, TEAM_CATEGORY_NAME, - PASSWORDS_CHANNEL_NAME, + PASSWORDS_CHANNEL_NAME, TEAM_VOICE_CATEGORY_NAME, ) TEAM_CREATED_REASON = "Created via command by " @@ -26,6 +26,22 @@ class Team(app_commands.Group): group = Team() +def permissions(client: "BotClient", team: discord.Role) -> dict[discord.Role, discord.PermissionOverwrite]: + return { + client.guild.default_role: discord.PermissionOverwrite( + read_messages=False, + send_messages=False), + client.volunteer_role: discord.PermissionOverwrite( + read_messages=True, + send_messages=True, + ), + team: discord.PermissionOverwrite( + read_messages=True, + send_messages=True, + ) + } + + @group.command( name='new', description='Creates a role and channel for a team', @@ -58,19 +74,7 @@ async def new_team(interaction: discord.interactions.Interaction["BotClient"], t name=f"team-{tla.lower()}", topic=name, category=category, - overwrites={ - guild.default_role: discord.PermissionOverwrite( - read_messages=False, - send_messages=False), - interaction.client.volunteer_role: discord.PermissionOverwrite( - read_messages=True, - send_messages=True, - ), - role: discord.PermissionOverwrite( - read_messages=True, - send_messages=True, - ) - } + overwrites=permissions(interaction.client, role) ) await _save_password(guild, tla, password) await interaction.response.send_message(f"{role.mention} and {channel.mention} created!", ephemeral=True) @@ -116,3 +120,29 @@ async def delete_team(interaction: discord.interactions.Interaction["BotClient"] await channel.delete(reason=reason) await role.delete(reason=reason) await interaction.edit_original_response(content=f"Team {tla.upper()} has been deleted") + + +@group.command( + name='voice', + description='Create a voice channel for a team', +) +@app_commands.describe( + tla='Three Letter Acronym (e.g. SRZ)', +) +async def create_voice(interaction: discord.interactions.Interaction["BotClient"], tla: str) -> None: + guild: discord.Guild | None = interaction.guild + role: discord.Role | None = discord.utils.get(guild.roles, name=f"{ROLE_PREFIX}{tla.upper()}") + if guild is None: + return + + if role is None: + await interaction.response.send_message(f"Team {tla.upper()} does not exist", ephemeral=True) + return + + category = discord.utils.get(guild.categories, name=TEAM_VOICE_CATEGORY_NAME) + channel = await guild.create_voice_channel( + f"team-{tla.lower()}", + category=category, + overwrites=permissions(interaction.client, role) + ) + await interaction.response.send_message(f"{channel.mention} created!", ephemeral=True) diff --git a/src/constants.py b/src/constants.py index 81349da..5415fcc 100644 --- a/src/constants.py +++ b/src/constants.py @@ -21,3 +21,4 @@ PASSWORDS_CHANNEL_NAME = "role-passwords" TEAM_CATEGORY_NAME = "Team Channels" +TEAM_VOICE_CATEGORY_NAME = "Team Voice Channels" From f78aefc1835f7423baea79690783b0c5c6adc954 Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Sat, 10 Aug 2024 11:42:27 +0200 Subject: [PATCH 24/31] Delete voice channels when deleting a team --- src/commands/team.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/commands/team.py b/src/commands/team.py index 95aba9c..f1d24a2 100644 --- a/src/commands/team.py +++ b/src/commands/team.py @@ -119,6 +119,12 @@ async def delete_team(interaction: discord.interactions.Interaction["BotClient"] await member.kick(reason=reason) await channel.delete(reason=reason) await role.delete(reason=reason) + + voice_channel: discord.VoiceChannel | None = discord.utils.get(guild.voice_channels, + name=f"team-{tla.lower()}") + if voice_channel is not None: + await voice_channel.delete() + await interaction.edit_original_response(content=f"Team {tla.upper()} has been deleted") From d1c7f0accb1c59bbc4dce2bc5165a5056e8bdeb3 Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Sat, 10 Aug 2024 11:49:03 +0200 Subject: [PATCH 25/31] Use `/join` in welcome message --- src/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bot.py b/src/bot.py index c7509ed..9b1e86b 100644 --- a/src/bot.py +++ b/src/bot.py @@ -109,7 +109,7 @@ async def on_member_join(self, member: discord.Member) -> None: ) await channel.send(textwrap.dedent( f"""Welcome {member.mention}! - To gain access, you must send a message in this channel with the password for your group. + To gain access, you must use `/join` with the password for your group. *Don't have the password? it should have been sent with this join link to your team leader* """, From d838f986622308940a497d1f1e1f8dd24d515a8c Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Sat, 10 Aug 2024 11:59:31 +0200 Subject: [PATCH 26/31] Fix auto-removing welcome channels --- src/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bot.py b/src/bot.py index 9b1e86b..1ef2ddf 100644 --- a/src/bot.py +++ b/src/bot.py @@ -121,7 +121,7 @@ async def on_member_remove(self, member: discord.Member) -> None: self.logger.info(f"Member '{name}' left") for channel in self.welcome_category.channels: # If the only user able to see it is the bot, then delete it. - if channel.overwrites.keys() == {member.guild.me}: + if channel.overwrites.keys() == {member.guild.default_role, member.guild.me}: await channel.delete() self.logger.info(f"Deleted channel '{channel.name}', because it has no users.") From 666886bb82ae80d97d5da30064a38442e6b2b8fa Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Sat, 10 Aug 2024 15:59:46 +0200 Subject: [PATCH 27/31] Create command for creating secondary text channels --- src/bot.py | 3 +- src/commands/team.py | 84 +++++++++++++++++++++++++++++++------------- 2 files changed, 61 insertions(+), 26 deletions(-) diff --git a/src/bot.py b/src/bot.py index 1ef2ddf..4d21cbf 100644 --- a/src/bot.py +++ b/src/bot.py @@ -17,7 +17,7 @@ PASSWORDS_CHANNEL_NAME, ) from src.commands.join import join -from src.commands.team import Team, new_team, delete_team, create_voice +from src.commands.team import Team, new_team, delete_team, create_voice, create_team_channel class BotClient(discord.Client): @@ -49,6 +49,7 @@ def __init__( team.add_command(new_team) team.add_command(delete_team) team.add_command(create_voice) + team.add_command(create_team_channel) self.tree.add_command(team, guild=self.guild) self.tree.add_command(join, guild=self.guild) diff --git a/src/commands/team.py b/src/commands/team.py index f1d24a2..344fd02 100644 --- a/src/commands/team.py +++ b/src/commands/team.py @@ -1,7 +1,7 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Iterable import discord -from discord import app_commands +from discord import app_commands, role from src.commands.ui import TeamDeleteConfirm @@ -11,7 +11,8 @@ from src.constants import ( ROLE_PREFIX, TEAM_CATEGORY_NAME, - PASSWORDS_CHANNEL_NAME, TEAM_VOICE_CATEGORY_NAME, + PASSWORDS_CHANNEL_NAME, + TEAM_VOICE_CATEGORY_NAME, ) TEAM_CREATED_REASON = "Created via command by " @@ -28,18 +29,18 @@ class Team(app_commands.Group): def permissions(client: "BotClient", team: discord.Role) -> dict[discord.Role, discord.PermissionOverwrite]: return { - client.guild.default_role: discord.PermissionOverwrite( - read_messages=False, - send_messages=False), - client.volunteer_role: discord.PermissionOverwrite( - read_messages=True, - send_messages=True, - ), - team: discord.PermissionOverwrite( - read_messages=True, - send_messages=True, - ) - } + client.guild.default_role: discord.PermissionOverwrite( + read_messages=False, + send_messages=False), + client.volunteer_role: discord.PermissionOverwrite( + read_messages=True, + send_messages=True, + ), + team: discord.PermissionOverwrite( + read_messages=True, + send_messages=True, + ) + } @group.command( @@ -96,11 +97,10 @@ async def _save_password(guild: discord.Guild, tla: str, password: str) -> None: async def delete_team(interaction: discord.interactions.Interaction["BotClient"], tla: str) -> None: guild: discord.Guild | None = interaction.guild role: discord.Role | None = discord.utils.get(guild.roles, name=f"{ROLE_PREFIX}{tla.upper()}") - channel: discord.TextChannel | None = discord.utils.get(guild.text_channels, name=f"team-{tla.lower()}") if guild is None: return - if role is None or channel is None: + if role is None: await interaction.response.send_message(f"Team {tla.upper()} does not exist", ephemeral=True) return @@ -113,19 +113,19 @@ async def delete_team(interaction: discord.interactions.Interaction["BotClient"] await interaction.edit_original_response(content=f"_Deleting Team {tla.upper()}..._", view=None) guild: discord.Guild | None = interaction.guild reason = f"Team removed by {interaction.user.name}" - if channel is not None and role is not None: + if role is not None: for member in role.members: await member.send(f"Your {guild.name} team has been removed.") await member.kick(reason=reason) - await channel.delete(reason=reason) - await role.delete(reason=reason) - voice_channel: discord.VoiceChannel | None = discord.utils.get(guild.voice_channels, - name=f"team-{tla.lower()}") - if voice_channel is not None: - await voice_channel.delete() + for channel in guild.channels: + if channel.name.startswith(f"team-{tla.lower()}"): + await channel.delete(reason=reason) + + await role.delete(reason=reason) - await interaction.edit_original_response(content=f"Team {tla.upper()} has been deleted") + if not interaction.channel.name.startswith(f"team-{tla.lower()}"): + await interaction.edit_original_response(content=f"Team {tla.upper()} has been deleted") @group.command( @@ -152,3 +152,37 @@ async def create_voice(interaction: discord.interactions.Interaction["BotClient" overwrites=permissions(interaction.client, role) ) await interaction.response.send_message(f"{channel.mention} created!", ephemeral=True) + + +@group.command( + name='channel', + description='Create a secondary channel for a team', +) +@app_commands.describe( + tla='Three Letter Acronym (e.g. SRZ)', + suffix='Channel name suffix (e.g. design)', +) +async def create_team_channel( + interaction: discord.interactions.Interaction["BotClient"], + tla: str, + suffix: str, +) -> None: + guild: discord.Guild | None = interaction.guild + if guild is None: + return + + main_channel = discord.utils.get(guild.text_channels, name=f"team-{tla.lower()}") + category = discord.utils.get(guild.categories, name=TEAM_CATEGORY_NAME) + + if category is None or main_channel is None: + await interaction.response.send_message(f"Team {tla.upper()} does not exist", ephemeral=True) + return + + new_channel = await guild.create_text_channel( + name=f"team-{tla.lower()}-{suffix.lower()}", + category=category, + overwrites=main_channel.overwrites, + position=main_channel.position + 1, + reason=TEAM_CREATED_REASON + ) + await interaction.response.send_message(f"{new_channel.mention} created!", ephemeral=True) From 75d08c2c9d712f793c22763bc43dbc4220af43a6 Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Thu, 15 Aug 2024 10:07:08 +0200 Subject: [PATCH 28/31] Add command for exporting teams --- src/bot.py | 3 ++- src/commands/team.py | 55 +++++++++++++++++++++++++++++++++++++++++++- src/constants.py | 1 + 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/bot.py b/src/bot.py index 4d21cbf..25b156f 100644 --- a/src/bot.py +++ b/src/bot.py @@ -17,7 +17,7 @@ PASSWORDS_CHANNEL_NAME, ) from src.commands.join import join -from src.commands.team import Team, new_team, delete_team, create_voice, create_team_channel +from src.commands.team import Team, new_team, delete_team, create_voice, create_team_channel, export_team class BotClient(discord.Client): @@ -50,6 +50,7 @@ def __init__( team.add_command(delete_team) team.add_command(create_voice) team.add_command(create_team_channel) + team.add_command(export_team) self.tree.add_command(team, guild=self.guild) self.tree.add_command(join, guild=self.guild) diff --git a/src/commands/team.py b/src/commands/team.py index 344fd02..85c740b 100644 --- a/src/commands/team.py +++ b/src/commands/team.py @@ -12,7 +12,7 @@ ROLE_PREFIX, TEAM_CATEGORY_NAME, PASSWORDS_CHANNEL_NAME, - TEAM_VOICE_CATEGORY_NAME, + TEAM_VOICE_CATEGORY_NAME, TEAM_LEADER_ROLE, ) TEAM_CREATED_REASON = "Created via command by " @@ -186,3 +186,56 @@ async def create_team_channel( reason=TEAM_CREATED_REASON ) await interaction.response.send_message(f"{new_channel.mention} created!", ephemeral=True) + + +@group.command( + name='export', + description='Outputs all commands needed to create a team (or all teams)', +) +@app_commands.describe( + tla='Three Letter Acronym (e.g. SRZ)', + only_teams="Only creates teams without extra channels", +) +async def export_team( + interaction: discord.interactions.Interaction["BotClient"], + tla: str | None = None, + only_teams: bool = False, +) -> None: + guild: discord.Guild | None = interaction.guild + if guild is None: + raise app_commands.NoPrivateMessage() + + await interaction.response.defer(thinking=True, ephemeral=True) + + async def _find_password(team_tla: str) -> str: + async for team_name, password in interaction.client.load_passwords(): + if team_name == team_tla: + return password + + async def _export_team(team_tla: str) -> str: + main_channel = discord.utils.get(guild.text_channels, name=f"team-{team_tla.lower()}") + password = await _find_password(team_tla) + commands = [f"/team new tla:{team_tla} name:{main_channel.topic} password:{password}"] + + if not only_teams: + channels = filter(lambda c: c.name.startswith(f"team-{team_tla.lower()}-"), guild.text_channels) + for channel in channels: + suffix = channel.name.removeprefix(f"team-{team_tla.lower()}-") + commands.append(f"/team channel tla:{team_tla} suffix:{suffix}") + + has_voice: bool = discord.utils.get(guild.voice_channels, name=f"team-{team_tla.lower()}") is not None + if has_voice: + commands.append(f"/team voice tla:{team_tla}") + + return "\n".join(commands) + "\n" + + output = "```\n" + + if tla is None: + for team_role in guild.roles: + if team_role.name.startswith(ROLE_PREFIX) and team_role.name != TEAM_LEADER_ROLE: + output = output + await _export_team(team_role.name.removeprefix(ROLE_PREFIX)) + else: + output = output + await _export_team(tla) + output = output + "\n```" + await interaction.followup.send(content=output, ephemeral=True) diff --git a/src/constants.py b/src/constants.py index 5415fcc..d129ec4 100644 --- a/src/constants.py +++ b/src/constants.py @@ -22,3 +22,4 @@ TEAM_CATEGORY_NAME = "Team Channels" TEAM_VOICE_CATEGORY_NAME = "Team Voice Channels" +TEAM_LEADER_ROLE = "Team Supervisor" From 5faac338ab8dba441af7176fa8a02425cb0d19cb Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Fri, 16 Aug 2024 10:31:47 +0200 Subject: [PATCH 29/31] Fix lint/typing errors --- src/bot.py | 9 +++- src/commands/join.py | 2 +- src/commands/team.py | 114 ++++++++++++++++++++++++++----------------- src/commands/ui.py | 9 +++- 4 files changed, 86 insertions(+), 48 deletions(-) diff --git a/src/bot.py b/src/bot.py index 25b156f..0e6b5d5 100644 --- a/src/bot.py +++ b/src/bot.py @@ -17,7 +17,14 @@ PASSWORDS_CHANNEL_NAME, ) from src.commands.join import join -from src.commands.team import Team, new_team, delete_team, create_voice, create_team_channel, export_team +from src.commands.team import ( + Team, + new_team, + delete_team, + export_team, + create_voice, + create_team_channel, +) class BotClient(discord.Client): diff --git a/src/commands/join.py b/src/commands/join.py index 569d3bb..8442c28 100644 --- a/src/commands/join.py +++ b/src/commands/join.py @@ -16,7 +16,7 @@ REASON = "A correct password was entered." -@discord.app_commands.command( +@discord.app_commands.command( # type:ignore[arg-type] name='join', description='Join a team using a password', ) diff --git a/src/commands/team.py b/src/commands/team.py index 85c740b..de0f9a5 100644 --- a/src/commands/team.py +++ b/src/commands/team.py @@ -1,7 +1,7 @@ -from typing import TYPE_CHECKING, Iterable +from typing import TYPE_CHECKING, Mapping import discord -from discord import app_commands, role +from discord import app_commands from src.commands.ui import TeamDeleteConfirm @@ -10,9 +10,10 @@ from src.constants import ( ROLE_PREFIX, + TEAM_LEADER_ROLE, TEAM_CATEGORY_NAME, PASSWORDS_CHANNEL_NAME, - TEAM_VOICE_CATEGORY_NAME, TEAM_LEADER_ROLE, + TEAM_VOICE_CATEGORY_NAME, ) TEAM_CREATED_REASON = "Created via command by " @@ -27,11 +28,16 @@ class Team(app_commands.Group): group = Team() -def permissions(client: "BotClient", team: discord.Role) -> dict[discord.Role, discord.PermissionOverwrite]: +def permissions(client: "BotClient", team: discord.Role) -> Mapping[ + discord.Role | discord.Member, discord.PermissionOverwrite]: + if not isinstance(client.guild, discord.Guild): + return {} + return { client.guild.default_role: discord.PermissionOverwrite( read_messages=False, - send_messages=False), + send_messages=False + ), client.volunteer_role: discord.PermissionOverwrite( read_messages=True, send_messages=True, @@ -43,7 +49,7 @@ def permissions(client: "BotClient", team: discord.Role) -> dict[discord.Role, d } -@group.command( +@group.command( # type:ignore[arg-type] name='new', description='Creates a role and channel for a team', ) @@ -56,8 +62,7 @@ async def new_team(interaction: discord.interactions.Interaction["BotClient"], t password: str) -> None: guild: discord.Guild | None = interaction.guild if guild is None: - await interaction.response.send_message("No guild found", ephemeral=True) - return + raise app_commands.NoPrivateMessage() category = discord.utils.get(guild.categories, name=TEAM_CATEGORY_NAME) role_name = f"{ROLE_PREFIX}{tla.upper()}" @@ -87,7 +92,7 @@ async def _save_password(guild: discord.Guild, tla: str, password: str) -> None: await channel.send(f"```\n{tla.upper()}:{password}\n```") -@group.command( +@group.command( # type:ignore[arg-type] name='delete', description='Deletes a role and channel for a team', ) @@ -96,9 +101,9 @@ async def _save_password(guild: discord.Guild, tla: str, password: str) -> None: ) async def delete_team(interaction: discord.interactions.Interaction["BotClient"], tla: str) -> None: guild: discord.Guild | None = interaction.guild - role: discord.Role | None = discord.utils.get(guild.roles, name=f"{ROLE_PREFIX}{tla.upper()}") if guild is None: - return + raise app_commands.NoPrivateMessage() + role: discord.Role | None = discord.utils.get(guild.roles, name=f"{ROLE_PREFIX}{tla.upper()}") if role is None: await interaction.response.send_message(f"Team {tla.upper()} does not exist", ephemeral=True) @@ -106,12 +111,11 @@ async def delete_team(interaction: discord.interactions.Interaction["BotClient"] view = TeamDeleteConfirm(guild, tla) - await interaction.response.send_message(f"Are you sure you want to delete Team {tla.upper()}\n\n" + await interaction.response.send_message(f"Are you sure you want to delete Team {tla.upper()}?\n\n" f"This will kick all of its members.", view=view, ephemeral=True) await view.wait() if view.value: await interaction.edit_original_response(content=f"_Deleting Team {tla.upper()}..._", view=None) - guild: discord.Guild | None = interaction.guild reason = f"Team removed by {interaction.user.name}" if role is not None: for member in role.members: @@ -124,11 +128,13 @@ async def delete_team(interaction: discord.interactions.Interaction["BotClient"] await role.delete(reason=reason) - if not interaction.channel.name.startswith(f"team-{tla.lower()}"): + if isinstance(interaction.channel, discord.abc.GuildChannel) and not interaction.channel.name.startswith(f"team-{tla.lower()}"): await interaction.edit_original_response(content=f"Team {tla.upper()} has been deleted") + else: + await interaction.delete_original_response() -@group.command( +@group.command( # type:ignore[arg-type] name='voice', description='Create a voice channel for a team', ) @@ -137,10 +143,10 @@ async def delete_team(interaction: discord.interactions.Interaction["BotClient"] ) async def create_voice(interaction: discord.interactions.Interaction["BotClient"], tla: str) -> None: guild: discord.Guild | None = interaction.guild - role: discord.Role | None = discord.utils.get(guild.roles, name=f"{ROLE_PREFIX}{tla.upper()}") if guild is None: - return + raise app_commands.NoPrivateMessage() + role: discord.Role | None = discord.utils.get(guild.roles, name=f"{ROLE_PREFIX}{tla.upper()}") if role is None: await interaction.response.send_message(f"Team {tla.upper()} does not exist", ephemeral=True) return @@ -154,7 +160,7 @@ async def create_voice(interaction: discord.interactions.Interaction["BotClient" await interaction.response.send_message(f"{channel.mention} created!", ephemeral=True) -@group.command( +@group.command( # type:ignore[arg-type] name='channel', description='Create a secondary channel for a team', ) @@ -169,6 +175,11 @@ async def create_team_channel( ) -> None: guild: discord.Guild | None = interaction.guild if guild is None: + raise app_commands.NoPrivateMessage() + + role: discord.Role | None = discord.utils.get(guild.roles, name=f"{ROLE_PREFIX}{tla.upper()}") + if role is None: + await interaction.response.send_message("Team does not exist", ephemeral=True) return main_channel = discord.utils.get(guild.text_channels, name=f"team-{tla.lower()}") @@ -181,14 +192,51 @@ async def create_team_channel( new_channel = await guild.create_text_channel( name=f"team-{tla.lower()}-{suffix.lower()}", category=category, - overwrites=main_channel.overwrites, + overwrites=permissions(interaction.client, role), position=main_channel.position + 1, reason=TEAM_CREATED_REASON ) await interaction.response.send_message(f"{new_channel.mention} created!", ephemeral=True) -@group.command( +async def _find_password( + team_tla: str, + interaction: discord.interactions.Interaction["BotClient"], +) -> str: + async for team_name, password in interaction.client.load_passwords(): + if team_name == team_tla: + return password + return "" + + +async def _export_team( + team_tla: str, + only_teams: bool, + guild: discord.Guild, + interaction: discord.interactions.Interaction["BotClient"], +) -> str: + main_channel = discord.utils.get(guild.text_channels, name=f"team-{team_tla.lower()}") + if main_channel is None and not isinstance(main_channel, discord.abc.GuildChannel): + raise app_commands.AppCommandError("Invalid TLA") + + password = await _find_password(team_tla, interaction) + commands = [f"/team new tla:{team_tla} name:{main_channel.topic} password:{password}"] + + if not only_teams: + channels = filter(lambda c: c.name.startswith(f"team-{team_tla.lower()}-"), guild.text_channels) + for channel in channels: + suffix = channel.name.removeprefix(f"team-{team_tla.lower()}-") + commands.append(f"/team channel tla:{team_tla} suffix:{suffix}") + + has_voice: bool = discord.utils.get(guild.voice_channels, name=f"team-{team_tla.lower()}") is not None + if has_voice: + commands.append(f"/team voice tla:{team_tla}") + + return "\n".join(commands) + "\n" + return "" + + +@group.command( # type:ignore[arg-type] name='export', description='Outputs all commands needed to create a team (or all teams)', ) @@ -207,35 +255,13 @@ async def export_team( await interaction.response.defer(thinking=True, ephemeral=True) - async def _find_password(team_tla: str) -> str: - async for team_name, password in interaction.client.load_passwords(): - if team_name == team_tla: - return password - - async def _export_team(team_tla: str) -> str: - main_channel = discord.utils.get(guild.text_channels, name=f"team-{team_tla.lower()}") - password = await _find_password(team_tla) - commands = [f"/team new tla:{team_tla} name:{main_channel.topic} password:{password}"] - - if not only_teams: - channels = filter(lambda c: c.name.startswith(f"team-{team_tla.lower()}-"), guild.text_channels) - for channel in channels: - suffix = channel.name.removeprefix(f"team-{team_tla.lower()}-") - commands.append(f"/team channel tla:{team_tla} suffix:{suffix}") - - has_voice: bool = discord.utils.get(guild.voice_channels, name=f"team-{team_tla.lower()}") is not None - if has_voice: - commands.append(f"/team voice tla:{team_tla}") - - return "\n".join(commands) + "\n" - output = "```\n" if tla is None: for team_role in guild.roles: if team_role.name.startswith(ROLE_PREFIX) and team_role.name != TEAM_LEADER_ROLE: - output = output + await _export_team(team_role.name.removeprefix(ROLE_PREFIX)) + output = output + await _export_team(team_role.name.removeprefix(ROLE_PREFIX), only_teams, guild, interaction) else: - output = output + await _export_team(tla) + output = output + await _export_team(tla, only_teams, guild, interaction) output = output + "\n```" await interaction.followup.send(content=output, ephemeral=True) diff --git a/src/commands/ui.py b/src/commands/ui.py index 5975f9a..d2d0efb 100644 --- a/src/commands/ui.py +++ b/src/commands/ui.py @@ -1,5 +1,10 @@ +from typing import TYPE_CHECKING + import discord +if TYPE_CHECKING: + from src.bot import BotClient + class TeamDeleteConfirm(discord.ui.View): def __init__(self, guild: discord.Guild, tla: str): @@ -9,11 +14,11 @@ def __init__(self, guild: discord.Guild, tla: str): self.value: bool = False @discord.ui.button(label='Delete', style=discord.ButtonStyle.red) - async def confirm(self, interaction: discord.Interaction, item) -> None: + async def confirm(self, interaction: discord.interactions.Interaction["BotClient"], item: discord.ui.Item[discord.ui.View]) -> None: self.value = True self.stop() @discord.ui.button(label='Cancel', style=discord.ButtonStyle.grey) - async def cancel(self, interaction: discord.Interaction, item) -> None: + async def cancel(self, interaction: discord.interactions.Interaction["BotClient"], item: discord.ui.Item[discord.ui.View]) -> None: await interaction.response.defer(ephemeral=True) self.stop() From 71a53e634fce74c0ce32857bab122848ba648125 Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Sat, 17 Aug 2024 19:43:53 +0200 Subject: [PATCH 30/31] Fix indentation on welcome message --- src/bot.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/bot.py b/src/bot.py index 0e6b5d5..37d00a3 100644 --- a/src/bot.py +++ b/src/bot.py @@ -1,7 +1,6 @@ import os import asyncio import logging -import textwrap from typing import Tuple, AsyncGenerator import discord @@ -116,13 +115,12 @@ async def on_member_join(self, member: discord.Member) -> None: guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True), }, ) - await channel.send(textwrap.dedent( + await channel.send( f"""Welcome {member.mention}! - To gain access, you must use `/join` with the password for your group. +To gain access, you must use `/join` with the password for your group. - *Don't have the password? it should have been sent with this join link to your team leader* - """, - )) +*Don't have the password? it should have been sent with this join link to your team leader*""", + ) self.logger.info(f"Created welcome channel for '{name}'") async def on_member_remove(self, member: discord.Member) -> None: From e585d6de558267e2ddeb4d156ce56e9478373802 Mon Sep 17 00:00:00 2001 From: "Karina J. Kwiatek" Date: Wed, 21 Aug 2024 22:06:39 +0200 Subject: [PATCH 31/31] Address review comments Co-authored-by: Andy Barrett-Sprot --- example.env | 2 +- src/commands/join.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/example.env b/example.env index d7ef59b..248b1d6 100644 --- a/example.env +++ b/example.env @@ -1,2 +1,2 @@ -DISCORD_TOKEN=BLAHBLAH +DISCORD_TOKEN= DISCORD_GUILD_ID= diff --git a/src/commands/join.py b/src/commands/join.py index 8442c28..0db50d2 100644 --- a/src/commands/join.py +++ b/src/commands/join.py @@ -18,7 +18,7 @@ @discord.app_commands.command( # type:ignore[arg-type] name='join', - description='Join a team using a password', + description='Use your password to join the server under your team', ) @app_commands.describe( password="Your team's password", @@ -28,12 +28,12 @@ async def join(interaction: discord.Interaction["BotClient"], password: str) -> if member is None or isinstance(member, discord.User): return - if interaction.guild is None or not isinstance(interaction.channel, discord.TextChannel): - return - guild: discord.Guild = interaction.guild - - channel: discord.TextChannel = interaction.channel - if channel is None or not channel.name.startswith(CHANNEL_PREFIX): + guild: discord.Guild | None = interaction.guild + channel: discord.interactions.InteractionChannel | None = interaction.channel + if (guild is None + or not isinstance(channel, discord.TextChannel) + or channel is None + or not channel.name.startswith(CHANNEL_PREFIX)): return chosen_team = await find_team(interaction.client, member, password)