Пример #1
0
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)
Пример #2
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)
Пример #3
0
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)
Пример #4
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)
Пример #5
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))