diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5ebe6da --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.env +.github/ +.vscode/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index b6e4761..6738b9d 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ dmypy.json # Pyre type checker .pyre/ + +# Editors +.vscode/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..11abd4b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.9-slim-buster +WORKDIR /app +COPY requirements.txt . +RUN python3 -m pip install -r requirements.txt + +COPY bot/ . + +ENV token=$TOKEN + +CMD ["python3", "./bot.py"] \ No newline at end of file diff --git a/README.md b/README.md index d95c9dd..e34b941 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,25 @@ -# lovelace -oSTEM's custom Discord bot, Lovelace. Currently in an alpha state with major rewrites and reorganization to come. +# Lovelace +oSTEM's custom Discord bot, Lovelace. Happily accepting contributions! -## Set-up Requirements -This is a Python Discord bot that uses the discord.py framework. -- Python 3.6+ +This bot uses the discord.py library for the basic bot functionality and uses dislash.py for Slash Commands support. + +The main purpose of the bot is to provide functionality for joining Affinity and Working groups securely + +# Set-up Requirements +This is a Python Discord bot that uses the discord.py and dislash.py frameworks. +It requires python 3.6+ but it currently using what is specified in the `Dockerfile`. + +Additional, this bot uses Docker to run the application. +The commands to build the docker image and to run the container are as follows: + +```bash +docker build -t lovelace . +``` -Run the command ```bash -python -m pip install -r requirements.txt +docker run -e "TOKEN=token_goes_here" lovelace ``` -to install all of the dependencies of this project +The `register_command.py` file is required to run once and requires an adjustment to the `Dockerfile`, specifically the file for `CMD`. This registers the Slash Commands for the specified Guild. -## Running the bot on a test server -This bot is tightly coupled with how we set-up our server. -- The affinity and working groups require that the specific channel names tied to those dictionary constants are present. -- You will need to update the `ACTIVE_GUILD` constant to match the test server. +Once the commands are registered for the guild, the bot can be re-run normally with the Docker commands listed above. diff --git a/bot/bot.py b/bot/bot.py new file mode 100644 index 0000000..8a84732 --- /dev/null +++ b/bot/bot.py @@ -0,0 +1,40 @@ +import os + +import discord +from dislash import SlashClient +from discord.ext import commands + +from constants import COMMAND_PREFIX, LOG_CHANNEL + + +class Lovelace(commands.Bot): + """ + This is the base bot instance. + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.load_extension("exts.affinity_working_groups") + + async def on_ready(self, *args, **kwargs): + await self.get_channel(LOG_CHANNEL).send("I have connected.") + + +if __name__ == "__main__": + TOKEN = os.getenv('TOKEN') + + # Intents + intents = discord.Intents.default() + intents.members = True + + + bot = Lovelace( + command_prefix=COMMAND_PREFIX, + acitivty=None, + intents=intents, + allowed_mentions=discord.AllowedMentions(everyone=False) + ) + + slash = SlashClient(bot) + + bot.run(TOKEN) \ No newline at end of file diff --git a/bot/constants.py b/bot/constants.py new file mode 100644 index 0000000..c2ea530 --- /dev/null +++ b/bot/constants.py @@ -0,0 +1,3 @@ +COMMAND_PREFIX = "!" +GUILD_ID = 754379784460566569 +LOG_CHANNEL = 784878993160929310 diff --git a/bot/exts/__init__.py b/bot/exts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/exts/affinity_working_groups.py b/bot/exts/affinity_working_groups.py new file mode 100644 index 0000000..8367a80 --- /dev/null +++ b/bot/exts/affinity_working_groups.py @@ -0,0 +1,88 @@ +import discord +from discord.ext import commands +from dislash import slash_commands + + +class WorkingGroups(commands.Cog): + """A cog that manages users adding themslves and leaving Working Groups""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @slash_commands.command(name="working-group") + async def affinity_group(self, interaction): + for k, v in interaction.data.options.items(): + if v.name == "join": + add = True + action = "joined" + else: + add = False + action = "left" + + result = await _add_remove_from_group(interaction.guild, interaction.author, v.value, "working groups", add) + + if result is True: + msg_content = f":white_check_mark: You have successfully {action} the {v.value} working group channel.\ + \nIf there was an error, please contact an admin." + else: + msg_content = ":x: Sorry, there was an error. Please try again or contact an admin." + + await interaction.reply( + content=msg_content, + hide_user_input=True, + ephemeral=True, # Only visible to the invoker of the command + type=4, # Immediate response with acknowledge + ) + + +class AffinityGroups(commands.Cog): + """A cog that manages users adding themselves and leaving Affinity Groups""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @slash_commands.command(name="affinity-group") + async def affinity_group(self, interaction): + for k, v in interaction.data.options.items(): + if v.name == "join": + add = True + action = "joined" + else: + add = False + action = "left" + + result = await _add_remove_from_group(interaction.guild, interaction.author, v.value, "affinity groups", add) + + if result is True: + msg_content = f":white_check_mark: You have successfully {action} the {v.value} affinity group channel.\ + \nIf there was an error, please contact an admin." + else: + msg_content = ":x: Sorry, there was an error. Please try again or contact an admin." + + await interaction.reply( + content=msg_content, + hide_user_input=True, + ephemeral=True, # Only visible to the invoker of the command + type=4, # Immediate response with acknowledge + ) + + +async def _add_remove_from_group(guild, user, channel_name: str, category_name: str, add: bool) -> bool: + """Helper function to add or remove a user from a specific channel. + If add is false, it will remove the permissions.""" + + category = discord.utils.get(guild.categories, name=category_name) + channel = discord.utils.get(guild.channels, category_id=category.id, name=channel_name) + if add is True: + await channel.set_permissions(user, read_messages=add, send_messages=add, add_reactions=add, + read_message_history=add, external_emojis=add, attach_files=add, embed_links=add) + else: + # Resets the permissions for the user and removes the channel-specific override + await channel.set_permissions(user, overwrite=None) + return True + + +def setup(bot: commands.Bot) -> None: + """Load Affinity and Working Groups cogs""" + bot.add_cog(AffinityGroups(bot)) + bot.add_cog(WorkingGroups(bot)) diff --git a/bot/register_command.py b/bot/register_command.py new file mode 100644 index 0000000..38a2e30 --- /dev/null +++ b/bot/register_command.py @@ -0,0 +1,99 @@ +import os + +import discord +from dislash import SlashClient, Option, Type, SlashCommand, OptionChoice +from discord.ext import commands +from dislash.slash_commands import slash_client + +from constants import COMMAND_PREFIX, GUILD_ID + + +class Lovelace(commands.Bot): + """ + This is the base bot instance. + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.load_extension("exts.affinity_working_groups") + + async def on_ready(self, *args, **kwargs): + print(f'{bot.user} has connected to Discord') + + +if __name__ == "__main__": + TOKEN = os.getenv('TOKEN') + + # Intents + intents = discord.Intents.default() + intents.members = True + + + bot = Lovelace( + command_prefix=COMMAND_PREFIX, + intents=intents, + allowed_mentions=discord.AllowedMentions(everyone=False) + ) + + slash = SlashClient(bot) + + + @slash.event + async def on_ready(): + sc = SlashCommand(name="working-group", + description="Join or leave a working group", + options=[ + Option(name="join", description="Join a working group", type=Type.STRING, + choices=[ + OptionChoice(name="Beyond the Binary", value="beyondthebinary"), + OptionChoice(name="Black & Queer", value="black_queer"), + OptionChoice(name="Queer Enabled", value="queer-enabled"), + ] + ), + Option(name="leave", description="Leave a working group", type=Type.STRING, + choices=[ + OptionChoice(name="Beyond the Binary", value="beyondthebinary"), + OptionChoice(name="Black & Queer", value="black_queer"), + OptionChoice(name="Queer Enabled", value="queer-enabled"), + ] + ) + ] + ) + + sc1 = SlashCommand(name="affinity-group", + description="Join or leave an affinity group", + options=[ + Option(name="join", description="Join an affinity group", type=Type.STRING, + choices=[ + OptionChoice(name="AAPI", value="aapi"), + OptionChoice(name="Ace/Aro", value="acearo"), + OptionChoice(name="(Dis)Ability", value="disability"), + OptionChoice(name="InQueery", value="inqueery"), + OptionChoice(name="Middle Sexualities", value="middle-sexualities"), + OptionChoice(name="Race and Ethnicity", value="race-ethnicity"), + OptionChoice(name="Trans and Non-binary", value="transnon-binary"), + OptionChoice(name="Women", value="women") + + ] + ), + Option(name="leave", description="Leave an affinity group", type=Type.STRING, + choices=[ + OptionChoice(name="AAPI", value="aapi"), + OptionChoice(name="Ace/Aro", value="acearo"), + OptionChoice(name="(Dis)Ability", value="disability"), + OptionChoice(name="InQueery", value="inqueery"), + OptionChoice(name="Middle Sexualities", value="middle-sexualities"), + OptionChoice(name="Race and Ethnicity", value="race-ethnicity"), + OptionChoice(name="Trans and Non-binary", value="transnon-binary"), + OptionChoice(name="Women", value="women") + + ] + ) + ] + ) + + await slash.register_guild_slash_command(GUILD_ID, sc) + await slash.register_guild_slash_command(GUILD_ID, sc1) + + + bot.run(TOKEN) diff --git a/lovelace.py b/lovelace.py deleted file mode 100644 index aea7dc5..0000000 --- a/lovelace.py +++ /dev/null @@ -1,162 +0,0 @@ -import os -from dotenv import load_dotenv - -import discord -from discord.ext import commands - -load_dotenv() -TOKEN = os.getenv('TOKEN') -COMMAND_PREFIX = "!" - -AFFINITY_GROUPS = { - 'womxn': ['womxn', 'women', 'woman'], - 'acearo': ['ace', 'aro', 'ace/aro', 'acearo'], - 'aapi': ['aapi'], - 'disability': ['disability', '(dis)ability'], - 'middle-sexualities': ['middle sexualities', 'middle-sexualities'], - 'race-ethnicity': ['race and ethnicity', 'race', 'ethnicity', 'race-ethnicity'], - 'transnon-binary': ['trans and non-binary', 'trans and nonbinary', 'trans', 'non-binary', 'enby', 'transgender', 'transnon-binary'], - 'inqueery': ['inqueery'] -} - -WORKING_GROUPS = { - 'dis-ability': ['disability', '(dis)ability'], - 'beyondthebinary': ['binary', 'beyond the binary', 'beyond-the-binary', 'beyond_the_binary'], - 'black_queer': ['black and queer', 'black', 'queery', 'black n queer', 'black_and_queer', 'black-and-queer', 'black_queer'] -} - -ACTIVE_GUILD = "oSTEM Global" - - -class oSTEMBot(commands.Bot): - """ - This is the base bot instance. - """ - - def __init__(self, **kwargs): - super().__init__(**kwargs) - - -# Intents -intents = discord.Intents.default() -intents.members = True - - -bot = oSTEMBot( - command_prefix=COMMAND_PREFIX, - activity=discord.Game(name=f"Commands: {COMMAND_PREFIX}help"), - intents=intents -) - - -class WorkingGroups(commands.Cog): - """A cog that manages users adding themslves and leaving Working Groups""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.command() - async def join_wg(self, ctx: commands.Context, *, working_group: str) -> None: - """Direct Message the bot with the affinity group they want to join. The bot will add you to the correct group. - - The current working groups can be found on the oSTEM.org website. - """ - guild = discord.utils.get(bot.guilds, name=ACTIVE_GUILD) - if await _has_role_check(ctx, guild, "Member"): - category = discord.utils.get(guild.categories, name="Working Groups") - await _add_remove_from_group(ctx, working_group, category, True) - else: - msg_content = """Sorry, you have not accepted the server rules yet.\ - \nPlease read the #welcome channel and click on the existing reaction emoji to agree to the server rules.""" - await ctx.author.send(msg_content) - - @commands.command() - async def leave_wg(self, ctx: commands.Context, *, working_group: str) -> None: - """Direct Message the bot with the affinity group that you would like to leave. - The bot will remove you from the group.""" - guild = discord.utils.get(bot.guilds, name=ACTIVE_GUILD) - category = discord.utils.get(guild.categories, name="Working Groups") - await _add_remove_from_group(ctx, working_group, category, False) - - -class AffinityGroups(commands.Cog): - """A cog that manages users adding themselves and leaving Affinity Groups""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.command() - async def join_ag(self, ctx: commands.Context, *, affinity_group: str) -> None: - """Direct Message the bot with the affinity group they want to join. The bot will add you to the correct group. - - The current affinity groups can be found on the oSTEM.org website - """ - guild = discord.utils.get(bot.guilds, name=ACTIVE_GUILD) - if await _has_role_check(ctx, guild, "Member"): - category = discord.utils.get(guild.categories, name="affinity groups") - await _add_remove_from_group(ctx, affinity_group, category, True) - else: - msg_content = """Sorry, you have not accepted the server rules yet.\ - \nPlease read the #welcome channel and click on the existing reaction emoji to agree to the server rules.""" - await ctx.author.send(msg_content) - - @commands.command() - async def leave_ag(self, ctx: commands.Context, *, affinity_group: str) -> None: - """Direct Message the bot with the affinity group that you would like to leave. - The bot will remove you from the group.""" - - guild = discord.utils.get(bot.guilds, name=ACTIVE_GUILD) - category = discord.utils.get(guild.categories, name="affinity groups") - await _add_remove_from_group(ctx, affinity_group, category, False) - - -async def _has_role_check(ctx: commands.Context, guild, *role_name: int) -> bool: - """Checks if the user has a specific role within the server""" - member = guild.get_member(int(ctx.author.id)) - - for role in member.roles: - if role.name in role_name: - return True - return False - - -async def _add_remove_from_group(ctx: commands.Context, channel_name: str, category, add: bool = True) -> bool: - """Helper function to add or remove a user from a specific channel. - If add is false, it will remove the permissions.""" - - guild = discord.utils.get(bot.guilds, name=ACTIVE_GUILD) - - active_group = '' - if category.name == "affinity groups": - active_group = AFFINITY_GROUPS - elif category.name == "Working Groups": - active_group = WORKING_GROUPS - - msg_content = f":x: Sorry, there was an error. Please try again or contact an admin." - - for key, value in active_group.items(): - if channel_name.lower() in value: - channel = discord.utils.get(guild.channels, category_id=category.id, name=key) - await channel.set_permissions(ctx.author, read_messages=add, send_messages=add, add_reactions=add, - read_message_history=add, external_emojis=add, attach_files=add, - embed_links=add) - if add is True: - result = "added" - else: - result = "removed" - msg_content = f":white_check_mark: You have been successfully {result} from the {key} affinity group channel.\ - \nIf there was an error, please contact an admin." - await ctx.author.send(msg_content) - if ctx.channel.type is discord.ChannelType.text: - await ctx.message.delete() - - -@bot.command(hidden=True) -async def on_ready(self, *args, **kwargs): - print(f'{bot.user} has connected to Discord') - - -bot.add_cog(AffinityGroups(bot)) -bot.add_cog(WorkingGroups(bot)) - -bot.run(TOKEN) diff --git a/requirements.txt b/requirements.txt index 062c9a8..b2b3d41 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -discord -python-dotenv +discord.py +dislash.py \ No newline at end of file