Esempio n. 1
0
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)
Esempio n. 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))