diff --git a/.gitignore b/.gitignore index c30d77c..a5fa955 100644 --- a/.gitignore +++ b/.gitignore @@ -146,6 +146,7 @@ pyvenv.cfg app/cogs/template.py.tmp app/cogs/events.py.tmp app/old_code/ +app/run_venv.sh # ---> VisualStudioCode .vscode/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e2256d..47ca179 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ I cannot believe how much I got into the debate between these two formats. Seriously, the database was overkill and such a bad idea. It was fun to learn but was such a bad idea.` - Implements client-side `/commands` as the core method of interaction -`This takes advantage of a new Discord feature to programme `/commands` into the console via Bot integration rather than natively at the bot. +`This takes advantage of a new Discord feature to programme /commands into the console via Bot integration rather than natively at the bot. This offers more robust command handling, with client-side data validation, option entries, input menus, etc. This does have the drawback of requiring a more complex API interaction in order for the bot to function, but it should improve the bot's functionality overall.` - Uses Discord Buttons and Select Menus to replace Reaction Role features diff --git a/README.md b/README.md index 31bbc3f..35b8695 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Geas Server Bot ``` -(Currently a work in progress. The bot is still not in a state to run, and none of its features have been programmed..) +(Currently a work in progress. The bot is still not in a state to run, and none of its features have been programmed.) ``` This is a bot I wrote to manage the Discord server for Geas, the Edinburgh University Table-Top Role-Playing Society, during our move to an on-line format. @@ -77,8 +77,8 @@ in order for to authenticate as the correct bot. | |-- .env |-- .gitignore -|-- CHANGELOG.md -|-- COMMANDS.md +|-- CHANGELOG.md +|-- COMMANDS.md |-- docker-compse.yml |-- LICENSE |-- README.md diff --git a/TODO.md b/TODO.md index af31ba8..6e35016 100644 --- a/TODO.md +++ b/TODO.md @@ -3,39 +3,40 @@ ## Bot Architecture - [x] Simplify directory tree - [x] Split event listeners into individual cogs. -- [ ] Update with re-organised data and config structure -> - [ ] Correct references to data in existing cogs. +- [x] Update with re-organised data and config structure +> - [x] Correct references to data in existing cogs. ## Bot Functionality - [ ] 'Delete Commands' Function - [ ] 'Register Commands' Function - [ ] Infer Permissions from Config -- [X] Dynamic Command Prefixes +- [x] Dynamic Command Prefixes - [ ] Infer Games from Server Structure - [ ] Re-enable logging -- [X] Delete Dev/Test Functions -- [ ] Error handlers +- [x] Delete Dev/Test Functions +- [x] Error handlers - [ ] Debug Features -- [ ] Help Channel Event Listener -> - [X] Add Config key for Help Channel +- [x] Help Channel Event Listener +> - [x] Add Config key for Help Channel - [ ] Slash Command Buttons or - [ ] Reaction listener selectors - [ ] Member Verification -> - [X] Add Config key membership signup channels -> - [X] Add config keys: Membership Category Roles +> - [x] Add Config key membership signup channels +> - [x] Add config keys: Membership Category Roles > - [ ] Message Receive listener > - [ ] Message React listener or buttons - [ ] Membership Restriction > - [ ] Message Receive Listener > - [ ] Membership Validation Listener - [ ] Re-register commands after any relevant config changes - +- [x] Flag for checking completeness of configuration for a guild. +> - [x] Function for checking configs for completeness ## Event Listeners -### Review Configs When -- [X] Guild Changing Ownership -- [X] Roles Modified -- [X] Mod Channel Deleted +## Review Configs When +- [x] Guild Changing Ownership +- [x] Roles Modified +- [x] Mod Channel Deleted ## Commands - [ ] Configure Bot function and sub commands diff --git a/app/bot.py b/app/bot.py index df9bcec..0da227c 100644 --- a/app/bot.py +++ b/app/bot.py @@ -7,6 +7,8 @@ import discord # Main Lib from discord.ext import commands # Commands module from discord_slash import SlashCommand, SlashContext, cog_ext, utils # Slash Command Library from discord_slash.utils.manage_commands import create_choice, create_option # Slash Command features +import discord_components # Additional Discord functions: buttons, menus, etc +from deepdiff import DeepDiff import logging ## Define YAML functions @@ -22,14 +24,14 @@ def yaml_dump(data:dict, filepath:str): with open(filepath, 'w') as file: yaml.dump(data, file) -# Locate or create config file -configFile = os.getenv('CONFIG') if os.getenv('CONFIG').endswith('.yml') else './data/config.yml' +# Locate or create config file. Read from environment variables to locate file, and if missing or not valid then use default location. +configFile = os.getenv('CONFIG') if ((os.getenv('CONFIG').endswith('.yml') or os.getenv('CONFIG').endswith('.yaml')) and not os.getenv('CONFIG').endswith('config_blueprint.yml')) else './data/config.yml' if not os.path.exists(configFile): yaml_dump({},configFile) -# Locate or create data file -dataFile = os.getenv('DATA') if os.getenv('DATA').endswith('.yml') else './data/data.yml' +# Locate or create data file. Same as above. +dataFile = os.getenv('DATA') if ((os.getenv('DATA').endswith('.yml') or os.getenv('DATA').endswith('.yaml')) and not os.getenv('DATA').endswith('data_blueprint.yml')) else './data/data.yml' if not os.path.exists(dataFile): yaml_dump({},dataFile) @@ -73,31 +75,40 @@ def setConfig(guild:discord.Guild): guildStr = str(guild.id) if guildStr not in conf: conf[guildStr] = {} - if 'name' not in conf[guildStr] or conf[guildStr]['name'] != guild.name: - conf[guildStr]['name'] = guild.name - if 'configured' not in conf[guildStr] or conf[guildStr]['configured'] is not bool: - conf[guildStr]['configured'] = False - if 'owner' not in conf[guildStr] or conf[guildStr]['owner'] != guild.owner_id: - conf[guildStr]['owner'] = guild.owner_id - if 'roles' not in conf[guildStr] or (type(conf[guildStr])['roles'] is not dict or len(conf[guildStr]['roles']) == 0 or None in conf[guildStr]['roles']): - conf[guildStr]['roles'] = {} - if 'admin' not in conf[guildStr]['roles'] or (type(conf[guildStr]['roles']['admin']) is not list or len(conf[guildStr]['roles']['admin']) == 0 or None in conf[guildStr]['roles']['admin']): - conf[guildStr]['roles']['admin'] = [] - for role in guild.roles: - if not (role.is_bot_managed() or role.is_integration()) and role.permissions.administrator: - conf[guildStr]['roles']['admin'].append(role.id) - if 'prefix' not in conf[guildStr]: - conf[guildStr]['prefix'] = '-' - if 'modchannel' not in conf[guildStr]: + gDict = conf[guildStr] + if 'channels' not in gDict or gDict['channels'] is not dict or None in gDict['channels']: + gDict['channels'] = {} + cDict = gDict['channels'] + if 'mod' not in cDict: if guild.system_channel is None: p = len(guild.channels) c = None for t in guild.text_channels: if t.position < p: p = t.position - conf[guildStr]['modchannel'] = t.id + cDict['mod'] = t.id else: - conf[guildStr]['modchannel'] = guild.system_channel.id + cDict['mod'] = guild.system_channel.id + if 'configured' not in gDict or gDict['configured'] is not bool: + gDict['configured'] = False + if 'membership' not in gDict or gDict['membership'] is not dict or None in gDict['membership']: + gDict['membership'] = {} + if 'name' not in gDict or gDict['name'] != guild.name: + gDict['name'] = guild.name + if 'owner' not in gDict or gDict['owner'] != guild.owner_id: + gDict['owner'] = guild.owner_id + if 'prefix' not in gDict: + gDict['prefix'] = '-' + if 'roles' not in gDict or (type(gDict)['roles'] is not dict or None in gDict['roles']): + gDict['roles'] = {} + rDict = gDict['roles'] + if 'admin' not in rDict or (type(rDict['admin']) is not list or len(rDict['admin']) == 0 or None in rDict['admin']): + rDict['admin'] = [] + for role in guild.roles: + if not (role.is_bot_managed() or role.is_integration()) and role.permissions.administrator: + rDict['admin'].append(role.id) + if 'timeslots' not in gDict or gDict['timeslots'] is not dict or None in gDict['timeslots']: + gDict['timeslots'] = [] yaml_dump(conf, configFile) def clearConfig(guildKey:str): @@ -107,6 +118,90 @@ def clearConfig(guildKey:str): del conf[guildKey] yaml_dump(conf, configFile) +def checkConfig(guild:discord.Guild): + #### Checks the completeness of the configurations of the current guild. returns (bool of config state, [list of missing keys]) + #### The bot does this by comparing the keys and structure in the ./data/config_blueprint.yml file. This offers the bot some level of abstraction in doing this. + #### DeepDiff between the blueprint and the configs, and see if any values have been removed (thus missing) + guildStr = str(guild.id) + conf = yaml_load(configFile) + unconfigured = [] + blueprint = yaml_load('./data/config_blueprint.yml') + diff = DeepDiff(blueprint['guild_id_string'], conf[guildStr]) + if 'dictionary_item_removed' in diff: + for i in diff['dictionary_item_removed']: + s = i.split("'") + for item in list(i.split("'")): + if '[' in item or ']' in item: + s.remove(item) + unconfigured.append('.'.join(s)) + if 'type_changes' in diff: + for i in diff['type_changes']: + s = i.split("'") + for item in list(i.split("'")): + if '[' in item or ']' in item: + s.remove(item) + if 'notifications' not in '.'.join(s): + unconfigured.append('.'.join(s)) + if 'iterable_item_removed' in diff: + for i in diff['iterable_item_removed']: + s = i.split("'") + for item in list(i.split("'")): + if '[' in item or ']' in item: + s.remove(item) + unconfigured.append('.'.join(s)) + for i in blueprint['guild_id_string']: + if i not in blueprint['guild_id_string']['meta']['strict'] and isinstance(blueprint['guild_id_string'][i], dict): + if i in conf[guildStr] and isinstance(conf[guildStr][i], dict) and len(conf[guildStr][i]) < 1: + unconfigured.append(i) + if 'meta' in unconfigured: + unconfigured.remove('meta') + if 'configured' in unconfigured: + unconfigured.remove('configured') + output = list(set(unconfigured)) + if len(output) > 0: + conf[guildStr]['configured'] = False + elif len(output) == 0: + conf[guildStr]['configured'] = True + yaml_dump(conf,configFile) + return conf[guildStr]['configured'], output + +def parseConfigCheck(missingKeys: list): + output = 'Configuration values for the following have not been defined:\n\n' + for entry in missingKeys: + if '.' in entry: + e1, e2 = entry.split('.') + if e1 == 'channels': + if e2 == 'help': + output = ''.join([output, f"- The `help channel` for the Bot to monitor and notify Committee\n"]) + if e2 == 'mod': + output = ''.join([output, f"- The `moderation channel` for the bot's outputs\n"]) + if e2 == 'signup': + output = ''.join([output, f"- The `sign-up channel` for the membershp registration\n"]) + if e1 == 'roles': + if e2 == 'admin': + output = ''.join([output, f"- The `administrator` role(s) for the guild\n"]) + if e2 == 'committee': + output = ''.join([output, f"- The `Committee` role for the guild\n"]) + if e2 == 'bots': + output = ''.join([output, f"- The `Bots` role for the guild\n"]) + if e2 == 'newcomer': + output = ''.join([output, f"- The `Newcomer` role for the guild\n"]) + if e2 == 'returning': + output = ''.join([output, f"- The `Returning Player` role for the guild\n"]) + if e2 == 'student': + output = ''.join([output, f"- The `Student` role for the guild\n"]) + if entry == 'membership': + output = ''.join([output, f"- `Membership roles`: the Channel needs at least one membership role\n"]) + if entry == 'name': + output = ''.join([output, f"- The guild's `name`\n"]) + if entry == 'owner': + output = ''.join([output, f"- The guild's `owner`\n"]) + if entry == 'prefix': + output = ''.join([output, f"- The guild's `prefix` for native (non-`/`) commands.\n"]) + if entry == 'timeslots': + output = ''.join([output, f"- Available `timeslots` for server games.\n"]) + return output + def loadCog(filepath:str): path = os.path.normpath(filepath).split(os.path.sep) if path[-1].endswith('.py'): diff --git a/app/cogs/botcommands/prefix.py b/app/cogs/botcommands/prefix.py index 9ba5e02..73157a0 100644 --- a/app/cogs/botcommands/prefix.py +++ b/app/cogs/botcommands/prefix.py @@ -6,7 +6,7 @@ from bot import configFile, yaml_load, yaml_dump ### Cog for handling the non-Slash prefix for native Bot commands. -class Prefix(commands.Cog): +class Prefix(commands.Cog, name='Server Command Prefix'): def __init__(self, client): self.client = client diff --git a/app/cogs/dev/dev.py b/app/cogs/dev/dev.py index cfb86f2..a28773e 100644 --- a/app/cogs/dev/dev.py +++ b/app/cogs/dev/dev.py @@ -6,11 +6,13 @@ import discord # Main Lib from discord.ext import commands # Commands module from discord_slash import SlashCommand, SlashContext, cog_ext, utils # Slash Command Library from discord_slash.utils.manage_commands import create_choice, create_option # Slash Command features +from deepdiff import DeepDiff +from pprint import pprint -from bot import loadCog, unloadCog +from bot import loadCog, unloadCog, checkConfig, parseConfigCheck, yaml_load, configFile ##### Dev Cog -class Dev(commands.Cog): +class Dev(commands.Cog, name='Developer Commands'): def __init__(self, client): self.client = client @@ -22,10 +24,28 @@ class Dev(commands.Cog): async def _debug(self, ctx, toggle:str): if toggle.lower() == 'on': loadCog(f'./debug/debug.py') - await ctx.reply(f'Debug commands enabled. Use them carefully.') + await ctx.reply(f'```Debug commands enabled. Use them carefully.```') elif toggle.lower() == 'off': unloadCog(f'./debug/debug.py') - await ctx.reply(f'Debug commands disabled.') + await ctx.reply(f'``Debug commands disabled.``') + else: + raise commands.CommandError(message='Invalid argument.') + # await ctx.reply(f'```Invalid argument.```') + + @commands.command( + name='testconfig', + description='Tests the completeness of the configuration values of the current guild by comparing it to a configuration blueprint.', + brief='Tests config values for current guild.', + aliases=['configtest'] + ) + async def _testconfig(self, ctx): + checkConfig(ctx.guild) + status, output = checkConfig(ctx.guild) + conf = yaml_load(configFile) + if not status: + await ctx.reply(f"```The Bot's configurations are incomplete for the guild {ctx.guild.name}. Some limited functions will still be available, but most features cannot be used until the configurations are complete.\n{parseConfigCheck(output)}\nYou can set these configuration values using the `/config` command.```") + elif status: + await ctx.reply(f"```The Bot's configurations for the guild {ctx.guild.name} are in order. The Bot is ready to interact with the guild.```") def setup(client): client.add_cog(Dev(client)) \ No newline at end of file diff --git a/app/cogs/events/on_command_error.py b/app/cogs/events/on_command_error.py new file mode 100644 index 0000000..a7df095 --- /dev/null +++ b/app/cogs/events/on_command_error.py @@ -0,0 +1,27 @@ +import yaml # YAML parser for Bot config files +import asyncio # Discord Py Dependency +import discord # Main Lib +from discord.ext import commands # Commands module +from discord_slash import SlashCommand, SlashContext, cog_ext, utils # Slash Command Library +from discord_slash.utils.manage_commands import create_choice, create_option # Slash Command features +import logging +# logger and handler +from bot import configFile, yaml_load, yaml_dump + +#### Error Handler Event Listener +class on_command_error(commands.Cog, name='On Command Error'): + def __init__(self, client): + self.client = client + + @commands.Cog.listener() + async def on_command_error(self, ctx, error): + if isinstance(error, discord.DiscordException): + if isinstance(error, commands.CheckFailure): + print(f'Error: User {ctx.author.name}#{ctx.author.discriminator} / {ctx.author.display_name} is not authorised to issue the command <{ctx.command.name}> in the guild {ctx.guild.name}.') + await ctx.reply(f'```Error: You are not authorised to issue this command.```') + else: + print(f'User {ctx.author.name}#{ctx.author.discriminator} / {ctx.author.display_name} received error: "{error}" when attempting to issue command <{ctx.command.name}> in the guild {ctx.guild.name}.') + await ctx.reply(f'```Error: {error}```') + +def setup(client): + client.add_cog(on_command_error(client)) \ No newline at end of file diff --git a/app/cogs/events/on_connect.py b/app/cogs/events/on_connect.py index a35130c..ba1b318 100644 --- a/app/cogs/events/on_connect.py +++ b/app/cogs/events/on_connect.py @@ -9,7 +9,7 @@ import logging from bot import configFile, yaml_load, yaml_dump #### Actions for the Bot to take on connecting to Discord. -class on_connect(commands.Cog): +class on_connect(commands.Cog, name='On Connect Events'): def __init__(self, client): self.client = client diff --git a/app/cogs/events/on_guild_channel_delete.py b/app/cogs/events/on_guild_channel_delete.py index f756e0f..a22b3f9 100644 --- a/app/cogs/events/on_guild_channel_delete.py +++ b/app/cogs/events/on_guild_channel_delete.py @@ -9,7 +9,7 @@ import logging from bot import configFile, yaml_load, yaml_dump ##### Actions for the bot to take whenever a channel in a guild is deleted -class on_guild_channel_delete(commands.Cog): +class on_guild_channel_delete(commands.Cog, name='On Guild Channel Delete Events'): def __init__(self, client): self.client = client @@ -17,16 +17,16 @@ class on_guild_channel_delete(commands.Cog): @commands.Cog.listener() async def on_guild_channel_delete(self, channel): conf = yaml_load(configFile) - if conf[str(channel.guild.id)]['modchannel'] == channel.id: + if conf[str(channel.guild.id)]['channels']['mod'] == channel.id: if channel.guild.system_channel is None: p = len(channel.guild.channels) c = None for t in channel.guild.text_channels: if t.position < p: p = t.position - conf[str(channel.guild.id)]['modchannel'] = t.id + conf[str(channel.guild.id)]['channels']['mod'] = t.id else: - conf[str(channel.guild.id)]['modchannel'] = channel.guild.system_channel.id + conf[str(channel.guild.id)]['channels']['mod'] = channel.guild.system_channel.id yaml_dump(conf, configFile) def setup(client): diff --git a/app/cogs/events/on_guild_join.py b/app/cogs/events/on_guild_join.py index cbbce62..5aa72b7 100644 --- a/app/cogs/events/on_guild_join.py +++ b/app/cogs/events/on_guild_join.py @@ -6,17 +6,21 @@ from discord_slash import SlashCommand, SlashContext, cog_ext, utils # Slash C from discord_slash.utils.manage_commands import create_choice, create_option # Slash Command features import logging # logger and handler -from bot import configFile, setConfig, yaml_load, yaml_dump +from bot import checkConfig, parseConfigCheck, configFile, setConfig, yaml_load, yaml_dump #### Actions for the bot to take when the Bot joins a guild. -class on_guild_join(commands.Cog): +class on_guild_join(commands.Cog, name='On Guild Join Events'): def __init__(self, client): self.client = client @commands.Cog.listener() async def on_guild_join(self, guild): setConfig(guild) + status, output = checkConfig(guild) + conf = yaml_load(configFile) + if not status: + await guild.get_channel(conf[str(guild.id)]['channels']['mod']).send(f"The Bot's configurations are incomplete for the guild `{guild.name}`. Some limited functions will still be available, but most features cannot be used until the configurations are complete.\n{parseConfigCheck(output)}\nYou can set these configuration values using the `/config` command.") def setup(client): client.add_cog(on_guild_join(client)) \ No newline at end of file diff --git a/app/cogs/events/on_guild_remove.py b/app/cogs/events/on_guild_remove.py index 9b50a22..20e9776 100644 --- a/app/cogs/events/on_guild_remove.py +++ b/app/cogs/events/on_guild_remove.py @@ -10,7 +10,7 @@ from bot import clearConfig, configFile, yaml_load, yaml_dump #### Actions for the bot to take when removed from a guild. -class on_guild_remove(commands.Cog): +class on_guild_remove(commands.Cog, name='On Guild Remove Events'): def __init__(self, client): self.client = client diff --git a/app/cogs/events/on_guild_role_create.py b/app/cogs/events/on_guild_role_create.py index a09707b..f4281b3 100644 --- a/app/cogs/events/on_guild_role_create.py +++ b/app/cogs/events/on_guild_role_create.py @@ -10,7 +10,7 @@ from bot import configFile, yaml_load, yaml_dump ##### Actions for the bot to take whenever there is a new role created. -class on_guild_role_create(commands.Cog): +class on_guild_role_create(commands.Cog, name='On Guild Role Create Events'): def __init__(self, client): self.client = client @@ -19,7 +19,7 @@ class on_guild_role_create(commands.Cog): conf = yaml_load(configFile) #### Bot will only respond if the role is not a bot-managed role, and the role is an admin role if not (role.is_bot_managed() or role.is_integration()) and role.permissions.administrator: - conf[str(role.guild.id)]['adminroles'].append(role.id) + conf[str(role.guild.id)]['roles']['admin'].append(role.id) yaml_dump(conf, configFile) #### If the role is created with admin privileges, the bot adds the role to its configs. diff --git a/app/cogs/events/on_guild_role_delete.py b/app/cogs/events/on_guild_role_delete.py index 4360ddd..189da66 100644 --- a/app/cogs/events/on_guild_role_delete.py +++ b/app/cogs/events/on_guild_role_delete.py @@ -10,7 +10,7 @@ from bot import configFile, yaml_load, yaml_dump ##### Actions for the bot to take whenever there is a new role deleted. -class on_guild_role_delete(commands.Cog): +class on_guild_role_delete(commands.Cog, name='On Guild Role Delete Events'): def __init__(self, client): self.client = client @@ -18,8 +18,8 @@ class on_guild_role_delete(commands.Cog): async def on_guild_role_delete(self, role): conf = yaml_load(configFile) #### Bot will only respond if the role is not a bot-managed role, and the role is an admin role - if role.id in conf[str(role.guild.id)]['adminroles']: - conf[str(role.guild.id)]['adminroles'].remove(role.id) + if role.id in conf[str(role.guild.id)]['roles']['admin']: + conf[str(role.guild.id)]['roles']['adminroles'].remove(role.id) yaml_dump(conf, configFile) #### If the role is one of the Admin roles and is deleted, updates the bot's config to delete that role, preventing unnecessary roles from accumulating. diff --git a/app/cogs/events/on_guild_role_update.py b/app/cogs/events/on_guild_role_update.py index 81a8fef..ad389a8 100644 --- a/app/cogs/events/on_guild_role_update.py +++ b/app/cogs/events/on_guild_role_update.py @@ -10,7 +10,7 @@ from bot import configFile, yaml_load, yaml_dump ##### Actions for the bot to take whenever there is a new role deleted. -class on_guild_role_update(commands.Cog): +class on_guild_role_update(commands.Cog, name='On Guild Role Update Events'): def __init__(self, client): self.client = client @@ -18,15 +18,15 @@ class on_guild_role_update(commands.Cog): async def on_guild_role_update(self, before, after): conf = yaml_load(configFile) #### If the original role is in the config as an admin role, and it subsequently is run by a bot or is not an admin, remove it from config - if before.id in conf[str(before.guild.id)]['adminroles']: + if before.id in conf[str(before.guild.id)]['roles']['admin']: if after.is_bot_managed() or after.is_integration() or not after.permissions.administrator: - conf[str(after.guild.id)]['adminroles'].remove(after.id) + conf[str(after.guild.id)]['roles']['admin'].remove(after.id) yaml_dump(conf, configFile) #### If the new role is an admin and is not already in the config, add it. if not (after.is_bot_managed() or after.is_integration()) and after.permissions.administrator: - if after.id not in conf[str(after.guild.id)]['adminroles']: - conf[str(after.guild.id)]['adminroles'].remove(after.id) + if after.id not in conf[str(after.guild.id)]['roles']['admin']: + conf[str(after.guild.id)]['roles']['admin'].remove(after.id) yaml_dump(conf, configFile) #### If the role is one of the Admin roles and is deleted, updates the bot's config to delete that role, preventing unnecessary roles from accumulating. diff --git a/app/cogs/events/on_guild_update.py b/app/cogs/events/on_guild_update.py index b51f370..140c4dc 100644 --- a/app/cogs/events/on_guild_update.py +++ b/app/cogs/events/on_guild_update.py @@ -9,7 +9,7 @@ import logging from bot import configFile, yaml_load, yaml_dump ##### Actions for the bot to take whenever the guild info or ownership are updated. -class on_guild_update(commands.Cog): +class on_guild_update(commands.Cog, name='On Guild Update Events'): def __init__(self, client): self.client = client diff --git a/app/cogs/events/on_message.py b/app/cogs/events/on_message.py new file mode 100644 index 0000000..eaa33b7 --- /dev/null +++ b/app/cogs/events/on_message.py @@ -0,0 +1,47 @@ +import os # OS Locations +import yaml # YAML parser for Bot config files +import asyncio # Discord Py Dependency +import discord # Main Lib +from discord.ext import commands, tasks # Commands module +from discord_slash import SlashCommand, SlashContext, cog_ext, utils # Slash Command Library +from discord_slash.utils.manage_commands import create_choice, create_option # Slash Command features +from datetime import datetime +import logging +# logger and handler +from bot import configFile, yaml_load + +#### Actions the bot will take on messages being sent in the channel. + +##### Message Listener Cog +class on_message(commands.Cog, name='On Message Events'): + def __init__(self, client): + self.client = client + + @commands.Cog.listener() + async def on_message(self,message): + if message.author.bot or message.author.id == message.guild.owner_id: + return + for role in message.author.roles: + if role.permissions.administrator: + return + conf = yaml_load(configFile) + guild = message.guild + guildStr = str(guild.id) + if 'notifications' in conf[guildStr]: + if 'help' in conf[guildStr]['notifications']: + if conf[guildStr]['notifications']['help']: + if 'help' in conf[guildStr]['channels'] and 'committee' in conf[guildStr]['roles']: + if message.channel.id == conf[guildStr]['channels']['help'] and isinstance(guild.get_role(conf[guildStr]['roles']['committee']), discord.Role): + modChannel = self.client.get_channel(conf[guildStr]['channels']['mod']) + committeeRole = guild.get_role(conf[guildStr]['roles']['committee']) + embed = discord.Embed( + title = f'[New Query in Help]({message.jump_url})', + description = message.content, + colour = discord.Colour.orange() + ) + embed.set_footer(text=datetime.now().strftime('%a %-d %b %y, %-I:%M %p')) + embed.set_author(name=message.author.display_name, icon_url=message.author.avatar_url) + await modChannel.send(f'{committeeRole.mention}\n```There has been a new help query posted.```\n{message.author.mention}` posted in `{message.channel.mention}`.`', embed = embed) + +def setup(client): + client.add_cog(on_message(client)) \ No newline at end of file diff --git a/app/cogs/events/on_ready.py b/app/cogs/events/on_ready.py index cad1ead..1d132fa 100644 --- a/app/cogs/events/on_ready.py +++ b/app/cogs/events/on_ready.py @@ -7,10 +7,10 @@ from discord_slash import SlashCommand, SlashContext, cog_ext, utils # Slash C from discord_slash.utils.manage_commands import create_choice, create_option # Slash Command features import logging # logger and handler -from bot import clearConfig, configFile, setConfig, yaml_dump, yaml_load +from bot import checkConfig, clearConfig, configFile, parseConfigCheck, setConfig, yaml_dump, yaml_load #### Actions for the Bot to take once it is ready to interact with commands. -class on_ready(commands.Cog): +class on_ready(commands.Cog, name='On Ready Events'): def __init__(self, client): self.client = client @@ -26,5 +26,12 @@ class on_ready(commands.Cog): for key in list(conf): clearConfig(key) + #### Check completeness of configurations + for guild in self.client.guilds: + status, output = checkConfig(guild) + conf = yaml_load(configFile) + if not status: + await guild.get_channel(conf[str(guild.id)]['channels']['mod']).send(f"```The Bot's configurations are incomplete for the guild `{guild.name}`. Some limited functions will still be available, but most features cannot be used until the configurations are complete.\n{parseConfigCheck(output)}\nYou can set these configuration values using the `/config` command.```") + def setup(client): client.add_cog(on_ready(client)) \ No newline at end of file diff --git a/app/cogs/membership_restriction/message_listener.py b/app/cogs/membership_restriction/message_listener.py new file mode 100644 index 0000000..9a2d208 --- /dev/null +++ b/app/cogs/membership_restriction/message_listener.py @@ -0,0 +1,23 @@ +import os # OS Locations +import yaml # YAML parser for Bot config files +import asyncio # Discord Py Dependency +import discord # Main Lib +from discord.ext import commands # Commands module +from discord_slash import SlashCommand, SlashContext, cog_ext, utils # Slash Command Library +from discord_slash.utils.manage_commands import create_choice, create_option # Slash Command features +import logging +# logger and handler +from bot import configFile, yaml_load + +##### Membership Restriction Message Listener Cog +class RestrictionMessageListener(commands.Cog, name='Membership Restriction Message Listener'): + def __init__(self, client): + self.client = client + + @commands.Cog.listener() + async def on_message(self,message): + if message.author.bot: + return + +def setup(client): + client.add_cog(RestrictionMessageListener(client)) \ No newline at end of file diff --git a/app/data/config.yml b/app/data/config.yml index 31e5b73..2797aec 100644 --- a/app/data/config.yml +++ b/app/data/config.yml @@ -1,7 +1,15 @@ '864651943820525609': - adminroles: - - 864661232005939280 - modchannel: 865348933022515220 + channels: + mod: 865348933022515220 + configured: false + membership: {} name: Test + notifications: + help: null + signup: null owner: 493694762210033664 prefix: '-' + roles: + admin: + - 864661232005939280 + timeslots: [] diff --git a/app/data/config_blueprint.yml b/app/data/config_blueprint.yml new file mode 100644 index 0000000..2284a9f --- /dev/null +++ b/app/data/config_blueprint.yml @@ -0,0 +1,30 @@ +guild_id_string: + channels: # Dictionary + # help is an optional feature so not necessary in blueprint. + mod: 0 + signup: 0 + configured: false + membership: {} # Dictionary + # For membership, at least one kind needs to be defined. But no key is mandatory. + name: string + owner: 0 + prefix: string + roles: # dictionary + admin: + - 0 # List + # For admins, at least one needs to be defined. + # committee notifications is optional so is not necessary in blueprint. + bots: 0 + # newcomer role is optional + # returning player role is optional + # student role is optional + timeslots: + - string # List + # At least one needs to be defined. + meta: + strict: + - channels + - roles + notifications: + help: true + signup: true diff --git a/app/debug/debug.py b/app/debug/debug.py index 6cecc85..ca62ff1 100644 --- a/app/debug/debug.py +++ b/app/debug/debug.py @@ -10,10 +10,14 @@ from discord_slash.utils.manage_commands import create_choice, create_option # S from bot import clearConfig, configFile, loadCog, loadCogs, setConfig, unloadCog, unloadCogs, yaml_dump, yaml_load ##### Debug Cog -class Debug(commands.Cog): +class Debug(commands.Cog, name='Debug Commands'): def __init__(self, client): self.client = client + #### Permission Check: Only available to the bot's maintainer. + async def cog_check(self, ctx): + return ctx.author.id == int(os.getenv('BOT_MAINTAINER_ID')) + @commands.command( name='reloadcogs', description='Reloads cogs within the specified category, or provide `all` for all cogs. Default: `all`.', @@ -22,7 +26,7 @@ class Debug(commands.Cog): async def _reload(self, ctx, cog_category: str='all'): unloadCogs(cog_category) loadCogs(cog_category) - await ctx.repky(f'`{cog_category}` cogs have been reloaded.') + await ctx.reply(f'`{cog_category}` cogs have been reloaded.') @commands.command( name='unloadcogs', @@ -32,7 +36,7 @@ class Debug(commands.Cog): async def _unloadcogs(self, ctx, cog_category: str='all'): unloadCogs(cog_category) loadCogs(cog_category) - await ctx.repky(f'`{cog_category}` cogs have been unloaded.') + await ctx.reply(f'`{cog_category}` cogs have been unloaded.') @commands.command( name='loadcogs', @@ -42,7 +46,7 @@ class Debug(commands.Cog): async def _loadcogs(self, ctx, cog_category: str='all'): unloadCogs(cog_category) loadCogs(cog_category) - await ctx.repky(f'`{cog_category}` cogs have been loaded.') + await ctx.reply(f'`{cog_category}` cogs have been loaded.') @commands.command( name='deletecommands', @@ -65,10 +69,12 @@ class Debug(commands.Cog): @commands.command( name='retrievecommands', - aliases=['slashcommands','retrieveslashcommands'] + aliases=['slashcommands','retrieveslashcommands'], + description='Debugging command that retrieves all slash commands currently registered for this guild and this bot to the Python console.', + brief='Retrieves registered slash commands to console.' ) async def _retrievecommands(self, ctx:commands.Context): - c = await utils.manage_commands.get_all_commands(client.user.id,os.getenv('TEST_3_TOKEN'),guild_id=ctx.guild.id) + c = await utils.manage_commands.get_all_commands(self.client.user.id,os.getenv('TEST_3_TOKEN'),guild_id=ctx.guild.id) print(c) @commands.command( @@ -81,7 +87,7 @@ class Debug(commands.Cog): conf = yaml_load(configFile) for key in list(conf): clearConfig(key) - ctx.reply(f'Config entries have been cleared.') + await ctx.reply(f'Config entries have been cleared.') @commands.command( name='setconfig', @@ -91,7 +97,7 @@ class Debug(commands.Cog): ) async def _setconfig(self, ctx:commands.Context): setConfig(ctx.guild) - ctx.reply(f'Config entry has been added for guild `{ctx.guild.name}`.') + await ctx.reply(f'Config entry has been added for guild `{ctx.guild.name}`.') def setup(client): client.add_cog(Debug(client)) \ No newline at end of file