diff --git a/.gitignore b/.gitignore index 2766bcf..fa3583a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +db/* + # ---> Python # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/README.md b/README.md index 3c7c1e5..92e5e18 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,68 @@ -# geas-bot +# Geas Server Bot -Bot for managing the Discord server for Geas, the Edinburgh table-top role-playing society. \ No newline at end of file +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. \ No newline at end of file diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..f0af2fd --- /dev/null +++ b/app/Dockerfile @@ -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 \ No newline at end of file diff --git a/app/bot.py b/app/bot.py new file mode 100644 index 0000000..97db4b1 --- /dev/null +++ b/app/bot.py @@ -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: , <@GM> , +@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\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')) \ No newline at end of file diff --git a/app/cogs/GameManagement.py b/app/cogs/GameManagement.py new file mode 100644 index 0000000..6256f8b --- /dev/null +++ b/app/cogs/GameManagement.py @@ -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)) \ No newline at end of file diff --git a/app/cogs/HelpNotifier.py b/app/cogs/HelpNotifier.py new file mode 100644 index 0000000..f5abea0 --- /dev/null +++ b/app/cogs/HelpNotifier.py @@ -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)) \ No newline at end of file diff --git a/app/cogs/MembershipRestriction.py b/app/cogs/MembershipRestriction.py new file mode 100644 index 0000000..6fb516b --- /dev/null +++ b/app/cogs/MembershipRestriction.py @@ -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)) \ No newline at end of file diff --git a/app/cogs/MembershipVerification.py b/app/cogs/MembershipVerification.py new file mode 100644 index 0000000..1fb47e8 --- /dev/null +++ b/app/cogs/MembershipVerification.py @@ -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)) \ No newline at end of file diff --git a/app/cogs/PitchMenu.py b/app/cogs/PitchMenu.py new file mode 100644 index 0000000..6e64cf8 --- /dev/null +++ b/app/cogs/PitchMenu.py @@ -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)) \ No newline at end of file diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..e126c24 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,5 @@ +discord +python-dotenv +wheel +pymongo +datetime \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b4c2045 --- /dev/null +++ b/docker-compose.yml @@ -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} \ No newline at end of file