v2.1.1 of the bot, which is currently in use on the Geas server.

This commit is contained in:
Vivek Santayana 2021-07-07 15:00:44 +01:00
parent 8aaf7f4e4c
commit 70b98d6c18
11 changed files with 1244 additions and 2 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
db/*
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/

View File

@ -1,3 +1,68 @@
# geas-bot
# Geas Server Bot
Bot for managing the Discord server for Geas, the Edinburgh table-top role-playing society.
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.
The bot is designed to create and manage channels and roles for gaming groups in order to replicate our in-person pitch events on a Discord space as far as possible.
The bot is written in Python, and was the first Python coding project I wrote, so it has a special place in my heart.
The first version I am committing to the repository is version 2.1, and I previously handled the version control manually, so migrating old versions to Git would be a pain.
## Bot Setup
The Bot is dockerised and uses docker-compose for deployment, so it is fairly straightforward to set up.
Clone the repository, install Docker and Docker Compose, navigate to the root directory (that contains the `docker-compose.yml` file), and use `docker-compose up -d` to set up and run the bot.
The bot uses two containers that are networked internally:
> 1. A python app that runs the bot, and
> 2. A MongoDB database that stores the bot's data for persistence.
The database is not exposed externally to the network, and can only be accessed by the Bot in the network of containers.
The bot authenticates using an API key, which I have kept private in a `.env` file that I have not uploaded to the repository.
In order to set up your own instance of the bot, you will need to create two copies of the `.env` file, one in the root directory and one in the `app` folder, and enter the respective values for the API keys for the Geas Server Bot and the Test Bot.
You will also need this database to set up a username and password for the MongoDB database.
The specific username and password don't matter as the bot refers back to the environment variable when authenticating.
The following is the template for the `.env` file, with the variable names as are referenced in the bot's code:
`.env` file:
```
BOT_TOKEN=
TEST_TOKEN=
MONGO_INITDB_ROOT_USERNAME=
MONGO_INITDB_ROOT_PASSWORD=
BOT_VERSION=2.1.1
```
The only thing that remains is for the correct API keys to be entered in the environment variables in the `.env` file, and for a copy of this file to be placed in the root and the `app` directories.
**N.B.**: When the bot is first run, it is configured to log in as the Test Bot, and not the main Geas Server Bot, as a safety measure.
To change this, navigate to the last line of the file `bot.py` and change the line:
```
client.run(os.getenv('TEST_TOKEN'))
```
to
```
client.run(os.getenv('BOT_TOKEN'))
```
in order for to authenticate as the correct bot.
## Bot Structure
The bot is divided into the following files:
```
app folder
| bot.py -- bot core functionality and code entrypoint
| Dockerfile -- Docker instructions on building the bot
| requirements.txt -- Dependencies to be installed
----cogs -- Individual modules for specific features
GameManagement.py -- adding or kicking players
HelpNotifier.py -- notifications for Help channel
MembershipRestriction.py -- restrictions unverified users
MembershipVerification.py -- membership verification system
PitchMenu.py -- automation for generating menus for game pitches
```
Many of the specific features, such as the bot's prefix, the roles it recognises as Committee, the channels it recognises as the Help or Membership Verification channels, are all hard-coded into the Bot.
This is because the bot was only ever supposed to be used on one server, so did not need the flexibility of adapting to multiple channels.
In the future, if I ever tinker with this in the future, I might try and add flexibility in the channels and roles it defines for its various functions.
I might also, in future incarnations, not use a database.
It was fun to learn how to use a database, but it is overkill.
## Bot Commands
A full list of bot commands can be retrieved using the `-help` command in the bot, and this might be an easier way of retrieving the commands than having a separate copy in the documentation.

6
app/Dockerfile Normal file
View File

@ -0,0 +1,6 @@
FROM python:3.8.6-buster
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN pip install --upgrade pip
RUN pip install -r requirements.txt
CMD python3 -u ./bot.py

466
app/bot.py Normal file
View File

@ -0,0 +1,466 @@
# Import Dependencies
import os
import pymongo
import discord
from discord.ext import commands, tasks
# Set Intents
intents = discord.Intents.all()
intents.typing = True
intents.presences = True
intents.members = True
caches = discord.MemberCacheFlags.all()
# Set Prefix
p = '¬'
# Define Global State Dictionary
state = {}
# Create Clients
dbClient = pymongo.MongoClient(host='geasbot-db', username=os.environ['MONGO_INITDB_ROOT_USERNAME'], password=os.environ['MONGO_INITDB_ROOT_PASSWORD'], authsource='admin', serverSelectionTimeoutMS=1000)
client = commands.Bot(command_prefix=p, description=f'Geas Server Bot v {os.getenv("BOT_VERSION")}. This is a bot to facilitate setting up game channels on the Geas server. The prefix for the bot is {p}. You can interact with and manipulate game channels that have been created with this bot by @-mentioning the relevant role associated with the game.', intents=intents)
# Define Game Times Dictionary
gameTimes = {
'wed': 'WED',
'sunaft': 'SUN AFT',
'suneve': 'SUN EVE',
'oneshot': 'ONE SHOT'
}
# Reference Time Codes
def gameTime(arg):
return gameTimes.get(arg, 'OTHER')
# List Time Codes
def timeSlotList():
l = []
for t in gameTimes:
l.append(gameTimes[t])
l.append('OTHER')
return l
# Lookup Game Time Slot
def dbFindTimeslot(guild, role):
db = dbClient[str(guild.id)]
if role.name.split(': ',maxsplit=1)[0] not in timeSlotList():
raise commands.CommandError(f'Invalid lookup value: {role.mention} is not a valid game role.')
try:
for c in db.list_collection_names():
ret = db[c].find_one({'role':role.id})
if ret != None and c != 'settings':
return c
except:
return role.name.split(': ',maxsplit=1)[0]
# Lookup Category from Role
def dbLookupRole(guild, role):
db = dbClient[str(guild.id)]
colName = dbFindTimeslot(guild, role)
try:
catID = db[colName].find_one({'role':role.id})['category']
except:
for cat in guild.categories:
if cat.name == role.name:
catID = cat.id
break
try:
return guild.get_channel(catID)
except:
raise commands.CommandError('Error: The game\'s corresponding category cannot be matched.')
# Get Settings from DB
def dbGetSettings():
try:
for db in dbClient.list_database_names():
if db not in ['admin','config','local']:
state[db] = {
'settings': dbClient[db]['settings'].find_one({'_id':0}).copy()
}
print('Imported settings from database to local state.',state)
except Exception as err:
print('Failed to get Settings from Database due to error: ',err)
# Get list of game role IDs on server
def gameRoleIDList(guild):
l = []
for r in guild.roles:
if r.name.split(': ',maxsplit=1)[0] in timeSlotList():
l.append(r.id)
return l
# Get list of game role IDs in DB
def gameRoleDBList(guild):
dbName = str(guild.id)
db = dbClient[dbName]
l = []
try:
for ts in timeSlotList():
for e in db[ts].find():
l.append(e['role'])
return l
except:
pass
# Get list of game category IDs in DB
def gameCategoryDBList(guild):
dbName = str(guild.id)
db = dbClient[dbName]
l = []
try:
for ts in timeSlotList():
for e in db[ts].find():
l.append(e['category'])
return l
except:
pass
# Get list of GM IDs in DB
def gmDBList(guild):
dbName = str(guild.id)
db = dbClient[dbName]
l = []
try:
for ts in timeSlotList():
for e in db[ts].find():
l.append(e['gm'])
return l
except:
pass
# Sync Games on Server
def syncGames(guild):
dbName = str(guild.id)
db = dbClient[dbName]
try:
for ts in timeSlotList():
for e in db[ts].find():
if e['role'] not in gameRoleIDList(guild):
db[ts].delete_many({'role':e['role']})
for r in guild.roles:
if r.name.split(': ',maxsplit=1)[0] in timeSlotList():
if r.id not in gameRoleDBList(guild):
gameName = r.name.split(': ',maxsplit=1)[1]
colName = r.name.split(': ',maxsplit=1)[0]
for c in guild.categories:
if c.name == r.name:
break
permissions = c.overwrites
for p in permissions:
if isinstance(p,discord.Member) and permissions[p].manage_channels:
break
g = {
'game': gameName,
'gm': p.id,
'category': c.id,
'capacity': 5,
'role': r.id
}
db[colName].replace_one({'role':g['role']}, g, upsert=True)
for c in guild.categories:
if c.name.split(': ',maxsplit=1)[0] in timeSlotList():
if c.id not in gameCategoryDBList(guild):
for r in guild.roles:
if r.name == c.name:
break
if r.name != c.name:
break
colName = r.name.split(': ',maxsplit=1)[0]
db[colName].update_one({'role':r.id},{'$set':{'category':c.id}})
for p in c.overwrites:
if isinstance(p,discord.Member) and c.overwrites[p].manage_channels:
break
if p.id not in gmDBList(guild):
for r in guild.roles:
if r.name == c.name:
break
if r.name != c.name:
break
colName = r.name.split(': ',maxsplit=1)[0]
db[colName].update_one({'role':r.id},{'$set':{'gm':p.id}})
print(f'Synced database for server {guild.name}')
except:
pass
# Sync for Each Guild
@tasks.loop(hours=1.0)
async def syncGuilds():
try:
for guild in client.guilds:
syncGames(guild)
print('Synced database with server games list.')
except:
pass
@client.event
async def on_command_error(ctx,error):
if isinstance(error, commands.CommandNotFound):
await ctx.send(f'Invalid command. Please use `{p}help` to see a list of available commands.')
else:
await ctx.channel.send(error)
# On Ready
@client.event
async def on_ready():
await client.change_presence(activity=discord.Activity(type=discord.ActivityType.listening, name=f'{p} commands'))
print(f'Bot has logged in as {client.user.name} responding to prefix {p}')
print(f'Geas Server Bot version {os.getenv("BOT_VERSION")} by Vivek Santayana')
dbGetSettings()
syncGuilds.start()
# Check Bot Role is Defined
def botrole_is_defined():
async def predicate(ctx):
try:
return state[str(ctx.guild.id)]['settings']['botrole'] != None
except:
raise commands.CommandError(f'Bot role has not been defined. Please set the bot role using the `{p}definebotrole` command first.')
return commands.check(predicate)
# Check if invoked in valid game channel
def in_game_channel(ctx):
categoriesList = []
db = dbClient[str(ctx.guild.id)]
try:
try:
for c in db.list_collection_names():
ret = db[c].find({})
for e in ret:
if ctx.channel.category == ctx.guild.get_channel(e['category']):
return True
except:
if ctx.channel.category.name.split(': ',maxsplit=1)[0] in timeSlotList():
return True
except:
pass
if ctx.command.name == 'deletegame':
raise commands.CommandError('Error: you must invoke this command in a text channel corresponding to the game you are attempting to delete.')
raise commands.CommandError(f'Error: you must invoke this command in the text channel of your game.')
# setupGame command: <when>, <@GM> <capacity>, <Name of game>
@client.command(name='setupgame', aliases=['setup','gamesetup','creategame','gamecreate','create'], description='Use this command to set up the roles and channels for a new game. The syntax is `setup {wed|sunaft|suneve|oneshot|other} {@GM Name} {Capacity} {Name of game}`')
@commands.has_permissions(administrator=True)
@botrole_is_defined()
async def setupGame(ctx,arg1,arg2, arg3: int, *, arg4):
if not (arg2.startswith('<@') and not (arg2.startswith('<@&'))):
raise commands.CommandError('Invalid argument. The second parameter must @ the GM.')
dbName = str(ctx.guild.id)
db = dbClient[dbName]
colName = gameTime(arg1.lower())
if colName == 'OTHER':
await ctx.channel.send('Time code not recognised. Game will be categorised as \'Other\'.')
await ctx.channel.trigger_typing()
gm = int(arg2.replace('<@', '').replace('>', '').replace('!', ''))
gmMember = await ctx.guild.fetch_member(gm)
cap = int(arg3)
gameTitle = f'{colName}: {arg4}'
roleExists = False
for r in ctx.guild.roles:
if r.name == gameTitle:
roleExists = True
break
if not roleExists:
r = await ctx.guild.create_role(name=gameTitle)
await r.edit(mentionable=True)
await gmMember.add_roles(r)
categoryExists = False
for c in ctx.guild.categories:
if c.name == gameTitle:
categoryExists = True
break
ret = state[dbName]['settings']
bots = ctx.guild.get_role(ret['botrole'])
permissions = {
ctx.guild.default_role: discord.PermissionOverwrite(read_messages=False),
r: discord.PermissionOverwrite(read_messages=True),
bots: discord.PermissionOverwrite(read_messages=True),
gmMember: discord.PermissionOverwrite(read_messages=True, manage_messages=True, manage_channels=True, manage_permissions=True,priority_speaker=True,move_members=True,mute_members=True,deafen_members=True),
}
if not categoryExists:
c = await ctx.guild.create_category(name=gameTitle, overwrites=permissions)
await c.create_voice_channel(name=f'voice: {gameTitle}', topic=f'Default voice channel for {gameTitle}')
t = await c.create_text_channel(name=f'text: {gameTitle}', topic=f'Default text channel for {gameTitle}')
await ctx.channel.send(f'Game {r.mention} has been created with GM {gmMember.mention} and space for {cap} players.')
await t.send(f'Hello, {gmMember.mention}! Your game channels for {gameTitle} have now been set up.\nYou can also ping your players or interact with the bot commands by mentioning the {r.mention} role.')
else:
await c.edit(overwrites=permissions)
tPos = len(ctx.guild.channels)
tFirst = None
vExists = False
for t in c.text_channels:
if t.position <= tPos:
tFirst = t
tPos = t.position
await t.edit(sync_permissions=True)
for v in c.voice_channels:
await v.edit(sync_permissions=True)
vExists = True
await ctx.channel.send(f'The category for game {r.mention} has been reset for GM {gmMember.mention} with space for {cap} players.')
if tFirst == None:
tFirst = await c.create_text_channel(name=f'text: {gameTitle}', topic=f'Default text channel for {gameTitle}')
if not vExists:
await c.create_voice_channel(name=f'voice: {gameTitle}', topic=f'Default voice channel for {gameTitle}')
await tFirst.send(f'Hello, {gmMember.mention}! Your game channels for {gameTitle} have now been set up.\nYou can also ping your players or interact with the bot commands by mentioning the {r.mention} role.')
g = {
'game': arg4,
'gm': gm,
'capacity': cap,
'category': c.id,
'role': r.id
}
try:
db[colName].replace_one({'role':g['role']}, g , upsert=True)
except:
pass
@setupGame.error
async def clear_error(ctx, error):
if isinstance(error, commands.BadArgument):
await ctx.channel.send('The number of players in the game must be an integer.')
raise error
# defineBotrole command
@client.command(name='definebotrole', aliases=['setbotrole','botrole','botroleset','botroledefine','br'], description='This is a set-up command to define which role is the botrole that the dice bot use. The syntax is `botrole {@Role}`')
@commands.has_permissions(administrator=True)
async def defineBotrole(ctx, arg):
if not arg.startswith('<@&'):
raise commands.CommandError('Invalid argument. The argument must @ a role.')
dbName = str(ctx.guild.id)
db = dbClient[dbName]
colName = 'settings'
r = ctx.guild.get_role(int(arg[3:-1]))
if dbName not in state:
state[dbName] = {}
if 'settings' not in state[dbName]:
state[dbName]['settings'] = {}
ret = state[dbName]['settings']
ret['_id'] = 0
if 'botrole' not in ret:
await ctx.channel.send(f'Bot role for server {ctx.guild.name} not set. Setting {r.mention} to bot role now.')
else:
await ctx.channel.send(f'Bot role for server {ctx.guild.name} has already been set to {ctx.guild.get_role(ret["botrole"]).mention}. Updating to {r.mention}.')
ret['botrole'] = r.id
try:
db[colName].update_one({'_id':0},{'$set':{'botrole': r.id}}, upsert=True)
except:
pass
# deleteGame command
@client.command(name='deletegame', aliases=['delete','del', 'removegame', 'delgame', 'gamedel', 'gamedelete'], description='Use this command to delete the role and associated channels for a game. The syntax is `delete {@Game Role}`. **It must be called in a text channel inside the relevant game.**')
@commands.has_permissions(administrator=True)
@commands.check(in_game_channel)
async def deleteGame(ctx, arg):
if not arg.startswith('<@&'):
raise commands.CommandError('Invalid argument. The argument must @ a role.')
r = ctx.guild.get_role(int(arg[3:-1]))
cat = dbLookupRole(ctx.guild, r)
dbName = str(ctx.guild.id)
db = dbClient[dbName]
await ctx.channel.trigger_typing()
try:
colName = dbFindTimeslot(ctx.guild,r)
except:
raise commands.CommandError(f'Invalid argument. {r.mention} role is not a valid game role.')
for t in ctx.guild.text_channels:
if t.category == cat:
await t.delete()
for v in ctx.guild.voice_channels:
if v.category == cat:
await v.delete()
await cat.delete()
try:
db[colName].delete_one({'role':r.id})
except:
pass
await r.delete()
# reset command reset wed|sunaft|suneve|oneshot|all|other
@client.command(name='reset', aliases=['deleteall','delall','cleargames','clear'], description='This deletes all games in a particular time slot category. This is a very powerful command. Be careful when you use it. The syntax is `reset {wed|sunaft|suneve|oneshot|other|all}`')
@commands.has_permissions(administrator=True)
async def reset(ctx, *args):
delList = []
dbName = str(ctx.guild.id)
db = dbClient[dbName]
await ctx.channel.trigger_typing()
for a in args:
if a.lower() not in ['all', 'other', 'wed', 'sunaft', 'suneve', 'oneshot']:
raise commands.CommandError(f'Invalid argument. {a} is not a valid flag for the command.')
if a.lower() == 'all':
for l in ['wed', 'sunaft', 'suneve', 'oneshot','other']:
if l not in delList:
delList.append(l)
else:
if a.lower() not in delList:
delList.append(a.lower())
for d in delList:
colName = gameTime(d)
try:
cur = db[colName].find({})
for g in cur:
await ctx.guild.get_role(g['role']).delete()
cat = ctx.guild.get_channel(g['category'])
for c in cat.channels:
await c.delete()
await cat.delete()
db[colName].deleteMany({})
except:
for r in ctx.guild.roles:
if r.name.startswith(colName):
for cat in ctx.guild.categories:
if cat.name == r.name:
for c in cat.channels:
await c.delete()
await cat.delete()
await r.delete()
await ctx.channel.send(f'All games for {colName} have been deleted.')
# Migrate Guild from Old Server Settings to New Settings
@client.command(name='migrate', aliases=['migrategames','migratedata'], description='A set-up command to migrate games from the old server settings to the new server settings using the database for the first time.')
@commands.has_permissions(administrator=True)
async def migrateData(ctx):
await ctx.channel.trigger_typing()
dbName = str(ctx.guild.id)
db = dbClient[dbName]
gNum = 0
for r in ctx.guild.roles:
if r.name.split(': ',maxsplit=1)[0] in timeSlotList():
gNum += 1
await r.edit(mentionable=True)
gameName = r.name.split(': ',maxsplit=1)[1]
colName = r.name.split(': ',maxsplit=1)[0]
for c in ctx.guild.categories:
if c.name == r.name:
break
permissions = c.overwrites
for p in permissions:
if isinstance(p,discord.Member) and permissions[p].manage_channels:
break
g = {
'game': gameName,
'gm': p.id,
'capacity': 5,
'category': c.id,
'role': r.id
}
try:
db[colName].replace_one({'role':g['role']}, g , upsert=True)
except:
raise commands.CommandError('Error: Database connection failed. Could not migrate games to database.')
await ctx.channel.send(f'Finished migrating {gNum} games onto the database.')
# Easter egg for Alan
@client.command()
async def alan(ctx):
if ctx.author.id != 355857207205691392:
alan = await ctx.guild.fetch_member(355857207205691392)
raise commands.CommandError(f'Error: This is a powerful, super secret master command that only {alan.mention} can use. Do **NOT** invoke this if you are not Alan. ~~It is a command that can do great evil in the wrong hands.~~')
await ctx.send(f'Hi {alan.mention}. In order to execute this script, you must click the following link:\n<http://vsnt.uk/alancommand>\nUse it wisely.')
# Import Cogs
for cogfile in os.listdir('./cogs'):
if cogfile.endswith('.py'):
client.load_extension(f'cogs.{cogfile[:-3]}')
# Run Bot
client.run(os.getenv('TEST_TOKEN'))

177
app/cogs/GameManagement.py Normal file
View File

@ -0,0 +1,177 @@
import os
import pymongo
import discord
from discord.ext import commands
from bot import dbClient, p, state, gameTimes, gameTime, timeSlotList, dbFindTimeslot, dbLookupRole, in_game_channel
# Lookup GMs
def gmLookup(guild, member):
gamesList = []
db = dbClient[str(guild.id)]
try:
for c in db.list_collection_names():
ret = db[c].find({'gm':member.id})
for e in ret:
gamesList.append(guild.get_role(e['role']))
except:
for cat in guild.categories:
if cat.name.split(': ',maxsplit=1)[0] in timeSlotList():
for p in cat.overwrites:
if cat.overwrites[member].manage_channels:
for r in guild.roles:
if r.name == cat.name:
gamesList.append(r)
break
break
finally:
return gamesList
# Check if User is a GM
def user_is_GM(ctx):
if ctx.author.guild_permissions.administrator:
return True
if len(gmLookup(ctx.guild,ctx.author)) > 0:
return True
# Check if User is a Player
def user_is_Player(ctx):
gamesList = []
db = dbClient[str(ctx.guild.id)]
try:
for c in db.list_collection_names():
ret = db[c].find({})
for e in ret:
gamesList.append(ctx.guild.get_role(e['role']))
except:
for r in ctx.guild.roles:
if r.name.split(': ',maxsplit = 1)[0] in timeSlotList():
gamesList.append(r)
if set(gamesList) & set(ctx.author.roles):
return True
raise commands.CommandError('Error: You are not currently playing in any game.')
class GameManagement(commands.Cog, name='Game Management Commands'):
def __init__(self, client):
self.client = client
# GM Kick Command
@commands.command(name='kickplayer', aliases=['kick','removeplayer','dropplayer','drop', 'remove'],description='This removes a player from your game. Can only be invoked by the GM or a server admin. The syntax is `kickplayer {@Player}`. **This command must be called inside the text channel of the game you are kicking the player from.**. *The action gets logged with the Committee so we can keep track of who is in wose game.*')
@commands.check(user_is_GM)
@commands.check(in_game_channel)
async def kickPlayer(self, ctx, arg):
if not (arg.startswith('<@') and not (arg.startswith('<@&'))):
raise commands.CommandError('Invalid argument. The second parameter must @ the Player.')
if not ctx.author.permissions_in(ctx.channel.category).manage_channels:
raise commands.CommandError('You are not authorised to use this command here as you are not the GM.')
await ctx.message.delete()
await ctx.channel.trigger_typing()
permissions = ctx.channel.category.overwrites
for p in permissions:
if isinstance(p,discord.Role) and p.name == ctx.channel.category.name:
break
u = await ctx.guild.fetch_member(int(arg.replace('<@', '').replace('>', '').replace('!', '')))
await u.remove_roles(p)
isPlayer = False
for playerRoles in u.roles:
if playerRoles.name.split(': ',maxsplit=1)[0] in timeSlotList():
isPlayer = True
break
if not isPlayer:
for r in ctx.guild.roles:
if r.name == 'Players':
break
if r.name == 'Players':
await u.remove_roles(r)
for cr in ctx.guild.roles:
if cr.name == 'Committee':
break
await ctx.channel.send(f'{u.mention} has been kicked from the game. This has been logged with the {cr.mention}.')
for tc in ctx.guild.text_channels:
if tc.name.split('-',maxsplit=1)[1] == 'moderator-logs':
break
await tc.send(f'Hey {cr.mention}, {u.mention} has been kicked from the {p.mention} game by GM {ctx.author.mention}.')
@kickPlayer.error
async def clear_kick_error(self, ctx, error):
if isinstance(error, commands.CheckFailure):
await ctx.channel.send('You are not authorised to use this command as you are not a GM.')
raise error
# GM Add Command
@commands.command(name='addplayer', aliases=['add'],description='This command adds a player to your game. Can only be invoked by the GM for the game. The syntax is `addplayer {@Player} {@Game Role}`. As you cannot @-mention someone who cannot already see your channel, you are **not restricted** to use this command in a text channel belonging to your game. *The action gets logged with the Committee so we can keep track of who is in wose game.*')
@commands.check(user_is_GM)
async def addPlayer(self, ctx, arg1, arg2):
if not (arg1.startswith('<@') and not (arg1.startswith('<@&'))):
raise commands.CommandError('Invalid argument. The first parameter must @ the Player.')
if not arg2.startswith('<@&'):
raise commands.CommandError('Invalid argument. The second parameter must @ the game role.')
r = ctx.guild.get_role(int(arg2[3:-1]))
if r.name.split(': ',maxsplit=1)[0] not in timeSlotList():
raise commands.CommandError('Error: the role is not a valid game role.')
cat = dbLookupRole(ctx.guild,r)
if not ctx.author.permissions_in(cat).manage_channels:
raise commands.CommandError('You are not authorised to use this command as you are not the GM for the game.')
await ctx.message.delete()
await ctx.channel.trigger_typing()
u = await ctx.guild.fetch_member(int(arg1.replace('<@', '').replace('>', '').replace('!', '')))
for rl in ctx.guild.roles:
if rl.name == 'Players':
break
await u.add_roles(r,rl)
tPos = len(ctx.guild.channels)
tFirst = None
for t in cat.text_channels:
if t.position <= tPos:
tFirst = t
tPos = t.position
for cr in ctx.guild.roles:
if cr.name == 'Committee':
break
for tc in ctx.guild.text_channels:
if tc.name.split('-',maxsplit=1)[1] == 'moderator-logs':
break
await tFirst.send(f'{u.mention} has joined the game. Welcome! This has been logged with the {cr.mention}.')
await ctx.channel.send(f'{u.mention} has been added to the game {r.mention}.')
await tc.send(f'Hey {cr.mention}, {u.mention} was added to the {r.mention} game by {ctx.author.mention}.')
@addPlayer.error
async def clear_add_error(self, ctx, error):
if isinstance(error, commands.CheckFailure):
await ctx.channel.send('You are not authorised to use this command as you are not a GM.')
raise error
# Leave Game Command
@commands.command(name='leave',aliases=['leavegame','quit','quitgame','dropout'],description='This command is to leave the game you are in. **It must be invoked in the text channel of the game you are in.** *The action gets logged with the Committee so we can keep track of who is in wose game.*')
@commands.check(user_is_Player)
@commands.check(in_game_channel)
async def leaveGame(self, ctx):
await ctx.message.delete()
await ctx.channel.trigger_typing()
permissions = ctx.channel.category.overwrites
for p in permissions:
if isinstance(p,discord.Role) and p.name == ctx.channel.category.name:
break
await ctx.author.remove_roles(p)
isPlayer = False
for playerRoles in ctx.author.roles:
if playerRoles.name.split(': ',maxsplit=1)[0] in timeSlotList():
isPlayer = True
break
if not isPlayer:
for r in ctx.guild.roles:
if r.name == 'Players':
break
if r.name == 'Players':
await ctx.author.remove_roles(r)
for cr in ctx.guild.roles:
if cr.name == 'Committee':
break
await ctx.channel.send(f'{ctx.author.mention} has left the game. This has been logged with the {cr.mention}.')
for tc in ctx.guild.text_channels:
if tc.name.split('-',maxsplit=1)[1] == 'moderator-logs':
break
await tc.send(f'Hey {cr.mention}, {ctx.author.mention} has left the {p.mention} game by GM {ctx.author.mention}.')
# Cog Setup Function
def setup(client):
client.add_cog(GameManagement(client))

48
app/cogs/HelpNotifier.py Normal file
View File

@ -0,0 +1,48 @@
import discord
from discord.ext import commands, tasks
from datetime import datetime
def helpChannels(client):
l = []
for guild in client.guilds:
channel = discord.utils.find(lambda c: c.name == '⛑-help', guild.channels)
l.append(channel)
return l
def committeeRoles(client):
l = []
for guild in client.guilds:
role = discord.utils.find(lambda r: r.name == 'Committee', guild.roles)
l.append(role)
return l
def checkCommitteeRoles(author,committee):
if set(author.roles) & set(committee):
return True
class HelpNotifier(commands.Cog, name='Help Notifier Commands'):
def __init__(self,client):
self.client = client
# Message in Help channel event listener.
@commands.Cog.listener()
async def on_message(self,message):
if message.author.bot:
return
if checkCommitteeRoles(message.author, committeeRoles(self.client)):
return
if message.channel not in helpChannels(self.client):
return
committeeChannel = discord.utils.find(lambda t: t.name == '🗞-moderator-logs',message.guild.channels)
committeeRole = discord.utils.find(lambda c: c.name == 'Committee', message.guild.roles)
embed = discord.Embed(
title = message.content,
description = f'[Jump to Message]({message.jump_url})',
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 committeeChannel.send(f'Hey {committeeRole.mention}, {message.author.mention} has just posted the following message in the {message.channel.mention} channel', embed=embed)
def setup(client):
client.add_cog(HelpNotifier(client))

View File

@ -0,0 +1,112 @@
import discord
from discord.ext import commands, tasks
from datetime import datetime
from bot import dbClient, p, state, gameTimes, gameTime, timeSlotList, dbFindTimeslot, dbLookupRole
def gameCategories(client):
l = []
try:
for guild in client.guilds:
dbName = str(guild.id)
db = dbClient[dbName]
for colName in db.list_collection_names():
if colName != 'settings':
ret = db[colName].find()
for e in ret:
l.append(guild.get_channel(e['category']))
except:
for guild in client.guilds:
for cat in guild.categories:
if cat.name.split(': ',maxsplit=1)[0] in timeSlotList():
l.append(cat)
return l
def gameRoles(client):
l = []
try:
for guild in client.guilds:
dbName = str(guild.id)
db = dbClient[dbName]
for colName in db.list_collection_names():
if colName != 'settings':
ret = db[colName].find()
for e in ret:
l.append(guild.get_role(e['role']))
except:
for guild in client.guilds:
for role in guild.roles:
if role.name.split(': ',maxsplit=1)[0] in timeSlotList():
l.append(role)
return l
def committeeRoles(client):
l = []
for guild in client.guilds:
role = discord.utils.find(lambda r: r.name == 'Committee', guild.roles)
l.append(role)
return l
def memberRoles(client):
l = []
for guild in client.guilds:
role = discord.utils.find(lambda r: r.name == 'Life Members', guild.roles)
l.append(role)
role = discord.utils.find(lambda r: r.name == 'Members: Full Year', guild.roles)
l.append(role)
role = discord.utils.find(lambda r: r.name == 'Members: Semester 2', guild.roles)
l.append(role)
role = discord.utils.find(lambda r: r.name == 'Members: Semester 1', guild.roles)
l.append(role)
role = discord.utils.find(lambda r: r.name == 'New Member', guild.roles)
l.append(role)
role = discord.utils.find(lambda r: r.name == 'Temporary Access', guild.roles)
l.append(role)
return l
def checkCommitteeRoles(author,committee):
if set(author.roles) & set(committee):
return True
def checkMemberRoles(author,memberRoles):
if set(author.roles) & set(memberRoles):
return True
class MembershipRestriction(commands.Cog, name='Membership Restriction Protocol'):
def __init__(self,client):
self.client = client
# Event Listener for Message from Non-Member in Game Channels
@commands.Cog.listener()
async def on_message(self,message):
if message.author.bot:
return
guestRole = discord.utils.find(lambda g: g.name == 'Guest', message.guild.roles)
if guestRole.permissions.read_messages:
return
if message.channel.category not in gameCategories(self.client):
return
if checkCommitteeRoles(message.author, committeeRoles(self.client)) or message.guild.owner == message.author:
return
if checkMemberRoles(message.author, memberRoles(self.client)):
return
signupChannel = discord.utils.find(lambda c: c.name == '📋-membership-signups', message.guild.channels)
if message.channel.overwrites_for(message.author).manage_channels:
return
await message.channel.send(f'{message.author.mention} does not have a verified membership of Geas. Please submit your membership confirmation for verification in the {signupChannel.mention} to ensure you have access to your game.')
await message.channel.category.set_permissions(message.author, send_messages = False, connect = False)
await message.delete()
# Event Listener for Reinstating Permissions when Membership is Assigned
@commands.Cog.listener()
async def on_member_update(self,before,after):
if before.roles == after.roles:
return
if checkMemberRoles(after, memberRoles(self.client)):
for g in after.roles:
if g in gameRoles(self.client):
cat = dbLookupRole(after.guild, g)
if not cat.overwrites_for(after).send_messages and not cat.overwrites_for(after).manage_channels:
await cat.set_permissions(after, overwrite = None)
def setup(client):
client.add_cog(MembershipRestriction(client))

View File

@ -0,0 +1,142 @@
import discord
from discord.ext import commands, tasks
from datetime import datetime
def membershipSignupChannels(client):
l = []
for guild in client.guilds:
channel = discord.utils.find(lambda c: c.name == '📋-membership-signups', guild.channels)
l.append(channel)
return l
def committeeRoles(client):
l = []
for guild in client.guilds:
role = discord.utils.find(lambda r: r.name == 'Committee', guild.roles)
l.append(role)
return l
def checkCommitteeRoles(author,committee):
if set(author.roles) & set(committee):
return True
class MembershipVerification(commands.Cog, name='Membership Verification Commands'):
def __init__(self,client):
self.client = client
# Message in Membership Signup event listener.
@commands.Cog.listener()
async def on_message(self,message):
if message.channel in membershipSignupChannels(self.client) and message.author.id != self.client.user.id:
if message.attachments == []:
await message.author.send(f'**Error**: The message you posted in the {message.channel.name} channel of {message.guild.name} was invalid. Your post must contain a screensot of your proof of purchase for membership from EUSA.')
await message.delete()
return
await message.add_reaction('1')
await message.add_reaction('2')
await message.add_reaction('📅')
await message.add_reaction('📚')
await message.add_reaction('⚠️')
await message.add_reaction('🚫')
@commands.Cog.listener()
async def on_raw_reaction_add(self,payload):
if payload.user_id != self.client.user.id and self.client.get_channel(payload.channel_id) in membershipSignupChannels(self.client):
guild = await self.client.fetch_guild(payload.guild_id)
member = await guild.fetch_member(payload.user_id)
channel = await self.client.fetch_channel(payload.channel_id)
message = await channel.fetch_message(payload.message_id)
studentsRole = discord.utils.find(lambda g: g.name == 'Students', guild.roles)
semesterOneRole = discord.utils.find(lambda g: g.name == 'Members: Semester 1', guild.roles)
semesterTwoRole = discord.utils.find(lambda g: g.name == 'Members: Semester 2', guild.roles)
fullYearRole = discord.utils.find(lambda g: g.name == 'Members: Full Year', guild.roles)
channels = await guild.fetch_channels()
committeeChannel = discord.utils.find(lambda t: t.name == '🗞-moderator-logs', channels)
committeeRole = discord.utils.find(lambda c: c.name == 'Committee', guild.roles)
if not checkCommitteeRoles(member, committeeRoles(self.client)):
await member.send(f'**Error**: Only Committee members are authorised to react to posts on the {channel.name} channel for {guild.name}.')
await message.remove_reaction(payload.emoji.name, member)
return
if payload.emoji.name == '1':
await message.author.add_roles(semesterOneRole)
await message.add_reaction('')
await message.author.send(f'Your membership for {guild.name} has been verified and you have been assigned the role **Members: Semester 1**.')
return
if payload.emoji.name == '2':
await message.author.add_roles(semesterTwoRole)
await message.add_reaction('')
await message.author.send(f'Your membership for {guild.name} has been verified and you have been assigned the role **Members: Semester 2**.')
return
if payload.emoji.name == '📅':
await message.author.add_roles(fullYearRole)
await message.add_reaction('')
await message.author.send(f'Your membership for {guild.name} has been verified and you have been assigned the role **Members: Full Year**.')
return
if payload.emoji.name == '📚':
await message.author.add_roles(studentsRole)
await message.author.send(f'You have additionally been assigned the role **Students**.')
return
if payload.emoji.name == '⚠️':
embed = discord.Embed(
title = message.author.name,
description = f'[Jump to Message]({message.jump_url})',
colour = discord.Colour.orange(),
)
await message.author.send(f'Your membership for {guild.name} needs to be reviewed by a Committee member.')
await committeeChannel.send(f'Hey {committeeRole.mention}, there is a problem verifying the membership of {message.author.mention}.\nCould someone verify this person\'s membership manually via the EUSA portal and return to the message?', embed=embed)
return
if payload.emoji.name == '🚫':
embed = discord.Embed(
title = message.author.name,
description = f'[Jump to Message]({message.jump_url})',
colour = discord.Colour.red(),
)
await message.author.send(f'Your membership for {guild.name} could not be verified. Please make sure that your name and the kind of membership you have bought are visible in the screenshot you upload. Please contact a Committee member if you have any difficulties.')
await committeeChannel.send(f'Hey {committeeRole.mention}, verifying the membership of {message.author.mention} failed.', embed=embed)
return
@commands.Cog.listener()
async def on_raw_reaction_remove(self,payload):
if payload.user_id != self.client.user.id and self.client.get_channel(payload.channel_id) in membershipSignupChannels(self.client):
guild = await self.client.fetch_guild(payload.guild_id)
member = await guild.fetch_member(payload.user_id)
channel = await self.client.fetch_channel(payload.channel_id)
message = await channel.fetch_message(payload.message_id)
studentsRole = discord.utils.find(lambda g: g.name == 'Students', guild.roles)
semesterOneRole = discord.utils.find(lambda g: g.name == 'Members: Semester 1', guild.roles)
semesterTwoRole = discord.utils.find(lambda g: g.name == 'Members: Semester 2', guild.roles)
fullYearRole = discord.utils.find(lambda g: g.name == 'Members: Full Year', guild.roles)
channels = await guild.fetch_channels()
committeeChannel = discord.utils.find(lambda t: t.name == '🗞-moderator-logs', channels)
committeeRole = discord.utils.find(lambda c: c.name == 'Committee', guild.roles)
if not checkCommitteeRoles(member, committeeRoles(self.client)):
await message.remove_reaction(payload.emoji.name, member)
return
if payload.emoji.name == '1':
await message.author.remove_roles(semesterOneRole)
await message.remove_reaction('',self.client.user)
await message.author.send(f'Your role **Members: Semester 1** for {guild.name} has been removed.')
return
if payload.emoji.name == '2':
await message.author.remove_roles(semesterTwoRole)
await message.remove_reaction('',self.client.user)
await message.author.send(f'Your role **Members: Semester 2** for {guild.name} has been removed.')
return
if payload.emoji.name == '📅':
await message.author.remove_roles(fullYearRole)
await message.remove_reaction('',self.client.user)
await message.author.send(f'Your role **Members: Full Year** for {guild.name} has been removed.')
return
if payload.emoji.name == '📚':
await message.author.remove_roles(studentsRole)
await message.author.send(f'Your role **Students** for {guild.name} has been removed.')
return
if payload.emoji.name == '⚠️':
await message.author.send(f'Your membership for {guild.name} is being reviewed by a Committee member.')
return
if payload.emoji.name == '🚫':
await message.author.send(f'Your membership for {guild.name} is being reviewed by a Committee member.')
return
def setup(client):
client.add_cog(MembershipVerification(client))

190
app/cogs/PitchMenu.py Normal file
View File

@ -0,0 +1,190 @@
import os
import pymongo
import discord
from discord.ext import commands, tasks
from bot import dbClient, p, state, gameTimes, gameTime, timeSlotList, dbFindTimeslot, dbLookupRole, syncGames
pitchState = {}
def pitchListening():
l = []
for guild in pitchState:
for slot in pitchState[guild]:
l.append(pitchState[guild][slot]['menuMessage'].id)
return l
emojiList = [
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'🔟',
'🇦',
'🇧',
'🇨',
'🇩',
'🇪',
'🇫',
'🇬',
'🇭',
'🇮',
'🇯'
]
class PitchMenu(commands.Cog, name='Pitch Menu Commands'):
def __init__(self,client):
self.client = client
# Pitch Run Command
@commands.has_permissions(administrator=True)
@commands.group(name='pitch', aliases=['pitches'], description='The command to run pitches. It has two subcommands. Syntax: `pitch {run|clear} {wed|sunaft|suneve|oneshot|other}`')
async def pitch(self, ctx):
if ctx.invoked_subcommand is None:
await ctx.send(f'Invalid subcommand. Please use either `{p}pitch run` or `{p}pitch clear`')
@pitch.command(name='run', aliases=['start','generate','setup'], description='Subcommand to set up pitches. Syntax: `pitch run {wed|sunaft|suneve|oneshot|other}`')
async def pitch_run(self, ctx, arg):
if arg.lower() not in ['all', 'other', 'wed', 'sunaft', 'suneve', 'oneshot']:
raise commands.CommandError('Invalid argument. {arg} is not a valid flag for the command.')
syncGames(ctx.guild)
await ctx.message.delete()
await ctx.channel.trigger_typing()
## Constructing Pitch State Dictionary
dbName = str(ctx.guild.id)
db = dbClient[dbName]
colName = gameTime(arg.lower())
if dbName not in pitchState:
pitchState[dbName] = {}
if colName not in pitchState[dbName]:
pitchState[dbName][colName] = {}
pitchState[dbName][colName]['entries'] = []
# Try database queries
try:
cur = db[colName].find().sort('game')
for entry in cur:
gameDict = {}
gameDict['game'] = entry['game']
gameDict['gm'] = await ctx.guild.fetch_member(entry['gm'])
gameDict['role'] = discord.utils.find(lambda m: m.id == entry['role'],ctx.guild.roles)
gameDict['capacity'] = entry['capacity'] if entry['capacity'] != None else 5
gameDict['signups'] = 0
cat = discord.utils.find(lambda m: m.id == entry['category'], ctx.guild.categories)
tFirst = None
tPos = len(ctx.guild.channels)
for t in cat.text_channels:
if t.position <= tPos:
tFirst = t
tPos = t.position
gameDict['textchannel'] = tFirst
pitchState[dbName][colName]['entries'].append(dict(gameDict))
# Infer from server if database fails
except:
for r in ctx.guild.roles:
if r.name.startswith(colName):
gameDict = {}
gameDict['game'] = r.name.split(': ',maxsplit=1)[1]
gameDict['role'] = r
gameDict['capacity'] = 5
gameDict['signups'] = 0
cat = discord.utils.find(lambda m: m.name == r.name, ctx.guild.categories)
for p in cat.overwrites:
if isinstance(p,discord.Member) and cat.overwrites[p].manage_channels:
gameDict['gm'] = p
break
tFirst = None
tPos = len(ctx.guild.channels)
for t in cat.text_channels:
if t.position <= tPos:
tFirst = t
tPos = t.position
gameDict['textchannel'] = t
pitchState[dbName][colName]['entries'].append(dict(gameDict))
pitchState[dbName][colName]['entries'].sort(key= lambda m: m['game'])
# Begin Constructing the Menu
pitchState[dbName][colName]['headerMessage'] = await ctx.channel.send(f'**Game listing for {colName}**\n_ _\nThe following are the games that are being pitched. Please select your game by clicking on the emoji reaction at the bottom of the menu.\n_ _')
for e in pitchState[dbName][colName]['entries']:
e['message'] = await ctx.channel.send(f'{emojiList[pitchState[dbName][colName]["entries"].index(e)]} **{e["game"]}** (GM {e["gm"].mention}). {e["capacity"] - e["signups"]} spaces remaining.')
pitchState[dbName][colName]['menuMessage'] = await ctx.channel.send('_ _\n**Please select a game from the above list by clicking on the corresponding emoji reaction below.**')
for option in pitchState[dbName][colName]['entries']:
await pitchState[dbName][colName]['menuMessage'].add_reaction(emojiList[pitchState[dbName][colName]["entries"].index(option)])
@pitch.command(name='clear', aliases=['end','cancel','reset','delete'], description='Subcommand to clear pitches. {wed|sunaft|suneve|oneshot|other}')
async def pitch_clear(self, ctx, arg):
if arg.lower() not in ['all', 'other', 'wed', 'sunaft', 'suneve', 'oneshot']:
raise commands.CommandError(f'Invalid argument. {arg} is not a valid flag for the command.')
await ctx.message.delete()
await ctx.channel.trigger_typing()
dbName = str(ctx.guild.id)
db = dbClient[dbName]
colName = gameTime(arg.lower())
for e in pitchState[dbName][colName]['entries']:
await e['message'].delete()
await pitchState[dbName][colName]['menuMessage'].delete()
await pitchState[dbName][colName]['headerMessage'].delete()
await ctx.send(f'Pitch menu for {colName} has been reset.')
pitchState[dbName][colName].clear
# Emoji Reaction Event Listeners
@commands.Cog.listener()
async def on_raw_reaction_add(self, payload):
if payload.user_id != self.client.user.id:
if payload.message_id in pitchListening():
guildID = str(payload.guild_id)
guild = discord.utils.find(lambda g: g.id == payload.guild_id, self.client.guilds)
channel = discord.utils.find(lambda c: c.id == payload.channel_id, guild.channels)
author = await guild.fetch_member(payload.user_id)
for slot in pitchState[guildID]:
if pitchState[guildID][slot]['menuMessage'].id == payload.message_id:
break
message = await channel.fetch_message(pitchState[guildID][slot]['menuMessage'].id)
for reaction in message.reactions:
if reaction.emoji != payload.emoji.name:
i = emojiList.index(reaction.emoji)
if author in await reaction.users().flatten():
await reaction.remove(author)
i = emojiList.index(payload.emoji.name)
e = pitchState[guildID][slot]['entries'][i]
playerRole = discord.utils.find(lambda p: p.name == 'Players',guild.roles)
await author.add_roles(playerRole,e['role'])
e['signups'] += 1
contentString = f'{emojiList[i]} **{e["game"]}** (GM {e["gm"].mention}). {e["capacity"] - e["signups"] if e["signups"] <= e["capacity"] else 0} {"space" if e["capacity"] - e["signups"] == 1 else "spaces"} remaining.'
await e['message'].edit(content=f'~~{contentString}~~' if e['signups'] >= e['capacity'] else contentString)
await e['textchannel'].send(f'{author.mention} has joined the game.')
# Emoji Un-React Event Listener
@commands.Cog.listener()
async def on_raw_reaction_remove(self, payload):
if payload.user_id != self.client.user.id:
if payload.message_id in pitchListening():
guildID = str(payload.guild_id)
guild = discord.utils.find(lambda g: g.id == payload.guild_id, self.client.guilds)
channel = discord.utils.find(lambda c: c.id == payload.channel_id, guild.channels)
author = await guild.fetch_member(payload.user_id)
for slot in pitchState[guildID]:
if pitchState[guildID][slot]['menuMessage'].id == payload.message_id:
break
message = await channel.fetch_message(pitchState[guildID][slot]['menuMessage'].id)
i = emojiList.index(payload.emoji.name)
e = pitchState[guildID][slot]['entries'][i]
e['signups'] -= 1
contentString = f'{emojiList[i]} **{e["game"]}** (GM {e["gm"].mention}). {e["capacity"] - e["signups"] if e["signups"] <= e["capacity"] else 0} {"space" if e["capacity"] - e["signups"] == 1 else "spaces"} remaining.'
await e['message'].edit(content=f'~~{contentString}~~' if e['signups'] >= e['capacity'] else contentString)
await e['textchannel'].send(f'{author.mention} has left the game.')
await author.remove_roles(e['role'])
isPlayer = False
for role in author.roles:
if role.name.split(': ',maxsplit=1)[0] in timeSlotList():
isPlayer = True
break
if not isPlayer:
playerRole = discord.utils.find(lambda p: p.name == 'Players',guild.roles)
await author.remove_roles(playerRole)
def setup(client):
client.add_cog(PitchMenu(client))

5
app/requirements.txt Normal file
View File

@ -0,0 +1,5 @@
discord
python-dotenv
wheel
pymongo
datetime

29
docker-compose.yml Normal file
View File

@ -0,0 +1,29 @@
version: '3.4'
services:
geasbot-app:
build: ./app
volumes:
- ./app:/usr/src/app
restart: always
depends_on:
- geasbot-db
environment:
- BOT_TOKEN=${BOT_TOKEN}
- TEST_TOKEN=${TEST_TOKEN}
- MONGO_INITDB_ROOT_USERNAME=${MONGO_INITDB_ROOT_USERNAME}
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_INITDB_ROOT_PASSWORD}
- BOT_VERSION=${BOT_VERSION}
geasbot-db:
image: mongo:latest
volumes:
- ./db:/data/db
expose:
- "27017"
environment:
- BOT_TOKEN=${BOT_TOKEN}
- TEST_TOKEN=${TEST_TOKEN}
- MONGO_INITDB_ROOT_USERNAME=${MONGO_INITDB_ROOT_USERNAME}
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_INITDB_ROOT_PASSWORD}
- BOT_VERSION=${BOT_VERSION}