Vivek Santayana
4f92e83e48
Added .env.example file. Branch now ready to be merged for the first tagged release of the Bot.
289 lines
13 KiB
Python
289 lines
13 KiB
Python
import os, sys # OS Locations
|
|
from dotenv import load_dotenv # Import OS variables from Dotenv file.
|
|
load_dotenv() # Load Dotenv. Delete this for production
|
|
import yaml # Parser for yaml files for config settings.
|
|
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 discord_components # Additional Discord functions: buttons, menus, etc
|
|
from deepdiff import DeepDiff
|
|
import logging
|
|
|
|
## Define YAML functions
|
|
|
|
def yaml_load(filepath:str):
|
|
### Loads a YAML file
|
|
with open(filepath, 'r') as file:
|
|
data = yaml.load(file, Loader=yaml.FullLoader)
|
|
return data
|
|
|
|
def yaml_dump(data:dict, filepath:str):
|
|
### Dumps a YAML file
|
|
with open(filepath, 'w') as file:
|
|
yaml.dump(data, file)
|
|
|
|
# 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. 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)
|
|
|
|
# Locate or create lookup file. Same as above.
|
|
lookupFile = os.getenv('LOOKUP') if os.getenv('LOOKUP').endswith('.yml') or os.getenv('LOOKUP').endswith('.yaml') else './data/lookup.yml'
|
|
|
|
if not os.path.exists(lookupFile): yaml_dump({},lookupFile)
|
|
|
|
# Locate or create GM lookup file. Same as above.
|
|
gmFile = os.getenv('GM') if os.getenv('GM').endswith('.yml') or os.getenv('GM').endswith('.yaml') else './data/gm.yml'
|
|
|
|
if not os.path.exists(gmFile): yaml_dump({},gmFile)
|
|
|
|
# Locate or create Categories lookup file. Same as above.
|
|
categoriesFile = os.getenv('CATEGORIES') if os.getenv('CATEGORIES').endswith('.yml') or os.getenv('CATEGORIES').endswith('.yaml') else './data/categories.yml'
|
|
|
|
if not os.path.exists(categoriesFile): yaml_dump({},categoriesFile)
|
|
|
|
# Locate or create Pitches data file. Same as above.
|
|
pitchesFile = os.getenv('PITCHES') if os.getenv('PITCHES').endswith('.yml') or os.getenv('PITCHES').endswith('.yaml') else './data/pitches.yml'
|
|
|
|
if not os.path.exists(pitchesFile): yaml_dump({},pitchesFile)
|
|
|
|
l = [dataFile, lookupFile, gmFile, categoriesFile, configFile, pitchesFile]
|
|
if len(set(l)) != len(l): raise Exception('Config Error: there is a clash between two file names.')
|
|
|
|
# Locate Cogs Directory
|
|
cogsDir = 'cogs'
|
|
|
|
## Logging configuration imported boilerplate from Discord Py Docs
|
|
logger = logging.getLogger('discord')
|
|
logger.setLevel(logging.DEBUG)
|
|
handler = logging.FileHandler(filename='discord.log', encoding='utf-8', mode='w')
|
|
handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s'))
|
|
logger.addHandler(handler)
|
|
|
|
#### Dynamic Prefixes
|
|
def getPrefix(client, message):
|
|
conf = yaml_load(configFile)
|
|
return conf[str(message.guild.id)]['prefix']
|
|
|
|
# Define Clients
|
|
client = commands.Bot(
|
|
intents=discord.Intents.all(),
|
|
command_prefix=getPrefix,
|
|
description=f'Geas Server Bot v.{os.getenv("BOT_VERSION")}.\n\nThis bot designed to automate the management of key features of the Geas Discord Server. It is written by Vivek Santayana. You can find the source code at https://git.vsnt.uk/viveksantayana/geas-bot.'
|
|
)
|
|
slash = SlashCommand(
|
|
client,
|
|
sync_commands = True,
|
|
sync_on_cog_reload = True
|
|
)
|
|
# sync_on_reload is an important parameter that will become relevant when having to reload cogs on changing bot configs.
|
|
|
|
# Define Config keys
|
|
|
|
def setConfig(guild:discord.Guild):
|
|
#### Check if the bot is missing any config entries for the guilds it is in, and if it is then add it in.
|
|
#### N.B.: The way the commands work, the bot will have to list specific guilds in which it will synchronise the commands when it is defining them. So it needs to give a list of all the guilds it is part of when the bot loads, which it draws from the config files.
|
|
#### Because the bot connects to Discord after it loads, it will not be able to introspect and see what guilds it is part of before the commands are first loaded, and it will only add new guilds to the config files after it has already connected.
|
|
#### The Bot will first need to set up all of its configurations, and then begin loading all other commands once it is ready.
|
|
conf = yaml_load(configFile)
|
|
guildStr = str(guild.id)
|
|
if guildStr not in conf:
|
|
conf[guildStr] = {}
|
|
gDict = conf[guildStr]
|
|
if 'channels' not in gDict or type(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
|
|
cDict['mod'] = t.id
|
|
else:
|
|
cDict['mod'] = guild.system_channel.id
|
|
if 'configured' not in gDict or type(gDict['configured']) is not bool:
|
|
gDict['configured'] = False
|
|
if 'membership' not in gDict or type(gDict['membership']) is not list 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 type(gDict['timeslots']) is not dict or None in gDict['timeslots']:
|
|
gDict['timeslots'] = {}
|
|
yaml_dump(conf, configFile)
|
|
|
|
def clearConfig(guildKey:str):
|
|
#### Delete Configs for Guilds that the Bot is no longer in
|
|
conf = yaml_load(configFile)
|
|
if discord.utils.find(lambda g: str(g.id) == guildKey, client.guilds) is None:
|
|
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)
|
|
if 'notifications' not in '.'.join(s):
|
|
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 'initialised' in unconfigured:
|
|
unconfigured.remove('initialised')
|
|
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 mandatory parameters 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 == 'bot':
|
|
output = ''.join([output, f"- The `Bot` 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'):
|
|
path[-1] = path[-1][:-3]
|
|
client.load_extension('.'.join(path))
|
|
|
|
def unloadCog(filepath:str):
|
|
path = os.path.normpath(filepath).split(os.path.sep)
|
|
if path[-1].endswith('.py'):
|
|
path[-1] = path[-1][:-3]
|
|
client.unload_extension('.'.join(path))
|
|
|
|
def reloadCog(filepath:str):
|
|
path = os.path.normpath(filepath).split(os.path.sep)
|
|
if path[-1].endswith('.py'):
|
|
path[-1] = path[-1][:-3]
|
|
client.reload_extension('.'.join(path))
|
|
|
|
def loadCogs(cogClass:str = '--all'):
|
|
for category in os.listdir(f'./{cogsDir}'):
|
|
if cogClass == '--all' or cogClass == category:
|
|
for cogfile in os.listdir(f'./{cogsDir}/{category}'):
|
|
if cogfile.endswith('.py'):
|
|
loadCog(f'./{cogsDir}/{category}/{cogfile}')
|
|
|
|
def unloadCogs(cogClass:str = '--all'):
|
|
for category in os.listdir(f'./{cogsDir}'):
|
|
if cogClass == '--all' or cogClass == category:
|
|
for cogfile in os.listdir(f'./{cogsDir}/{category}'):
|
|
if cogfile.endswith('.py'):
|
|
unloadCog(f'./{cogsDir}/{category}/{cogfile}')
|
|
|
|
def reloadCogs(cogClass:str = '--all'):
|
|
for category in os.listdir(f'./{cogsDir}'):
|
|
if cogClass == '--all' or cogClass == category:
|
|
for cogfile in os.listdir(f'./{cogsDir}/{category}'):
|
|
if cogfile.endswith('.py'):
|
|
reloadCog(f'./{cogsDir}/{category}/{cogfile}')
|
|
|
|
loadCogs('controlcommands')
|
|
loadCogs('events')
|
|
loadCogs('membership')
|
|
loadCogs('botcommands')
|
|
loadCogs('slashcommands')
|
|
if yaml_load(configFile):
|
|
if any([yaml_load(configFile)[x]['timeslots'] for x in yaml_load(configFile)]):
|
|
loadCog(f'./{cogsDir}/slashcommands/secondary/manipulate_timeslots.py')
|
|
if any(['bot' in yaml_load(configFile)[x]['roles'] for x in yaml_load(configFile)]):
|
|
loadCog(f'./{cogsDir}/slashcommands/secondary/game_create.py')
|
|
if yaml_load(lookupFile):
|
|
if any([x for x in yaml_load(lookupFile).values()]):
|
|
loadCog(f'./{cogsDir}/slashcommands/secondary/game_management.py')
|
|
loadCog(f'./{cogsDir}/slashcommands/secondary/player_commands.py')
|
|
loadCog(f'./{cogsDir}/slashcommands/secondary/tcard.py')
|
|
loadCog(f'./{cogsDir}/slashcommands/secondary/pitch.py')
|
|
if yaml_load(pitchesFile):
|
|
loadCog(f'./{cogsDir}/events/secondary/pitch_listener.py')
|
|
if any([len(yaml_load(configFile)[x]['membership']) > 0 for x in yaml_load(configFile)]):
|
|
loadCog(f'./{cogsDir}/slashcommands/secondary/edit_membership.py')
|
|
|
|
client.run(os.getenv('BOT_TOKEN')) |