class Minecraft(commands.Cog): """Extension for commands for a guild Minecraft server Adds the following commands: - `?mc`: provides the Minecraft server information for the guild - `?mc clear`: clear Minecraft server information for the guild - `?mc set name [str]`: set name of Minecraft server - `?mc set address [str]`: set ip address of Minecraft server - `?mc set whitelist [bool]`: set if server has a whitelist - `?mc set admin @Member`: admin of server to message for whitelist Attributes: mc_dict (dict): dict indexed by guild IDs with values corresponding to dicts of the form: :code:`{name: str, address: str, has_whitelist: bool, admin_id: int}`. """ def __init__(self, bot: Bot, mc_file: str) -> None: """Init Minecraft Args: bot (Bot): bot that loaded this cog mc_file (str): path to store database """ self.bot = bot self.db = GuildDatabase(mc_file) async def mc(self, ctx: commands.Context) -> None: """Message `ctx.channel` with Minecraft server info Args: ctx (Context): context from command call """ if not self._server_exists(ctx.guild): await self.bot.message_guild( 'there is no minecraft server on this guild. ' 'Use `mc set name [name]` and `mc set address [ip]` ' 'to set one', ctx.channel) else: name = self.db.value(ctx.guild, 'name') address = self.db.value(ctx.guild, 'address') has_whitelist = self.db.value(ctx.guild, 'has_whitelist') admin_id = self.db.value(ctx.guild, 'admin_id') msg = 'To login to the {} server:\n'.format(name) msg += ' - join the server using IP: {}\n'.format(address) if has_whitelist is True and admin_id is not None: msg += ' - message <@{}> for whitelist'.format(admin_id) await self.bot.message_guild(msg, ctx.channel) async def clear(self, ctx: commands.Context) -> None: """Clear Mincraft server info for `ctx.guild` Args: ctx (Context): context from command call """ if is_admin(ctx.author) or self.bot.is_bot_admin(ctx.author): self.db.drop_table(ctx.guild) await self.bot.message_guild('cleared Minecraft server info', ctx.channel) else: raise commands.MissingPermissions async def address(self, ctx: commands.Context, address: str) -> None: """Change Minecraft server name for the guild Args: ctx (Context): context from command call address (str): new ip address """ if is_admin(ctx.author) or self.bot.is_bot_admin(ctx.author): self.db.set(ctx.guild, 'address', address) await self.bot.message_guild( 'updated server address to: {}'.format(address), ctx.channel) else: raise commands.MissingPermissions async def admin(self, ctx: commands.Context, member: discord.Member) -> None: """Change Minecraft server name for the guild The "admin" is the guild member that will be mentioned for contact if there is a whitelist on the server. Args: ctx (Context): context from command call member (Member): member to set as Minecraft server admin """ if is_admin(ctx.author) or self.bot.is_bot_admin(ctx.author): self.db.set(ctx.guild, 'admin_id', member.id) await self.bot.message_guild( 'updated server admin to: {}'.format(member.mention), ctx.channel) else: raise commands.MissingPermissions async def name(self, ctx: commands.Context, name: str) -> None: """Change Minecraft server name for the guild Args: ctx (Context): context from command call name (str): new server name """ if is_admin(ctx.author) or self.bot.is_bot_admin(ctx.author): self.db.set(ctx.guild, 'name', name) await self.bot.message_guild( 'updated server name to: {}'.format(name), ctx.channel) else: raise commands.MissingPermissions async def whitelist(self, ctx: commands.Context, has_whitelist: bool) -> None: """Change Minecraft server name for the guild Args: ctx (Context): context from command call has_whitelist (bool): if the server has a whitelist """ if is_admin(ctx.author) or self.bot.is_bot_admin(ctx.author): self.db.set(ctx.guild, 'has_whitelist', has_whitelist) await self.bot.message_guild( 'updated server whitelist to: {}'.format(has_whitelist), ctx.channel) else: raise commands.MissingPermissions def _server_exists(self, guild: discord.Guild) -> bool: """Check if guild has server info The name and address are required for the server to be valid. """ return (self.db.value(guild, 'name') is not None and self.db.value(guild, 'address') is not None) @commands.group( name='mc', pass_context=True, brief='Minecraft server info', description='Manage and see Minecraft server info for the guild. ' 'Calling mc on its own will print the server info.') async def _mc(self, ctx: commands.Context) -> None: if ctx.invoked_subcommand is None: await self.mc(ctx) @_mc.command( name='clear', pass_context=True, brief='clear Minecraft info', ignore_extra=False, description='Remove all info about the Mincraft server for the guild.') async def _clear(self, ctx: commands.Context) -> None: await self.clear(ctx) @_mc.group( name='set', pass_context=True, brief='update Minecraft info', description='Update the Minecraft server info. Note that at minimum, ' 'the name and address must be specified.') async def _set(self, ctx: commands.Context) -> None: if ctx.invoked_subcommand is None: await self.bot.message_guild( 'use one of the subcommands (address/admin/name/whitelist) ' 'to set that value', ctx.channel) @_set.command( name='address', pass_context=True, brief='set Minecraft server IP address', description='Set the IP address used to connect to the server') async def _address(self, ctx: commands.Context, *, address: str) -> None: await self.address(ctx, address) @_set.command( name='admin', pass_context=True, brief='set Minecraft server admin', ignore_extra=False, description='Set the admin of the Minecraft server. Note this should ' 'be a mention and is only used if whitelist is also set ' 'to true.') async def _admin(self, ctx: commands.Context, member: discord.Member) -> None: await self.admin(ctx, member) @_set.command( name='name', pass_context=True, brief='set Minecraft server name', description='Set name of Minecraft server. Note quotations around the ' 'name are not necessary') async def _name(self, ctx: commands.Context, *, name: str) -> None: await self.name(ctx, name) @_set.command( name='whitelist', pass_context=True, brief='set Minecraft server whitelist', ignore_extra=False, description='Set the flag for if the server has a whitelist. I.e. ' '<whitelist>=true|false. If true, a server admin must ' 'also be set.') async def _whitelist(self, ctx: commands.Context, has_whitelist: bool) -> None: await self.whitelist(ctx, has_whitelist)
class Commands(commands.Cog): """Extension for custom commands. Adds the following commands: - `?command add [name] [command text]` - `?command remove [name]` """ def __init__( self, bot: Bot, commands_file: str, guild_admin_permission: bool = True, bot_admin_permission: bool = True, everyone_permission: bool = False, ) -> None: """Init Commands Args: bot (Bot): bot that loaded this cog commands_file (str): path to store database guild_admin_permission (bool): can guild admins start polls bot_admin_permission (bool): can bot admin start polls everyone_permission (bool): allow everyone to start polls """ self.bot = bot self.guild_admin_permission = guild_admin_permission self.bot_admin_permission = bot_admin_permission self.everyone_permission = everyone_permission self.db = GuildDatabase(commands_file) # Register all commands on startup tables = self.db.tables() for table in tables.values(): for name, text in table.items(): self.bot.add_command(self.make_command(name, text)) def has_permission(self, member: discord.Member) -> bool: """Does a member have permission to edit commands""" if self.everyone_permission: return True if is_admin(member) or self.bot.is_bot_admin(member): return True return False def is_custom_command(self, cmd: commands.Command) -> bool: """Check if command was created by this cog""" if cmd.cog_name == 'custom_commands': return True if 'cog_name' in cmd.__original_kwargs__: return cmd.__original_kwargs__['cog_name'] == 'custom_commands' return False def make_command(self, name: str, text: str) -> commands.Command: """Create Discord Command Args: name (str): name of command text (str): message to return when command is executed Returns: Command """ async def _command(_ctx: commands.Context): _text = self._get_command(_ctx.guild, name) if _text is None: await self.bot.message_guild( 'this command is not available in this guild', _ctx.channel) else: await self.bot.message_guild(_text, _ctx.channel) return commands.Command(_command, name=name, cog_name='custom_commands') async def add(self, ctx: commands.Context, name: str, text: str) -> None: """Add a new command to the guild Sends a message to `ctx.channel` on success or failure. Args: ctx (Context): context from command call name (str): name of command text (str): text of command """ if not self.has_permission(ctx.message.author): raise commands.MissingPermissions # Check if this is a custom command that can be overwritten if # it exists cmd = self.bot.get_command(name) if cmd is not None: if self.is_custom_command(cmd): self.bot.remove_command(name) else: await self.bot.message_guild( 'this command already exist and cannot be overwritten', ctx.channel, ) return # Save to database self._set_command(ctx.guild, name, text) # Register with bot self.bot.add_command(self.make_command(name, text)) await self.bot.message_guild( 'added command {}{}'.format(self.bot.command_prefix, name), ctx.channel, ) async def remove(self, ctx: commands.Context, name: str) -> None: """Remove a command from the guild Sends a message to `ctx.channel` on success or failure. Args: ctx (Context): context from command call name (str): name of command to remove """ if not self.has_permission(ctx.message.author): raise commands.MissingPermissions cmd = self.bot.get_command(name) if cmd is not None: # Only remove if it is a custom command if self.is_custom_command(cmd): # Remove from bot self.bot.remove_command(name) # Remove from database self._remove_command(ctx.guild, name) msg = 'removed command {}{}'.format(self.bot.command_prefix, name) await self.bot.message_guild(msg, ctx.channel) else: await self.bot.message_guild('this command cannot be removed', ctx.channel) return else: await self.bot.message_guild( 'the {} command does not exists'.format(name), ctx.channel) def _get_command(self, guild: discord.Guild, name: str) -> Optional[str]: """Get command text from database""" return self.db.value(guild, name) def _remove_command(self, guild: discord.Guild, name: str) -> None: """Remove command text from database""" return self.db.clear(guild, name) def _set_command(self, guild: discord.Guild, name: str, text: str) -> None: """Set command in dataset""" self.db.set(guild, name, text) @commands.group( name='commands', pass_context=True, brief='add/remove custom commands', description='Add and remove custom commands for this guild.', ) async def _commands(self, ctx: commands.Context) -> None: if ctx.invoked_subcommand is None: await self.bot.message_guild( 'use the `add` or `remove` subcommands. See `{}help {}` for ' 'more info'.format(self.bot.command_prefix, ctx.invoked_with), ctx.channel, ) @_commands.command( name='add', pass_context=True, brief='add a custom command', description='Add a custom command that can be invoked with <name>. ' 'The command will print all <text> after <name>. ' 'Note that quotes are not needed around the command body ' 'text.', ) async def _add(self, ctx: commands.Context, name: str, *, text: str) -> None: await self.add(ctx, name, text) @_commands.command( name='remove', pass_context=True, brief='remove a custom command', ignore_extra=False, description='Remove the custom command with <name>', ) async def _remove(self, ctx: commands.Context, name: str) -> None: await self.remove(ctx, name)
class Games(commands.Cog): """Extension for picking games to play. Adds the following commands: - `?games`: get options for managing games - `?games list`: list games for the guild - `?games roll`: pick a random game to play - `?games add [title]`: add a game to list for this guild - `?games remove [title]`: remove a game from this guild """ def __init__(self, bot: Bot, games_file: str) -> None: """Init Games Args: bot (Bot): bot that loaded this cog games_file (str): path to store database """ self.bot = bot self.db = GuildDatabase(games_file) async def is_empty(self, ctx: commands.Context) -> bool: """Check if games list Sends message to the channel if the guild has no games added. This function acts as a helper for the commands added by this cog. Args: ctx (Context): context from command call Returns: `True` if the guild has no games else `False` """ if len(self._get_games(ctx.guild)) == 0: await self.bot.message_guild( 'There are no games to play. Add more with ' '{}games add [title]'.format(self.bot.command_prefix), ctx.channel, ) return True return False async def list(self, ctx: commands.Context) -> None: """Message `ctx.channel` with list of games for guild Args: ctx (Context): context from command call """ if await self.is_empty(ctx): return msg = 'games to play:\n```\n' games = sorted(self._get_games(ctx.guild)) for game in games: msg += '{}\n'.format(game) msg += '```' await self.bot.message_guild(msg, ctx.channel) async def add(self, ctx: commands.Context, name: str) -> None: """Add a new game to the guild Sends a message to `ctx.channel` on success or failure. Args: ctx (Context): context from command call name (str): title of game to add """ if not (is_admin(ctx.message.author) or self.bot.is_bot_admin(ctx.message.author)): raise commands.MissingPermissions games = self._get_games(ctx.guild) if name not in games: games.append(name) self._set_games(ctx.guild, games) await self.bot.message_guild('added {}'.format(name), ctx.channel) else: await self.bot.message_guild('{} already in list'.format(name), ctx.channel) async def remove(self, ctx: commands.Context, name: str) -> None: """Remove a game from the guild Sends a message to `ctx.channel` on success or failure. Args: ctx (Context): context from command call name (str): title of game to remove """ if not (is_admin(ctx.message.author) or self.bot.is_bot_admin(ctx.message.author)): raise commands.MissingPermissions games = self._get_games(ctx.guild) if name in games: games.remove(name) self._set_games(ctx.guild, games) await self.bot.message_guild('removed {}'.format(name), ctx.channel) else: await self.bot.message_guild('{} not in list'.format(name), ctx.channel) async def roll(self, ctx: commands.Context) -> None: """Message `ctx.channel` with random game to play Args: ctx (Context): context from command call """ if await self.is_empty(ctx): return games = self._get_games(ctx.guild) await self.bot.message_guild( 'you should play {}'.format(random.choice(games)), ctx.channel) def _get_games(self, guild: discord.Guild) -> Optional[list]: """Get list of games for guild from database""" games = self.db.value(guild, 'games') if games is None: return [] return games def _set_games(self, guild: discord.Guild, games: List[str]) -> None: """Set list of games for guild in database""" self.db.set(guild, 'games', games) @commands.group( name='games', pass_context=True, brief='manage list of games for the guild', ) async def _games(self, ctx: commands.Context) -> None: if ctx.invoked_subcommand is None: await self.bot.message_guild( 'use the `add/list/remove/roll` subcommands. ' 'See `{}help {}` for more info'.format(self.bot.command_prefix, ctx.invoked_with), ctx.channel, ) @_games.command( name='list', pass_context=True, brief='list available games', ignore_extra=False, description='List games added to the guild', ) async def _list(self, ctx: commands.Context) -> None: await self.list(ctx) @_games.command( name='add', pass_context=True, brief='add game', description='Add a new game to the guild\'s list', ) async def _add(self, ctx: commands.Context, *, name: str) -> None: await self.add(ctx, name) @_games.command( name='remove', pass_context=True, brief='remove game', description='Remove a game for the guild\'s list', ) async def _remove(self, ctx: commands.Context, *, name: str) -> None: await self.remove(ctx, name) @_games.command( name='roll', pass_context=True, brief='pick a random game', ignore_extra=False, description='Get a random game to play from the guild\'s list', ) async def _roll(self, ctx: commands.Context) -> None: await self.roll(ctx)
class Rules(commands.Cog): """Extension for enforcing guild rules This cog enforces a long running meme on our Discord server that all messages have to start with '3pseat'. This cog enforces a more general version of that concept and tracks the number of infractions a user has. If they get too many, they will be kicked. Adds the following commands: - `?strikes`: aliases `?strikes list` - `?strikes list`: list strike count for members - `?strikes add @member`: add strike to member - `?strikes remove @member`: remove strike from member """ def __init__(self, bot: Bot, database_path: str, message_prefix: Optional[Union[str, List[str]]] = None, whitelist_prefix: Union[str, List[str]] = [], max_offenses: int = 3, allow_deletes: bool = True, allow_edits: bool = True, allow_wrong_commands: bool = True, booster_exception: bool = True, invite_after_kick: bool = True) -> None: """Init Rules Args: bot (Bot): bot object this cog is attached to database_path (str): path to file storing database of strikes message_prefix (str, list[str]): valid prefixes for messages. If None, no message prefix checking is done. whitelist_prefix (str, list[str]): messages starting with these prefixes will be ignored. Useful for ignoring the command prefixes of other bots. max_offenses (int): maximum strikes before being kicked allow_deletes (bool): if false, the bot will notify the channel if a message is deleted allow_edits (bool): if false, the bot will notify the channel if a message is edited allow_wrong_commands (bool): if false, members will be given a strike for trying to use invalid commands booster_exception (bool): boosters are exempt from rules invite_after_kick (bool): send invite link if member is kicked """ self.bot = bot if isinstance(message_prefix, str): message_prefix = [message_prefix] self.message_prefix = message_prefix if isinstance(whitelist_prefix, str): message_prefix = [whitelist_prefix] self.whitelist_prefix = whitelist_prefix self.max_offenses = max_offenses self.allow_deletes = allow_deletes self.allow_edits = allow_edits self.allow_wrong_commands = allow_wrong_commands self.booster_exception = booster_exception self.invite_after_kick = invite_after_kick if self.message_prefix is not None: self.bot.guild_message_prefix = self.message_prefix[0] self.db = GuildDatabase(database_path) async def list(self, ctx: commands.Context) -> None: """List strikes for members in `ctx.guild` Args: ctx (Context): context from command call """ msg = '{}, here are the strikes:'.format(ctx.message.author.mention) serverCount = 0 strikes = self.db.table(ctx.guild) if len(strikes) > 0: msg += '```' for uid in strikes: if int(uid) != ctx.guild.id: msg = msg + '\n{}: {}/{}'.format( self.bot.get_user(int(uid)).name, strikes[uid], self.max_offenses) else: serverCount = strikes[uid] msg += '```' else: msg += ' there are none!\n' msg += 'Total offenses to date: {}'.format(serverCount) await self.bot.message_guild(msg, ctx.channel) async def add(self, ctx: commands.Context, member: discord.Member) -> None: """Adds a strike to `member` Requires `ctx.message.author` to be a bot admin (not guild admin). Args: ctx (Context): context from command call member (Member): member to add strike to """ if self.bot.is_bot_admin(ctx.message.author): await self._add_strike(member, ctx.channel, ctx.guild) else: await self.bot.message_guild( 'you lack permission, {}'.format(ctx.message.author.mention), ctx.channel) async def remove(self, ctx: commands.Context, member: discord.Member) -> None: """Removes a strike from `member` Requires `ctx.message.author` to be a bot admin (not guild admin). Args: ctx (Context): context from command call member (Member): member to remove strike from """ if self.bot.is_bot_admin(ctx.message.author): self.remove_strike(member) msg = 'removed strike for {}. New strike count is {}.'.format( member.mention, self.get_strikes(member)) await self.bot.message_guild(msg, ctx.message.channel) else: await self.bot.message_guild( 'you lack permission, {}'.format(ctx.message.author.mention), ctx.channel) def should_ignore(self, message: discord.Message) -> bool: """Returns true if the message should be ignored Many types of messages are exempt from the message prefix rules including: pin messages, member join messages, guild boosters if `booster_exception`, messages that start with `whitelist_prefix`, and bot commands. Args: message (Message): message to parse Returns: `bool` """ if message.type is discord.MessageType.pins_add: return True if message.type is discord.MessageType.new_member: return True if self.booster_exception and is_booster(message.author): return True text = message.content.strip().lower() for keyword in self.whitelist_prefix: if text.startswith(keyword.lower()): return True if text.startswith(self.bot.command_prefix): return True return False def is_verified(self, message: discord.Message) -> bool: """Verifies a message passes the rules A message is verified if it: start with `message_prefix`, is just emojis, is a single attachment with no text, is a single url with no text, is quoted, or is code. Args: message (Message): message to parse Returns: `bool` """ text = message.content.strip().lower() # Check if starts with 3pseat, 3pfeet, etc if self.message_prefix is not None: for keyword in self.message_prefix: if text.startswith(keyword): return True # Check if just emoji if is_emoji(text): return True # Check if just images if text == '' and message.attachments: return True # Check if single link if is_url(text): return True # Check if quoted message if text.startswith('>'): return True # Check if code if text.startswith('```'): return True return False async def check_strikes(self, member: discord.Member, channel: discord.TextChannel, guild: discord.Guild) -> None: """Handles what to do when user recieves a strike If the user has fewer that `self.max_offenses` strikes, a warning is sent. Otherwise, the bot attempts to kick the user and send them a invite link if `self.invite_after_kick`. Warning: In general, this function should only be called by `add_strike()`. Args: member (Member): member to check strikes of channel (Channel): channel to send message in guild (Guild): guild to kick user from if needed """ count = self.get_strikes(member) if count < self.max_offenses: msg = '{}! You\'ve disturbed the spirits ({}/{})'.format( member.mention, count, self.max_offenses) await self.bot.message_guild(msg, channel) else: self.clear_strikes(member) msg = ('That\'s {} strikes, {}. I\'m sorry but your time as come. ' 'RIP.\n'.format(self.max_offenses, member.mention)) success = await self.kick(member, channel, guild, msg) if self.invite_after_kick and success: msg = ('Sorry we had to kick you. Here is a link to rejoin: ' '{link}') await self.invite(member, channel, msg) async def invite(self, user: Union[discord.User, discord.Member], channel: discord.TextChannel, message: str) -> None: """Send guild invite link to user Args: user (Member, User): user to direct message link channel (Channel): channel to create invite for message (str): optional message to include with invite link """ try: link = await channel.create_invite(max_uses=1) msg = message.format(link=link) await self.bot.message_user(msg, user) except Exception as e: logger.warning('Failed to send rejoin message to {}.' 'Caught exception: {}'.format(user, e)) async def kick(self, member: discord.Member, channel: discord.TextChannel, guild: discord.Guild, message: str = None) -> bool: """Kick member from guild Args: member (Member): member to kick channel (Channel): channel to send notification messages to guild (Guild): guild member belongs to to be kicked from message (str): optional message to sent to channel when the member is kicked Returns: `True` if kick was successful """ if is_admin(member): if message is None: message = '' message += ('Failed to kick {}. Your cognizance is highly ' 'acknowledged.'.format(member.mention)) await self.bot.message_guild(message, channel) return False try: await guild.kick(member) except Exception as e: logger.warning('Failed to kick {}. Caught exception: ' '{}'.format(member, e)) return False if message is not None: message += 'Press F to pay respects.' await self.bot.message_guild(message, channel, react=F_EMOTE) return True async def add_strike(self, member: discord.Member, channel: discord.TextChannel) -> None: """Add a strike to the `member` of `guild` in the database Calls `check_strikes()` Args: member (Member): member channel (Channel): channel to send message in """ count = self.get_strikes(member) self.db.set(member.guild, str(member.id), count + 1) server_count = self.get_global_strikes(member.guild) self.db.set(member.guild, str(member.guild.id), server_count + 1) await self.check_strikes(member, channel, member.guild) def clear_strikes(self, member: discord.Member) -> None: """Reset strikes for the `member` in the database""" self.db.set(member.guild, str(member.id), 0) def get_strikes(self, member: discord.Member) -> int: """Get strike count for `member`""" value = self.db.value(member.guild, str(member.id)) if value is None: return 0 return value def get_global_strikes(self, guild: discord.Guild) -> int: """Get global strike count for guild""" value = self.db.value(guild, str(guild.id)) if value is None: return 0 return value def remove_strike(self, member: discord.Member) -> None: """Remove a strike from the `member` in the database""" count = self.get_strikes(member) if count > 0: self.db.set(member.guild, str(member.id), count - 1) server_count = self.get_global_strikes(member.guild) if server_count > 0: self.db.set(member.guild, str(member.guild.id), server_count + 1) @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: """Called when message is created and sent""" if message.author.bot: return if message.guild is None: await self.bot.message_user( 'Hi there, I cannot reply to direct messages.', message.author) return if self.should_ignore(message): return if not self.is_verified(message): await self.add_strike(message.author, message.channel) @commands.Cog.listener() async def on_message_delete(self, message: discord.Message) -> None: """Called when message is deleted""" if self.allow_deletes or message.author.bot or self.should_ignore( message): return msg = '{}, where did your message go? It was: \"{}\"'.format( message.author.mention, message.clean_content) await self.bot.message_guild(msg, message.channel) @commands.Cog.listener() async def on_message_edit(self, before: discord.Message, after: discord.Message) -> None: """Called when message is edited""" if self.allow_edits or before.author.bot or self.should_ignore(after): return # ignore message with embeds because it counts as editing a message if after.embeds: return # ignore message being edited because it was pinned if not before.pinned and after.pinned: return msg = '{}, what did you do to your message? It was: \"{}\"'.format( before.author.mention, before.clean_content) await self.bot.message_guild(msg, before.channel) # confirm new message still passes rules if not self.is_verified(after): await self.add_strike(after.author, after.channel) @commands.Cog.listener() async def on_command_error(self, ctx: commands.Context, error: commands.CommandError) -> None: """Called when a command is invalid""" if (isinstance(error, commands.CommandNotFound) and not self.allow_wrong_commands): for prefix in self.whitelist_prefix: if ctx.message.content.startswith(prefix): return await self.add_strike(ctx.message.author, ctx.channel) @commands.group(name='strikes', pass_context=True, brief='?help strikes for more info') async def _strikes(self, ctx: commands.Context) -> None: if ctx.invoked_subcommand is None: await self.list(ctx) @_strikes.command(name='add', pass_context=True, brief='add strike to user', ignore_extra=False) async def _add(self, ctx: commands.Context, member: discord.Member) -> None: await self.add(ctx, member) @_strikes.command(name='list', pass_context=True, brief='add strike to user', ignore_extra=False) async def _list(self, ctx: commands.Context) -> None: await self.list(ctx) @_strikes.command(name='remove', pass_context=True, brief='remove strike from user', ignore_extra=False) async def _remove(self, ctx: commands.Context, member: discord.Member) -> None: await self.remove(ctx, member)
class Voice(commands.Cog): """Extension for playing sound clips in voice channels Based on https://github.com/Rapptz/discord.py/blob/master/examples/basic_voice.py Adds the following commands: - `?join`: have bot join voice channel of user - `?leave`: have bot leave voice channel of user - `?sounds`: aliases `?sounds list` - `?sounds play [name]`: play sound with name - `?sounds roll`: play a random sound - `?sounds list`: list available sounds - `?sounds add [name] [youtube_url]`: download youtube audio and saves a sound with name - `?sounds update [name] [key] [value]`: update data about a sound - `?sounds remove [name]`: remove a sound """ def __init__( self, bot: Bot, sounds_file: str, sounds_dir: str, ) -> None: """Init Voice Args: bot (Bot): bot that loaded this cog sounds_file (str): path to store sounds database sounds_dir (str): directory to store audio files in """ self.bot = bot # Note: sounds stored as ./<sounds_dir>/<guild_id>/<sound_name>.mp3 self.sounds_dir = sounds_dir self.db = GuildDatabase(sounds_file) self.rebuild_database() self._leave_on_empty.start() def rebuild_database(self): """Scan and clean up database Checks for sounds that are missing and downloads if possible, deletes otherwise. Enforces schema. Each entry keyed by `sound` has the following attributes: `url`, `image_url`, `path`, `tag`. """ logger.info('[VOICE] Scanning sounds database...') for guild_id, table in self.db.tables().items(): guild = self.bot.get_guild(guild_id) if guild is None: logger.info(f'[VOICE] Failed to find guild {guild_id}') continue for sound in list(table.keys()): data = table[sound] # Check additional metadata exists if 'image_url' not in data: data['image_url'] = None if 'url' not in data: data['url'] = None if 'tag' not in data: data['tag'] = None # If file does not exist, try to download, otherwise delete if not os.path.isfile(data['path']): if 'url' in data and data['url'] is not None: self.add( guild, sound, data['url'], image_url=data['image_url'], tag=data['tag'], ) logger.info( f'[VOICE] Restored missing {sound} in {guild.name}' ) else: self.db.clear(guild, sound) logger.info( f'[VOICE] Removed invalid {sound} from {guild.name}' ) self.db.set(guild, sound, data) logger.info('[VOICE] Finished scanning sounds database') async def join_channel(self, channel: discord.VoiceChannel) -> None: """Join voice channel Args: channel (VoiceChannel): voice channel to leave Raises: JoinVoiceChannelException """ try: if channel.guild.voice_client is not None: await channel.guild.voice_client.move_to(channel) return await channel.connect() except Exception: logger.exception( f'Caught exception when trying to join voice channel ' f'{channel}. Raising JoinVoiceChannelException instead.') raise JoinVoiceChannelException async def leave_channel(self, guild: discord.Guild) -> None: """Leave voice channel for guild Args: guild (Guild): guild to leave voice channel in """ if guild.voice_client is not None: await guild.voice_client.disconnect() async def play(self, channel: discord.VoiceChannel, sound: str) -> None: """Play the sound for `ctx.message.author` Args: channel (VoiceChannel): channel to play sound in sound (str): name of sound to play Raises: JoinVoiceChannelException: if unable to join channel SoundNotFoundException: if unable to find sound """ await self.join_channel(channel) voice_client = channel.guild.voice_client if voice_client.is_playing(): voice_client.stop() sound_data = self.db.value(channel.guild, sound) if sound_data is None: raise SoundNotFoundException(f'Unable to find sound {sound}') if 'path' not in sound_data or not os.path.isfile(sound_data['path']): # If entry is missing valid path, remove from database self.db.clear(channel.guild, sound) raise SoundNotFoundException(f'Unable to find sound {sound}') source = discord.FFmpegPCMAudio(sound_data['path']) voice_client.play(source, after=None) voice_client.source = discord.PCMVolumeTransformer( voice_client.source, 1) def sounds(self, guild: discord.Guild) -> dict: """Returns all sounds and metadata Args: guild (Guild) """ return self.db.table(guild) def add( self, guild: discord.Guild, name: str, url: str, *, image_url: Optional[str] = None, tag: Optional[str] = None, ) -> None: """Add a new sound from YouTube Args: guild (Guild): guild to add sound to name (str): name for the sound url (str): youtube url to download image_url (str): optional url of image for soundboard tag (str): optional tag for sound Raises: SoundExistsException if sound with `name` already exists SoundExceedsLimitException if sound is too long SoundDownloadException if there is an error downloading the sound """ sounds = self.sounds(guild) if name in sounds and os.path.isfile(sounds[name]['path']): raise SoundExistsException path = os.path.join(self.sounds_dir, str(guild.id)) os.makedirs(path, exist_ok=True) path = os.path.join(path, f'{name}.mp3') ydl_opts = { 'outtmpl': path, 'format': 'worst', 'postprocessors': [{ 'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3', 'preferredquality': '128', }], 'logger': logger, 'socket_timeout': 30, } try: with youtube_dl.YoutubeDL(ydl_opts) as ydl: metadata = ydl.extract_info(url, download=False, process=False) if int(metadata['duration']) > MAX_SOUND_LENGTH_SECONDS: raise SoundExceedsLimitException ydl.download([url]) except Exception: logger.exception('Caught error downloading sound') raise SoundDownloadException self.db.set( guild, name, { 'path': path, 'url': url, 'image_url': image_url, 'tag': tag }, ) def update( self, guild: discord.Guild, name: str, *, url: Optional[str] = None, image_url: Optional[str] = None, tag: Optional[str] = None, ) -> None: """Update and existing sound Args: guild (Guild): guild sound is in name (str): name of the sound url (str): update with new youtube url image_url (str): update with new image url tag (str): update with new tag Raises: SoundNotFoundException: if the sound cannot be found """ sound_data = self.db.value(guild, name) if sound_data is None: raise SoundNotFoundException(f'Unable to find sound {name}') if image_url is not None: sound_data['image_url'] = image_url if tag is not None: sound_data['tag'] = tag # URL has changed so download new file if url != sound_data['url']: self.db.clear(guild, name) try: self.add( guild, name, sound_data['url'], image_url=sound_data['image_url'], tag=sound_data['tag'], ) except Exception as e: # Restore original state if there is an error self.db.set(guild, name, sound_data) raise e def remove(self, guild: discord.Guild, member: discord.Member, name: str) -> None: """Remove a sound Requires bot admin permissions. Args: guild (Guild): guild sound is in member (Member): member issuing command name (str): name of the sound Raises: SoundNotFoundException: if the sound cannot be found MissingPermissions: if calling user lacks permissions """ if not (is_admin(member) or self.bot.is_bot_admin(member)): raise commands.MissingPermissions if name not in self.sounds(guild): raise SoundNotFoundException(f'Unable to find sound {name}') self.db.clear(guild, name) @tasks.loop(seconds=30.0) async def _leave_on_empty(self) -> None: """Task that periodically checks if connected channels are empty and leaves""" for client in self.bot.voice_clients: if len(client.channel.members) <= 1: await client.disconnect() @commands.command( name='join', pass_context=True, brief='join voice channel', ignore_extra=False, ) async def _join(self, ctx: commands.Context) -> bool: if ctx.author.voice is None or ctx.author.voice.channel is None: await self.bot.message_guild( '{}, you must be in a voice channel'.format( ctx.author.mention), ctx.channel, ) else: await self.join_channel(ctx.author.voice.channel) @commands.command( name='leave', pass_context=True, brief='leave voice channel', ignore_extra=False, ) async def _leave(self, ctx: commands.Context) -> None: await self.leave_channel(ctx.guild) @commands.group(name='sounds', pass_context=True, brief='?help sounds for more info') async def _sounds(self, ctx: commands.Context) -> None: if ctx.invoked_subcommand is None: await self._list(ctx) @_sounds.command( name='add', pass_context=True, brief='add a sound: <name> <url> [image_url] [tag]', ignore_extra=False, ) async def _add( self, ctx: commands.Context, name: str, url: str, image_url: Optional[str] = None, tag: Optional[str] = None, ) -> None: try: self.add(ctx.guild, name, url, image_url=image_url, tag=tag) except SoundExistsException: await self.bot.message_guild( f'sound with name `{name}` already exists', ctx.channel) except SoundExceedsLimitException: await self.bot.message_guild( f'{ctx.author.mention}, the clip is too long ' f'(max={MAX_SOUND_LENGTH_SECONDS}s)', ctx.channel, ) except SoundDownloadException: await self.bot.message_guild( f'error downloading video for `{name}`', ctx.channel) else: await self.bot.message_guild(f'added sound `{name}`', ctx.channel) @_sounds.command( name='update', pass_context=True, brief='update a sound: <name> <key> <value>', ignore_extra=False, ) async def _update(self, ctx: commands.Context, name: str, key: str, value: str) -> None: sounds = self.sounds(ctx.guild) if name not in sounds: await self.bot.message_guild( f'sound with name `{name}` does not exists', ctx.channel) return if key not in sounds[name]: await self.bot.message_guild( f'`{key}` is not a valid option. Options are: ' f'`{list(sounds[name].keys())}`', ctx.channel, ) return kwargs = {key: value} try: self.update(ctx.guild, name, **kwargs) except SoundNotFoundException: await self.bot.message_guild( f'sound with name `{name}` does not exists', ctx.channel) except SoundExceedsLimitException: await self.bot.message_guild( f'{ctx.author.mention}, the new clip is too long ' f'(max={MAX_SOUND_LENGTH_SECONDS}s)', ctx.channel, ) except SoundDownloadException: await self.bot.message_guild( f'error downloading video for `{name}`', ctx.channel) else: await self.bot.message_guild(f'update sound `{name}`', ctx.channel) @_sounds.command( name='get', pass_context=True, brief='get sound data: <name>', ignore_extra=False, ) async def _get(self, ctx: commands.Context, name: str) -> None: sounds = self.sounds(ctx.guild) if name in sounds: await self.bot.message_guild(f'`{name}`: `{sounds[name]}`', ctx.channel) else: await self.bot.message_guild( f'sound with name `{name}` does not exists', ctx.channel) @_sounds.command( name='remove', pass_context=True, brief='remove a sound: <name>', ignore_extra=False, ) async def _remove(self, ctx: commands.Context, name: str) -> None: try: self.remove(ctx.guild, ctx.author, name) except commands.MissingPermissions: await self.bot.message_guild( f'{ctx.author.mention}, you lack permissions', ctx.channel) except SoundNotFoundException: await self.bot.message_guild( f'sound with name `{name}` does not exists', ctx.channel) else: await self.bot.message_guild(f'removed sound `{name}`', ctx.channel) async def _list(self, ctx: commands.Context) -> None: sounds = self.sounds(ctx.guild) sounds = ' '.join(sorted(sounds.keys())) await self.bot.message_guild(f'the available sounds are: `{sounds}`', ctx.channel) @_sounds.command( name='play', pass_context=True, brief='play a sound: [name]', ignore_extra=False, ) async def _play(self, ctx: commands.Context, sound: str) -> None: if ctx.author.voice is None or ctx.author.voice.channel is None: await self.bot.message_guild( '{}, you must be in a voice channel'.format( ctx.author.mention), ctx.channel, ) try: await self.play(ctx.author.voice.channel, sound) except JoinVoiceChannelException: await self.bot.message_guild('failed to join the voice channel', ctx.channel) except SoundNotFoundException: await self.bot.message_guild(f'unable to find sound `{sound}`', ctx.channel) except Exception: logger.exception( f'Failed to play sound {sound} in guild {ctx.guild.id}') @_sounds.command( name='roll', pass_context=True, brief='play a random sound', ignore_extra=False, ) async def _roll(self, ctx: commands.Context) -> None: sounds = list(self.sounds(ctx.guild).keys()) if len(sounds) < 1: await self.bot.message_guild('there are no sounds available', ctx.channel) else: await self._play(ctx, random.choice(sounds))