Beispiel #1
0
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)
Beispiel #2
0
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))