diff --git a/.gitignore b/.gitignore index 7686201..b26dfd7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ log.log .env seen_posts.txt +passwords.json diff --git a/README.md b/README.md index 5cc4c24..34f6aec 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,11 @@ For development, see `script/requirements.txt` - 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 a role named `Team Supervisor`. +- Create a new channel category called `welcome`, block all users from reading this category in its permissions. +- Create channel categories called `Team Channels` and `Team Voice Channels`. +- Create a channel named `#blog`, block all users from sending messages in it. +- Create teams using the `/team new` command. And voilĂ , any new users should automatically get their role assigned once they enter the correct password. @@ -35,3 +37,4 @@ And voilĂ , any new users should automatically get their role assigned once they 4. `pip install -r requirements.txt` 5. `python src/main.py` 6. In the server settings, ensure the `/join` command cannot be used by the `Verified` role +7. Ensure the `/passwd` commands can only be used by `Blueshirt`s diff --git a/src/bot.py b/src/bot.py index 3e94cb3..b5a32c8 100644 --- a/src/bot.py +++ b/src/bot.py @@ -1,7 +1,7 @@ import os +import json import asyncio import logging -from typing import Tuple, AsyncGenerator import discord from discord import app_commands @@ -17,7 +17,6 @@ FEED_CHECK_INTERVAL, ANNOUNCE_CHANNEL_NAME, WELCOME_CATEGORY_NAME, - PASSWORDS_CHANNEL_NAME, ) from src.commands.join import join from src.commands.logs import logs @@ -29,6 +28,7 @@ create_voice, create_team_channel, ) +from src.commands.passwd import passwd class BotClient(discord.Client): @@ -39,7 +39,7 @@ class BotClient(discord.Client): volunteer_role: discord.Role welcome_category: discord.CategoryChannel announce_channel: discord.TextChannel - passwords_channel: discord.TextChannel + passwords: dict[str, str] feed_channel: discord.TextChannel def __init__( @@ -64,8 +64,10 @@ def __init__( team.add_command(create_team_channel) team.add_command(export_team) self.tree.add_command(team, guild=self.guild) + self.tree.add_command(passwd, guild=self.guild) self.tree.add_command(join, guild=self.guild) self.tree.add_command(logs, guild=self.guild) + self.load_passwords() async def setup_hook(self) -> None: # This copies the global commands over to your guild. @@ -86,7 +88,6 @@ async def on_ready(self) -> None: 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) feed_channel = discord.utils.get(guild.text_channels, name=FEED_CHANNEL_NAME) if ( @@ -95,7 +96,6 @@ async def on_ready(self) -> None: or volunteer_role is None or welcome_category is None or announce_channel is None - or passwords_channel is None or feed_channel is None ): logging.error("Roles and channels are not set up") @@ -106,7 +106,6 @@ async def on_ready(self) -> None: self.volunteer_role = volunteer_role self.welcome_category = welcome_category self.announce_channel = announce_channel - self.passwords_channel = passwords_channel self.feed_channel = feed_channel async def on_member_join(self, member: discord.Member) -> None: @@ -152,19 +151,29 @@ async def check_for_new_blog_posts(self) -> None: async def before_check_for_new_blog_posts(self) -> None: await self.wait_until_ready() - async def load_passwords(self) -> AsyncGenerator[Tuple[str, str], None]: + def load_passwords(self) -> 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 ``` """ - message: discord.Message - async for message in self.passwords_channel.history(limit=100, oldest_first=True): - if message.content.startswith('```'): - content: str = message.content.replace('`', '').strip() - team, password = content.split(':') - yield team.strip(), password.strip() + try: + with open('passwords.json') as f: + self.passwords = json.load(f) + except (json.JSONDecodeError, FileNotFoundError): + with open('passwords.json', 'w') as f: + f.write('{}') + self.passwords = {} + + def set_password(self, tla: str, password: str) -> None: + self.passwords[tla.upper()] = password + with open('passwords.json', 'w') as f: + json.dump(self.passwords, f) + + def remove_password(self, tla: str) -> None: + del self.passwords[tla.upper()] + with open('passwords.json', 'w') as f: + json.dump(self.passwords, f) diff --git a/src/commands/join.py b/src/commands/join.py index 0db50d2..9c8c30c 100644 --- a/src/commands/join.py +++ b/src/commands/join.py @@ -36,7 +36,7 @@ async def join(interaction: discord.Interaction["BotClient"], password: str) -> or not channel.name.startswith(CHANNEL_PREFIX)): return - chosen_team = await find_team(interaction.client, member, password) + chosen_team = find_team(interaction.client, member, password) if chosen_team: if chosen_team == SPECIAL_TEAM: role_name = SPECIAL_ROLE @@ -75,15 +75,15 @@ async def join(interaction: discord.Interaction["BotClient"], password: str) -> f"deleted channel '{channel.name}' because verification has completed.", ) else: - await interaction.response.send_message("Incorrect password.", ephemeral=True) + await interaction.response.send_message("Incorrect password.") -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(): +def find_team(client: "BotClient", member: discord.Member, entered: str) -> str | None: + for team_name, password in client.passwords.items(): + if entered == password: client.logger.info( f"'{member.name}' entered the correct password for {team_name}", ) # Password was correct! return team_name - return None + return "" diff --git a/src/commands/passwd.py b/src/commands/passwd.py new file mode 100644 index 0000000..27c79de --- /dev/null +++ b/src/commands/passwd.py @@ -0,0 +1,39 @@ +from typing import TYPE_CHECKING + +import discord +from discord import app_commands + +if TYPE_CHECKING: + from src.bot import BotClient + +@app_commands.command( # type:ignore[arg-type] + name='passwd', + description='Outputs or changes team passwords', +) +@app_commands.describe( + tla='Three Letter Acronym (e.g. SRZ)', + new_password='New password', +) +async def passwd( + interaction: discord.interactions.Interaction["BotClient"], + tla: str | None = None, + new_password: str | None = None, +) -> None: + if tla is None: + await interaction.response.send_message( + '\n'.join([f"**{team}:** {password}" for team, password in interaction.client.passwords.items()]), + ephemeral=True, + ) + else: + if new_password is not None: + if isinstance(interaction.user, discord.Member) and not interaction.user.guild_permissions.administrator: + await interaction.response.send_message( + "You do not have permission to change team passwords.", + ephemeral=True + ) + return + interaction.client.set_password(tla, new_password) + await interaction.response.send_message(f"The password for {tla.upper()} has been changed.", ephemeral=True) + else: + password = interaction.client.passwords[tla] + await interaction.response.send_message(f"The password for {tla.upper()} is `{password}`", ephemeral=True) diff --git a/src/commands/team.py b/src/commands/team.py index 9678d9d..2029c1b 100644 --- a/src/commands/team.py +++ b/src/commands/team.py @@ -13,7 +13,6 @@ TEAM_LEADER_ROLE, TEAM_CATEGORY_NAME, TEAM_CHANNEL_PREFIX, - PASSWORDS_CHANNEL_NAME, TEAM_VOICE_CATEGORY_NAME, ) @@ -23,7 +22,8 @@ @app_commands.guild_only() @app_commands.default_permissions() class Team(app_commands.Group): - pass + def __init__(self) -> None: + super().__init__(description="Manage teams") group = Team() @@ -83,16 +83,10 @@ async def new_team(interaction: discord.interactions.Interaction["BotClient"], t category=category, overwrites=permissions(interaction.client, role) ) - await _save_password(guild, tla, password) + interaction.client.set_password(tla, password) 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) -> 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.upper()}:{password}\n```") - - @group.command( # type:ignore[arg-type] name='delete', description='Deletes a role and channel for a team', @@ -128,6 +122,7 @@ async def delete_team(interaction: discord.interactions.Interaction["BotClient"] await channel.delete(reason=reason) await role.delete(reason=reason) + interaction.client.remove_password(tla) if isinstance(interaction.channel, discord.abc.GuildChannel) and not interaction.channel.name.startswith(f"{TEAM_CHANNEL_PREFIX}{tla.lower()}"): await interaction.edit_original_response(content=f"Team {tla.upper()} has been deleted") @@ -200,16 +195,6 @@ async def create_team_channel( await interaction.response.send_message(f"{new_channel.mention} created!", ephemeral=True) -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, @@ -220,7 +205,7 @@ async def _export_team( 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) + password = interaction.client.passwords[team_tla] commands = [f"/team new tla:{team_tla} name:{main_channel.topic} password:{password}"] if not only_teams: