Exemple #1
0
    def __init__(self, bot):
        """Initializes the Cog.

        Args:
            bot (Bot): The bot for which this cog should be enabled.
        """
        self.bot = bot
        self._db_connector = DatabaseConnector(constants.DB_FILE_PATH,
                                               constants.DB_INIT_SCRIPT)
Exemple #2
0
    def __init__(self, bot):
        """Initializes the Cog.

        Args:
            bot (discord.ext.commands.Bot): The bot for which this cog should be enabled.
        """
        self.bot = bot
        self._db_connector = DatabaseConnector(constants.DB_FILE_PATH, constants.DB_INIT_SCRIPT)

        # Channel instances
        self.ch_role = bot.get_guild(int(constants.SERVER_ID)).get_channel(int(constants.CHANNEL_ID_ROLES))
Exemple #3
0
    def __init__(self, bot):
        """Initializes the Cog.

        Args:
            bot (discord.ext.commands.Bot): The bot for which this cog should be enabled.
        """
        self._db_connector = DatabaseConnector(const.DB_FILE_PATH, const.DB_INIT_SCRIPT)

        # Channel Category instances
        self.cat_gaming_rooms = bot.get_guild(int(const.SERVER_ID)).get_channel(int(const.CATEGORY_ID_GAMING_ROOMS))
        self.cat_study_rooms = bot.get_guild(int(const.SERVER_ID)).get_channel(int(const.CATEGORY_ID_STUDY_ROOMS))
Exemple #4
0
def test_db():
    """Tests if inserts and reads from the database work.

    Initializes the database, adds a single value, queries the same value by key and finally deletes the db file.
    Passes if the returned value is found (ie. not None) and equal to the original one.
    """
    conn = DatabaseConnector("./test.sqlite",
                             init_script=constants.DB_INIT_SCRIPT)
    conn.add_modmail(47348382920304934)
    res = conn.get_modmail_status(47348382920304934)

    os.remove("./test.sqlite")

    assert res == ModmailStatus.OPEN
Exemple #5
0
    def __init__(self, bot):
        """Initializes the Cog.

        Args:
            bot (discord.ext.commands.Bot): The bot for which this cog should be enabled.
        """
        self._db_connector = DatabaseConnector(constants.DB_FILE_PATH, constants.DB_INIT_SCRIPT)

        # Static variable which is needed for running jobs created by the scheduler. A lot of data structures provided
        # by discord.py can't be pickled (serialized) which is why IDs are being used instead. For converting them into
        # usable objects, a bot/client object is needed, which should be the same for the whole application anyway.
        UniversityCog.bot = bot

        # Channel instances
        self.ch_group_exchange = bot.get_guild(int(constants.SERVER_ID))\
            .get_channel(int(constants.CHANNEL_ID_GROUP_EXCHANGE))

        # Adds jobs needed for reopening/closing the group exchange channel if they don't already exist.
        _initialize_scheduler_jobs()
Exemple #6
0
class UniversityCog(commands.Cog):
    """Cog for Functions regarding the IT faculty or the University of Vienna as a whole."""
    def __init__(self, bot):
        """Initializes the Cog.

        Args:
            bot (discord.ext.commands.Bot): The bot for which this cog should be enabled.
        """
        self._db_connector = DatabaseConnector(constants.DB_FILE_PATH,
                                               constants.DB_INIT_SCRIPT)

        # Static variable which is needed for running jobs created by the scheduler. A lot of data structures provided
        # by discord.py can't be pickled (serialized) which is why IDs are being used instead. For converting them into
        # usable objects, a bot/client object is needed, which should be the same for the whole application anyway.
        UniversityCog.bot = bot

        # Channel instances
        self.ch_group_exchange = bot.get_guild(int(constants.SERVER_ID))\
            .get_channel(int(constants.CHANNEL_ID_GROUP_EXCHANGE))

        # Adds jobs needed for reopening/closing the group exchange channel if they don't already exist.
        _initialize_group_exchange_jobs()

    @commands.group(name="ufind", invoke_without_command=True)
    @command_log
    async def ufind(self, ctx: commands.Context):
        """Command Handler for the `ufind` command.

        Allows users to search the service `ufind` for various information regarding staff members, courses and exams
        and posts them on Discord. For each of these categories exists a corresponding subcommand. If no subcommand has
        been invoked, a help message for this command will be posted instead.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
        """
        await ctx.send_help(ctx.command)

    @ufind.command(name='staff')
    @command_log
    async def ufind_get_staff_data(self, ctx: commands.Context, *,
                                   search_term: str):
        """Command Handler for the `ufind` subcommand `staff`.

        Allows users to search the service `ufind` for information regarding staff members and posts them on Discord.
        If the search result contains more than one person, an embed with reactions will be posted. The user can then
        choose a person by reacting to said embed. If the result contains no person at all, the bot will inform the
        user by posting a sad message.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            search_term (str): The term to be searched for (most likely firstname, lastname or both).
        """
        search_filters = "%20%2Be%20c%3A6"  # URL Encoding
        query_url = constants.URL_UFIND_API + "/staff/?query=" + search_term + search_filters

        async with singletons.http_session.get(query_url) as response:
            response.raise_for_status()
            await response.text(encoding='utf-8')

            xml = ET.fromstring(await response.text())

        persons = xml.findall("person")
        if not persons:
            raise ValueError("No person with the specified name was found.")

        index = await self._staff_selection(ctx.author, ctx.channel,
                                            persons) if len(persons) > 1 else 0
        staff_url = constants.URL_UFIND_API + "/staff/" + persons[
            index].attrib["id"]

        async with singletons.http_session.get(staff_url) as response:
            response.raise_for_status()
            await response.text(encoding='utf-8')

            staff_data = _parse_staff_xml(await response.text())

        embed = _create_embed_staff(staff_data)
        await ctx.channel.send(embed=embed)

    @ufind_get_staff_data.error
    async def ufind_error(self, ctx, error: commands.CommandError):
        """Error Handler for the `ufind` subcommand `staff`.

        Handles specific exceptions which occur during the execution of this command. The global error handler will
        still be called for every error thrown.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            error (commands.CommandError): The error raised during the execution of the command.
        """
        if isinstance(error, commands.CommandInvokeError) and isinstance(
                error.original, ValueError):
            await ctx.send(
                "Ich konnte leider niemanden unter dem von dir angegeben Namen finden. :slight_frown:\n"
                "Hast du dich möglicherweise vertippt?")

    async def _staff_selection(self, author: discord.User, channel: discord.TextChannel, persons: List[ET.Element]) \
            -> int:
        """Method for handling multiple results when searching for a staff member on `ufind`.

        If multiple results have been returned by `ufind`, this method will post an embed containing the names of these.
        The user can then choose one person by reacting to the embed. If the user takes too long and the timeout is
        reached, the embed will be removed and the bot will post a message to inform the user about what happened.

        Args:
            author (discord.User): The user who invoked the command.
            channel (discord.TextChannel): The channel in which the command has been invoked.
            persons (List[ET.Element]): A list containing the person elements from the XML string returned by `ufind`.

        Returns:
            int: The index of the selected person.
        """
        list_selection_emojis = SelectionEmoji.to_list()

        embed = _create_embed_staff_selection(persons)
        message = await channel.send(
            embed=embed, delete_after=constants.TIMEOUT_USER_SELECTION)

        for i in range(len(embed.fields)):
            await message.add_reaction(list_selection_emojis[i])

        def check_reaction(_reaction, user):
            return _reaction.message.id == message.id and user == author and SelectionEmoji(
                _reaction.emoji) is not None

        reaction = await self.bot.wait_for(
            'reaction_add',
            timeout=constants.TIMEOUT_USER_SELECTION,
            check=check_reaction)
        await message.delete()

        return list_selection_emojis.index(reaction[0].emoji)

    @commands.group(name='exchange', hidden=True, invoke_without_command=True)
    @command_log
    async def exchange(self, ctx: commands.Context,
                       channel: discord.TextChannel, offered_group: int, *,
                       requested_groups_str: str):
        """Command Handler for the exchange command

       Creates a new request for group exchange. Posts an embed in the configured group exchange channel, adds the
       according entries in the DB and notifies the poster and all possible exchange partners via direct message.

        Args:
            ctx (Context): The context in which the command was called.
            channel (discord:TextChannel): The channel corresponding to the course for group change.
            offered_group (int): The group that the user offers.
            requested_groups_str (List[int]): A list of all groups the user would be willing to take.
        """
        if constants.EMOJI_CHANNEL_NAME_SEPARATOR not in channel.name:
            raise SyntaxError("Invalid course channel.")

        try:
            requested_groups = list(map(int, requested_groups_str.split(',')))
        except ValueError:
            raise SyntaxError("Invalid symbol in list of requested groups: " +
                              requested_groups_str)

        if offered_group in requested_groups:
            raise ValueError(
                "The offered Group was part of the requested groups. Offered Group {0}, "
                "Requested Groups: {1}".format(offered_group,
                                               requested_groups))

        self._db_connector.add_group_offer_and_requests(
            ctx.author.id, channel.id, offered_group, requested_groups)
        embed = _build_group_exchange_offer_embed(ctx.author, channel,
                                                  offered_group,
                                                  requested_groups)
        message = await self.ch_group_exchange.send(embed=embed)
        self._db_connector.update_group_exchange_message_id(
            ctx.author.id, channel.id, message.id)

        if ctx.channel != self.ch_group_exchange:
            await ctx.send(
                ":white_check_mark: Dein Tauschangebot wurde erfolgreich erstellt!"
            )

        potential_candidates = self._db_connector.get_candidates_for_group_exchange(
            ctx.author.id, channel.id, offered_group, requested_groups)
        if potential_candidates:
            await self._notify_author_about_candidates(ctx.author,
                                                       potential_candidates,
                                                       self.ch_group_exchange,
                                                       channel)
            notification_embed = _build_candidate_notification_embed(
                ctx.author, message, channel, offered_group,
                self.bot.command_prefix)
            await self._notify_candidates_about_new_offer(
                potential_candidates, notification_embed)

    @exchange.command(name="remove", hidden=True)
    @command_log
    async def remove_exchange(self, ctx: commands.Context,
                              channel: discord.TextChannel):
        """Removes a group exchange request.

        Deletes the message in the exchange channel as well as all corresponding entries in the db.

        Args:
            ctx (discord.ext.commands.Context): The context from which this command is invoked.
            channel (discord.TextChannel): The channel corresponding to the course.
        """
        message_id = self._db_connector.get_group_exchange_message(
            ctx.author.id, channel.id)

        if message_id:
            self._db_connector.remove_group_exchange_offer(
                ctx.author.id, channel.id)
            msg = await self.ch_group_exchange.fetch_message(message_id)

            await msg.delete()
            await ctx.author.send(
                ":white_check_mark: Dein Tauschangebot wurde erfolgreich gelöscht."
            )

        else:
            await ctx.author.send(
                "Du besitzt derzeit kein aktives Tauschangebot für diesen Kurs. :face_with_monocle:"
            )

    @exchange.error
    @remove_exchange.error
    async def exchange_error(self, ctx: commands.Context,
                             error: commands.CommandError):
        """Error Handler for the exchange command.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            error (commands.CommandError): The error raised during the execution of the command.
        """
        if isinstance(error, commands.CommandInvokeError) and isinstance(
                error.original, ValueError):
            await ctx.author.send(
                "**__Error:__** Die angebotene Gruppe kann nicht Teil der gewünschten Gruppen sein."
            )
        elif isinstance(error, commands.CommandInvokeError) and isinstance(
                error.original, IntegrityError):
            await ctx.author.send(
                "**__Error:__** Du hast für diesen Kurs bereits ein aktives Tauschangebot.\nDu musst das alte Angebot "
                "zuerst mit `{0}exchange remove <channel-mention>` löschen, bevor du ein neues einreichen kannst."
                .format(self.bot.command_prefix))
        elif isinstance(error, commands.CommandInvokeError) and isinstance(
                error.original, SyntaxError):
            await ctx.author.send(
                "**__Error:__** Der von dir eingegebene Befehl, zur Erstellung eines Tauschangebots, ist inkorrekt.\n"
                "Bitte achte darauf, einen gültigen LV-Kanal anzugeben und die Nummern der gewünschten Gruppen "
                "mittels Beistrich zu trennen. Für weitere Infos, siehe angepinnte Nachrichten in {0}. :pushpin:"
                .format(self.ch_group_exchange.mention))
        elif isinstance(error, commands.MissingRequiredArgument):
            await ctx.author.send(
                "**__Error:__** Der von dir eingegebene Befehl ist unvollständig bzw. inkorrekt.\n"
                "Bitte lies dir die im Kanal {0} angepinnte Nachricht nochmals durch und versuche es "
                "dann erneut. :pushpin:".format(self.ch_group_exchange.mention)
            )
        elif isinstance(error, commands.BadArgument):
            await ctx.author.send(
                "**__Error:__** Der von dir angegebene LV-Kanal existiert nicht.\nVersuche ihn "
                "mittels einer Markierung (Bsp: #pr1...) anzugeben, um Probleme zu vermeiden. Für "
                "weitere Infos, siehe angepinnte Nachrichten in {0}. :pushpin:"
                .format(self.ch_group_exchange.mention))

    @exchange.command(name="list", hidden=True)
    @command_log
    async def list_exchanges(self, ctx: commands.Context):
        """Lists all active group exchange requests by a user.

        Sends the active requests in an embed via direct message to a user.

        Args:
            ctx (discord.ext.commands.Context): The context from which this command is invoked.
        """
        exchange_requests = self._db_connector.get_group_exchange_for_user(
            ctx.author.id)
        if exchange_requests:
            embed = await self._build_group_exchange_list_embed(
                exchange_requests)
            await ctx.author.send(embed=embed)
        else:
            await ctx.author.send(
                "Du hast zurzeit keine aktiven Tauschangebote. :hushed:")

    async def _notify_author_about_candidates(
            self, author: discord.User,
            potential_candidates: List[Tuple[str, str, int]],
            channel: discord.TextChannel, course_channel: discord.TextChannel):
        """Notifies the Author of a group exchange request about possible exchange candidates.

        The author is informed via a direct message which contains infos about all possible users he or she could
        exchange groups with.

        Args:
            author (discord.User): The author to be notified.
            potential_candidates (List[Tuple[str, str]]): The possible candidate ids and the message ids of their
            exchange messages.
            channel (discord.TextChannel): The channel in which the the message of potential candidates can be found
            course_channel (discord.TextChannel): The channel that refers to the course that the exchange is for.
        """
        course_name = _parse_course_from_channel_name(course_channel)
        embed = discord.Embed(
            title="Mögliche Tauschpartner - {0}".format(course_name),
            description=
            "Bitte vergiss nicht, deine Anfrage mit dem Befehl `{0}exchange remove "
            "<channel-mention>` wieder zu löschen, sobald du einen Tauschpartner "
            "gefunden hast.".format(self.bot.command_prefix),
            color=constants.EMBED_COLOR_GROUP_EXCHANGE)

        async def group_by_group_nr(cand):
            """Async Generator function to group a tuple (user_id, message_id, group_nr) by group_nr.

            Args:
                cand (List[Tuple[str, str, int]]): The tuple list to group.

            Examples:
                 Example Result: [(1, [(user1, msg1), (user2, msg2)], (2, [(user3, msg3), (...),...]),...]
            """
            guild = self.bot.get_guild(int(constants.SERVER_ID))
            iterable_cand = itertools.groupby(cand, operator.itemgetter(2))

            for key, subiter in iterable_cand:
                group_text = "Gruppe {0}".format(key)
                user_list = []

                for item in subiter:
                    user = guild.get_member(int(item[0]))
                    msg = await channel.fetch_message(int(item[1]))
                    user_list.append("- [{0}]({1})".format(user, msg.jump_url))

                yield group_text, "\n".join(user_list)

        users_by_group = group_by_group_nr(potential_candidates)
        async for group in users_by_group:
            embed.add_field(name=group[0], value=group[1])
        await author.send(
            content="Ich habe potentielle Tauschpartner für dich gefunden:",
            embed=embed)

    async def _notify_candidates_about_new_offer(
            self, potential_candidates: List[tuple], embed: discord.Embed):
        """Notifies all potential candidates that a new relevant group exchange offer has been posted.

        The candidates are informed via a direct message which contains information about the members and their offers
        he/she could exchange groups with.

        Args:
            potential_candidates (List[tuple]): The user ids of the potential candidates and the message ids of the
                                                corresponding exchange messages.
            embed (discord.Embed): The embed which should be send to the potential candidates.
        """
        guild = self.bot.get_guild(int(constants.SERVER_ID))

        for candidate in potential_candidates:
            member = guild.get_member(int(candidate[0]))
            await member.send(
                content="Ich habe ein neues Tauschangebot für dich gefunden:",
                embed=embed)

    async def _build_group_exchange_list_embed(self,
                                               exchange_requests: List[tuple]):
        """Builds an embed that contains infos about all group exchange requests a user has currently open.

        Args:
            exchange_requests (List[tuple]): A list containing tuples consisting of channel_id, message_id,
            offered_group and requested groups joined with commas.

        Returns:
            (discord.Embed): The created embed.
        """
        embed = discord.Embed(title="Deine Gruppentausch-Anfragen:",
                              color=constants.EMBED_COLOR_GROUP_EXCHANGE)
        for request in exchange_requests:
            course_channel = self.bot.get_guild(int(
                constants.SERVER_ID)).get_channel(int(request[0]))
            msg = await self.ch_group_exchange.fetch_message(int(request[1]))
            course = _parse_course_from_channel_name(course_channel)
            offered_group = request[2]
            requested_groups = request[3]
            embed.add_field(
                name=course,
                inline=False,
                value=
                "__Biete:__ Gruppe {0}\n__Suche:__ Gruppen {1}\n[[Zur Nachricht]]({2})"
                .format(offered_group, requested_groups, msg.jump_url))
        return embed
Exemple #7
0
class RoleManagementCog(commands.Cog):
    """Cog for functions regarding server roles."""
    def __init__(self, bot):
        """Initializes the Cog.

        Args:
            bot (discord.ext.commands.Bot): The bot for which this cog should be enabled.
        """
        self.bot = bot
        self._db_connector = DatabaseConnector(constants.DB_FILE_PATH,
                                               constants.DB_INIT_SCRIPT)

        # Channel instances
        self.ch_role = bot.get_guild(int(constants.SERVER_ID)).get_channel(
            int(constants.CHANNEL_ID_ROLES))

    @commands.group(name='module', invoke_without_command=True)
    @command_log
    async def toggle_module(self, ctx: commands.Context, *, str_modules: str):
        """Command Handler for the `module` command.

        Allows members to assign/remove so called mod roles to/from themselves. This way users can toggle text channels
        about specific courses to be visible or not to them. When the operation is finished, SAM will send an overview
        about the changes he did per direct message to the user who invoked the command.
        Keep in mind that this only works if the desired role has been whitelisted as a module role by the bot owner.

        If the command is invoked outside of the configured role channel, the bot will post a short info that this
        command should only be invoked there and delete this message shortly after.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            str_modules (str): A string containing abbreviations of all the modules a user would like to toggle.
        """
        if ctx.channel.id != self.ch_role.id:
            if not self._db_connector.is_botonly(ctx.channel.id):
                await ctx.message.delete()

            ctx.channel.send(
                value=
                f"Dieser Befehl wird nur in {self.ch_role.mention} unterstützt. Bitte "
                f"versuche es dort noch einmal. ",
                delete_after=constants.TIMEOUT_INFORMATION)
            return

        converter = commands.RoleConverter()
        modules = list(set(str_modules.split()))  # Removes duplicates

        modules_error = []
        modules_added = []
        modules_removed = []

        for module in modules:
            module_upper = module.upper()
            try:
                role = await converter.convert(ctx, module_upper)

                if not self._db_connector.check_module_role(role.id):
                    raise commands.BadArgument(
                        "The specified role hasn't been whitelisted as a module role."
                    )

                if role in ctx.author.roles:
                    await ctx.author.remove_roles(
                        role,
                        atomic=True,
                        reason="Selbstständig entfernt via SAM.")
                    modules_removed.append(module_upper)
                else:
                    await ctx.author.add_roles(
                        role,
                        atomic=True,
                        reason="Selbstständig zugewiesen via SAM.")
                    modules_added.append(module_upper)
            except commands.BadArgument:
                modules_error.append(module_upper)

        if len(modules_error) < len(modules):
            log.info("Module roles of the member %s have been changed.",
                     ctx.author)

        embed = _create_embed_module_roles(modules_added, modules_removed,
                                           modules_error)
        await ctx.author.send(embed=embed)

    @toggle_module.command(name="add", hidden=True)
    @commands.is_owner()
    @command_log
    async def add_module_role(self, ctx: commands.Context, module_name: str):
        """Command Handler for the `module` subcommand `add`.

        Allows the bot owner to add a specific role to the module roles.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            module_name (str): The name of the role which should be added.
        """
        module_role = await commands.RoleConverter().convert(
            ctx, module_name.upper())

        try:
            self._db_connector.add_module_role(module_role.id)
            log.info("Role \"%s\" has been whitelisted as a module role.",
                     module_role)

            await ctx.send(
                f"Die Rolle \"**__{module_role}__**\" wurde erfolgreich zu den verfügbaren Modul-Rollen "
                f"hinzugefügt.")
        except IntegrityError:
            await ctx.send(
                f"Die Rolle \"**__{module_role}__**\" gehört bereits zu den verfügbaren Modul-Rollen."
            )

    @toggle_module.command(name="remove", hidden=True)
    @commands.is_owner()
    @command_log
    async def remove_module_role(self, ctx: commands.Context,
                                 module_name: str):
        """Command Handler for the `module` subcommand `remove`.

        Allows the bot owner to remove a specific role from the module roles.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            module_name (str): The name of the role which should be removed.
        """
        module_role = await commands.RoleConverter().convert(
            ctx, module_name.upper())

        self._db_connector.remove_module_role(module_role.id)
        log.info("Role \"%s\" has been disabled as a module role.",
                 module_role)

        await ctx.send(
            f"Die Rolle \"**__{module_role}__**\" wurde aus den verfügbaren Modul-Rollen entfernt."
        )

    @add_module_role.error
    @remove_module_role.error
    async def module_role_error(self, ctx: commands.Context,
                                error: commands.CommandError):
        """Error Handler for the `module` subcommand `remove`.

        Handles specific exceptions which occur during the execution of this command. The global error handler will
        still be called for every error thrown.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            error (commands.CommandError): The error raised during the execution of the command.
        """
        if isinstance(error, commands.BadArgument):
            role = re.search(
                r"\"(.*)\"",
                error.args[0])  # Regex for getting text between two quotes.
            role = role.group(1) if role is not None else None

            await ctx.author.send(
                f"Die von dir angegebene Rolle \"**__{role}__**\" existiert leider nicht."
            )

    @commands.group(name="reactionrole",
                    aliases=["rr"],
                    hidden=True,
                    invoke_without_command=True)
    @commands.is_owner()
    @command_log
    async def reaction_role(self, ctx: commands.Context):
        """Command Handler for the `reactionrole` command.

        Allows the bot owner to manage so called "reaction roles" for messages in the configured role channel. This can
        be done via multiple subcommands like `add`, `remove` or `clear`.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
        """
        await ctx.send_help(ctx.command)

    @reaction_role.command(name="add")
    @commands.is_owner()
    @command_log
    async def add_reaction_role(self, ctx: commands.Context,
                                message: discord.Message, emoji: str,
                                role: discord.Role):
        """Command Handler for the subcommand `add` of the `reactionrole` command.

        Adds a reaction to a specified message and creates a corresponding database entry for it to work as a so called
        reaction role.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            message (discord.Message): The message to which the reaction role should be added.
            emoji (str): The emoji of the reaction which will be added.
            role (discord.Role): The specific role a member should get when adding the reaction.
        """
        if message.channel.id != self.ch_role.id:
            await ctx.send(
                f":information_source: Reaction-Roles können nur für Nachrichten im Kanal "
                f"{self.ch_role.mention} erstellt werden.")
            return
        if emoji in [reaction.emoji for reaction in message.reactions]:
            await ctx.send(
                ":x: Für den angegebenen Emoji existiert bereits eine Reaction-Role."
            )
            return

        self._db_connector.add_reaction_role(message.id, emoji, role.id)
        await message.add_reaction(emoji)
        log.info("A reaction role has been added to the message with id %s.",
                 message.id)

        await ctx.send(
            ":white_check_mark: Die Reaction-Role wurde erfolgreich erstellt.")

    @reaction_role.command(name="remove")
    @commands.is_owner()
    @command_log
    async def remove_reaction_role(self, ctx: commands.Context,
                                   message: discord.Message, emoji: str):
        """Command Handler for the subcommand `remove` of the `reactionrole` command.

        Removes the reaction from a specified message and deletes the corresponding database entry of the reaction role.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            message (discord.Message): The message from which the reaction role should be removed.
            emoji (str): The emoji of the reaction.
        """
        if message.channel.id != self.ch_role.id:
            await ctx.send(
                f":information_source: Nachrichten außerhalb des Kanals {self.ch_role.mention} können keine "
                f"Reaction-Roles besitzen.")
            return
        if emoji not in [reaction.emoji for reaction in message.reactions]:
            await ctx.send(
                ":x: Für den angegebenen Emoji existiert leider keine Reaction-Role."
            )
            return

        self._db_connector.remove_reaction_role(message.id, emoji)
        await message.clear_reaction(emoji)
        log.info(
            "A reaction role has been removed from the message with id %s.",
            message.id)

        if len(message.reactions) == 1:
            self._db_connector.remove_reaction_role_uniqueness_group(
                message.id)

        await ctx.send(
            ":white_check_mark: Die Reaction-Role wurde erfolgreich entfernt.")

    @reaction_role.command(name="clear")
    @commands.is_owner()
    @command_log
    async def clear_reaction_roles(self, ctx: commands.Context,
                                   message: discord.Message):
        """Command Handler for the subcommand `clear` of the `reactionrole` command.

        Removes all the reaction from a specified message and deletes all the corresponding database entry of the
        reaction roles.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            message (discord.Message): The message from which the reaction roles should be removed.
        """
        if message.channel.id != self.ch_role.id:
            await ctx.send(
                f":information_source: Nachrichten außerhalb des Kanals {self.ch_role.mention} können keine "
                f"Reaction-Roles besitzen.")
            return
        had_reaction_roles = self._db_connector.clear_reaction_roles(
            message.id)
        self._db_connector.remove_reaction_role_uniqueness_group(message.id)

        if not had_reaction_roles:
            await ctx.send(
                "Die von dir angegebene Nachricht hat keine Reaction-Roles. :face_with_monocle:"
            )
            return

        await message.clear_reactions()
        log.info(
            "All reaction roles of the message with id %s have been removed.",
            message.id)

        await ctx.send(
            ":white_check_mark: Die Reaction-Roles wurden erfolgreich entfernt."
        )

    @reaction_role.command(name="unique")
    @commands.is_owner()
    @command_log
    async def toggle_reaction_roles_exclusiveness(self, ctx: commands.Context,
                                                  message: discord.Message):
        """Command Handler for the subcommand `unique` of the `reactionrole` command.

        Marks all the reaction roles of a message as "unique" by adding the message id to a specific table in the db.
        This means that users can only have one of the configured reaction roles of this message at a time.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            message (discord.Message): The message from which the reaction roles should be removed.
        """
        if len(message.reactions) == 0:
            await ctx.send(
                ":x: Die angegebene Nachricht besitzt keine Reaction-Roles.")
            return

        if self._db_connector.is_reaction_role_uniqueness_group(message.id):
            self._db_connector.remove_reaction_role_uniqueness_group(
                message.id)
            log.info(
                "A reaction role has been added to the message with id %s.",
                message.id)

            await ctx.send(
                ":white_check_mark: Die Reaction-Roles der angegebenen Nachricht sind nicht mehr "
                "\"exklusiv\".")
        else:
            self._db_connector.add_reaction_role_uniqueness_group(message.id)
            await ctx.send(
                ":white_check_mark: Die Reaction-Roles der angegebenen Nachricht sind nun \"exklusiv\"."
            )

    @add_reaction_role.error
    @remove_reaction_role.error
    @clear_reaction_roles.error
    @toggle_reaction_roles_exclusiveness.error
    async def reaction_role_error(self, _ctx: commands.Context,
                                  error: commands.CommandError):
        """Error Handler for the `reactionrole` command group.

        Handles specific exceptions which occur during the execution of this command. The global error handler will
        still be called for every error thrown.

        Args:
            _ctx (discord.ext.commands.Context): The context in which the command was called.
            error (commands.CommandError): The error raised during the execution of the command.
        """
        if isinstance(error, commands.BadArgument):
            print(
                "**__Error:__** Die von dir angegebene Nachricht/Rolle existiert nicht."
            )

    @commands.command(name="role")
    @command_log
    async def role(self, ctx: commands.Context, role: discord.Role,
                   user: discord.Member):
        if role.id not in {_role.id for _role in user.roles}:
            try:
                await user.add_roles(role)
                await ctx.channel.send(embed=discord.Embed(
                    description=f"Added {role.mention} to {user.mention}",
                    colour=discord.Colour.green()))
            except:
                await ctx.channel.send(embed=discord.Embed(
                    title="I do not have the permission to add that role",
                    colour=discord.Colour.red()))
        else:
            try:
                await user.remove_roles(role)
                await ctx.channel.send(embed=discord.Embed(
                    description=f"Removed {role.mention} from {user.mention}",
                    colour=discord.Colour.green()))
            except:
                await ctx.channel.send(embed=discord.Embed(
                    title="I do not have the permission to remove that role",
                    colour=discord.Colour.red()))

    @commands.Cog.listener(name='on_raw_reaction_add')
    async def reaction_role_add(self, payload: discord.RawReactionActionEvent):
        """Event listener which triggers if a reaction has been added by a user.

        If the affected message is in the specified role channel and the added reaction represents one of the configured
        reaction roles, the corresponding role specified in the db will be added to the user.

        Args:
            payload (discord.RawReactionActionEvent): The payload for the triggered event.
        """
        if payload.channel_id == self.ch_role.id and not payload.member.bot:
            if self._db_connector.is_reaction_role_uniqueness_group(
                    payload.message_id):
                message = await self.ch_role.fetch_message(payload.message_id)

                for reaction in message.reactions:
                    if reaction.emoji != payload.emoji.name:
                        await reaction.remove(payload.member)

            role_id = self._db_connector.get_reaction_role(
                payload.message_id, payload.emoji.name)
            role = self.bot.get_guild(payload.guild_id).get_role(role_id)
            await payload.member.add_roles(
                role, reason="Selbstzuweisung via Reaction.")

    @commands.Cog.listener(name='on_raw_reaction_remove')
    async def reaction_role_remove(self,
                                   payload: discord.RawReactionActionEvent):
        """Event listener which triggers if a reaction has been removed.

        If the affected message is in the specified role channel and the removed reaction represents one of the
        configured reaction roles, the corresponding role specified in the db will removed from the user.

        Args:
            payload (discord.RawReactionActionEvent): The payload for the triggered event.
        """
        if payload.channel_id == self.ch_role.id:
            role_id = self._db_connector.get_reaction_role(
                payload.message_id, payload.emoji.name)
            role = self.bot.get_guild(payload.guild_id).get_role(role_id)

            member = self.bot.get_guild(payload.guild_id).get_member(
                payload.user_id)
            await member.remove_roles(
                role, reason="Automatische/Manuelle Entfernung via Reaction.")

    @commands.Cog.listener(name='on_raw_message_delete')
    async def delete_reaction_role_group(
            self, payload: discord.RawReactionActionEvent):
        """Event listener which triggers if a message has been deleted.

        If the affected message was in the specified role channel and was listed as a special Reaction Role Group in
         the db, the corresponding entry will be removed.

        Args:
            payload (discord.RawReactionActionEvent): The payload for the triggered event.
        """
        if payload.channel_id == self.ch_role.id:
            self._db_connector.remove_reaction_role_uniqueness_group(
                payload.message_id)
Exemple #8
0
class CommunityCog(commands.Cog):
    """Cog for Community Functions."""
    def __init__(self, bot):
        """Initializes the Cog.

        Args:
            bot (discord.ext.commands.Bot): The bot for which this cog should be enabled.
        """
        self.bot = bot
        self._db_connector = DatabaseConnector(const.DB_FILE_PATH,
                                               const.DB_INIT_SCRIPT)

        # Channel Category instances
        self.cat_gaming_rooms = bot.get_guild(int(
            const.SERVER_ID)).get_channel(int(const.CATEGORY_ID_GAMING_ROOMS))
        self.cat_study_rooms = bot.get_guild(int(const.SERVER_ID)).get_channel(
            int(const.CATEGORY_ID_STUDY_ROOMS))

    @commands.command(name='studyroom', aliases=["sr"])
    @command_log
    async def create_study_room(self, ctx: commands.Context,
                                ch_name: Optional[str],
                                user_limit: Optional[int]):
        """Command Handler for the `studyroom` command.

        Allows users to create temporary "Study Rooms" consisting of a voice and text channel in the configured study
        room category on the server. The member who created the room gets special permissions for muting/deafening
        members in the voice channel. If no members are left in the voice channel, it, as well as the corresponding text
        channel, will be automatically deleted.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            ch_name (Optional[str]): The name of the channel provided by the member.
            user_limit (int): The user limit for the voice channel provided by the member.
        """
        if not self._db_connector.is_botonly(ctx.channel.id):
            await ctx.message.delete()

        await self.create_community_room(ctx, self.cat_study_rooms, ch_name,
                                         user_limit)

    @commands.command(name='gameroom', aliases=["gr"])
    @command_log
    async def create_gaming_room(self, ctx: commands.Context,
                                 ch_name: Optional[str],
                                 user_limit: Optional[int]):
        """Command Handler for the `gameroom` command.

        Allows users to create temporary "Game Rooms" consisting of a voice and text channel in the configured game room
        category on the server. The member who created the room gets special permissions for muting/deafening members
        in the voice channel. If no members are left in the voice channel, it, as well as the corresponding text
        channel, will be automatically deleted.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            ch_name (Optional[str]): The name of the channel provided by the member.
            user_limit (int): The user limit for the voice channel provided by the member.
        """
        if not self._db_connector.is_botonly(ctx.channel.id):
            await ctx.message.delete()

        await self.create_community_room(ctx, self.cat_gaming_rooms, ch_name,
                                         user_limit)

    @create_gaming_room.error
    @create_study_room.error
    async def community_room_error(self, ctx: commands.Context,
                                   error: commands.CommandError):
        """Error Handler for the community room commands.

        Handles the exceptions which could occur during the execution of said command.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            error (commands.CommandError): The error raised during the execution of the command.
        """
        if isinstance(error, commands.CommandInvokeError) and isinstance(
                error.original, NotImplementedError):
            await ctx.send(
                "Bitte lösche zuerst deinen bestehenden Study/Game Room, bevor du einen weiteren erstellst.",
                delete_after=const.TIMEOUT_INFORMATION)
        elif isinstance(error, commands.CommandInvokeError) and isinstance(
                error.original, RuntimeWarning):
            await ctx.send(
                "Es gibt zurzeit zu viele aktive Räume in dieser Kategorie. Bitte versuche es später noch "
                "einmal. :hourglass:",
                delete_after=const.TIMEOUT_INFORMATION)
        elif isinstance(error, commands.CommandInvokeError) and isinstance(
                error.original, discord.InvalidArgument):
            await ctx.send(
                "Das Nutzer-Limit für einen Sprachkanal muss zwischen 1 und 99 liegen. Bitte versuche es "
                "noch einmal.",
                delete_after=const.TIMEOUT_INFORMATION)

    async def create_community_room(self, ctx: commands.Context,
                                    ch_category: discord.CategoryChannel,
                                    ch_name: Optional[str],
                                    user_limit: Optional[int]):
        """Method which creates a temporary community room requested via the study/game room commands.

        Additionally it validates the configured limits (max. amount of community rooms, valid user limit, one room
        per member) and raises an exception if needed.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            ch_name (Optional[str]): The name of the channel provided by the member.
            user_limit (int): The user limit for the voice channel provided by the member.
        """
        if len(ch_category.voice_channels) >= const.LIMIT_COMMUNITY_CHANNELS:
            raise RuntimeWarning(
                "Too many Community Rooms of this kind at the moment.")
        if user_limit and (user_limit < 1 or user_limit > 99):
            raise discord.InvalidArgument(
                "User limit cannot be outside range from 1 to 99.")
        if any(True for ch in self.cat_gaming_rooms.voice_channels if ctx.author in ch.overwrites) or \
           any(True for ch in self.cat_study_rooms.voice_channels if ctx.author in ch.overwrites):
            raise NotImplementedError(
                "Member already has an active Community Room.")

        limit: Optional[int]
        if ch_name and not user_limit:
            try:
                limit = int(ch_name)
                name = f"{ctx.author.display_name}'s Room"
            except ValueError:
                limit = None
                name = ch_name
        else:
            name = f"{ctx.author.display_name}'s Room" if ch_name is None else ch_name
            limit = user_limit

        # Remove channel number if user has added it himself.
        regex = re.search(r"(\[#\d+])", name)
        if regex:
            name = name.replace(regex.group(1), "")

        ch_number_addition = _determine_channel_number(ch_category, name)
        if ch_number_addition:
            name += ch_number_addition

        if len(name) > 100:
            name = name[:100]

        reason = f"Manuell erstellt von {ctx.author} via SAM."

        # Voice Channel
        bitrate = 96000  # 96 Kbit/s
        overwrites_voice = ch_category.overwrites
        overwrites_voice[ctx.author] = discord.PermissionOverwrite(
            priority_speaker=True,
            move_members=True,
            mute_members=True,
            deafen_members=True)
        await ch_category.create_voice_channel(name=name,
                                               user_limit=limit,
                                               bitrate=bitrate,
                                               overwrites=overwrites_voice,
                                               reason=reason)

        # Text Channel
        channel_type = "Game" if ch_category == self.cat_gaming_rooms else "Study"
        topic = f"Temporärer {channel_type}-Channel. || Erstellt von: {ctx.author.display_name}"
        await ch_category.create_text_channel(name=name,
                                              topic=topic,
                                              reason=reason)

        log.info("Temporary %s Room created by %s", channel_type, ctx.author)
        await ctx.send(
            f":white_check_mark: Der {channel_type}-Room wurde erfolgreich erstellt!",
            delete_after=const.TIMEOUT_INFORMATION)

    @commands.Cog.listener(name='on_voice_state_update')
    async def delete_community_room(self, member: discord.Member,
                                    before: discord.VoiceState,
                                    after: discord.VoiceState):
        """Event listener which triggers if the VoiceState of a member changes.

        Deletes a Community Room (consisting of a voice and text channel) if no members are left in the corresponding
        voice channel.

        Args:
            member (discord.Member): The member whose VoiceState changed.
            before (discord.VoiceState): The previous VoiceState.
            after (discord.VoiceState): The new VoiceState.
        """
        if before.channel and before.channel.category_id in {self.cat_gaming_rooms.id, self.cat_study_rooms.id} and \
           before.channel != after.channel and len(before.channel.members) == 0:
            reason = "No one was left in Community Room."
            txt_ch_name = re.sub(
                r"[^\w\s-]", "",
                before.channel.name.lower())  # Remove non-word chars except WS
            txt_ch_name = re.sub(r"\s", "-",
                                 txt_ch_name)  # Replace whitespaces with "-"
            txt_ch = next(ch for ch in before.channel.category.text_channels
                          if ch.name == txt_ch_name)

            await before.channel.delete(reason=reason)
            await txt_ch.delete(reason=reason)
            log.info(
                "Empty Community Room [%s] has been automatically deleted.",
                before.channel.name)

    @commands.Cog.listener(name='on_raw_reaction_add')
    async def mark_as_highlight(self, payload: discord.RawReactionActionEvent):
        """Event listener which triggers if a reaction has been added to a message.

        If enough users react to a message with the specified highlight emoji, it will be reposted in the configured
        highlights channel by SAM. This way even users who don't have access to specific channels are able to see
        interesting content from somewhere on the server.
        If recently a highlight message has already been posted for a specific message, the reaction counter inside its
        embed will be modified.

        Args:
            payload (discord.RawReactionActionEvent): The payload for the triggered event.
        """
        if payload.emoji.name != const.EMOJI_HIGHLIGHT or payload.channel_id == int(const.CHANNEL_ID_HIGHLIGHTS) \
                or self._db_connector.is_botonly(payload.channel_id):
            return

        guild = self.bot.get_guild(payload.guild_id)
        message_channel = guild.get_channel(payload.channel_id)
        message = await message_channel.fetch_message(payload.message_id)
        reaction = next(x for x in message.reactions
                        if x.emoji == const.EMOJI_HIGHLIGHT)

        has_author_reacted = await reaction.users().get(id=message.author.id)
        reaction_counter = reaction.count - 1 if has_author_reacted else reaction.count

        highlight_channel = guild.get_channel(int(const.CHANNEL_ID_HIGHLIGHTS))
        highlight_message = await _check_if_already_highlight(
            highlight_channel, message.id)

        if highlight_message:
            embed = highlight_message.embeds[0]
            embed.set_field_at(
                0,
                name=f"{const.EMOJI_HIGHLIGHT} {reaction_counter}",
                value=const.ZERO_WIDTH_SPACE)
            await highlight_message.edit(
                content=
                f"Sieht so aus als hätte sich {message.author.mention} einen Platz in "
                f"der Ruhmeshalle verdient! :tada:",
                embed=embed)
            log.info(
                "The highlight embed of the message with id \"%s\" has been updated.",
                message.id)

        elif reaction_counter == const.LIMIT_HIGHLIGHT:
            # Check if an image has been attached to the original message. If yes, take the first image and pass it to
            # the method which builds the embed so that it will be displayed inside it. Every other image or type of
            # attachment should be attached to a second message which will be send immediately after the highlight embed
            # because they can't be included in the embed.
            image = next(
                (a for a in message.attachments
                 if a.filename.split(".")[-1].lower() in
                 ["jpg", "jpeg", "png", "gif"] and not a.is_spoiler()), None)
            files = [
                await a.to_file(spoiler=a.is_spoiler())
                for a in message.attachments if a != image
            ]

            embed = _build_highlight_embed(
                message, image,
                guild.get_channel(int(const.CHANNEL_ID_ROLES)).name)
            await highlight_channel.send(
                f"Sieht so aus als hätte sich {message.author.mention} einen Platz in der "
                f"Ruhmeshalle verdient! :tada:",
                embed=embed)
            if files:
                async with highlight_channel.typing():
                    await highlight_channel.send(
                        ":paperclip: **Dazugehörige Attachments:**",
                        files=files)

            log.info(
                "A highlight embed for the message with id \"%s\" has been posted in the configured highlights "
                "channel.", message.id)
Exemple #9
0
class ModerationCog(commands.Cog):
    """Cog for Moderation Functions."""
    def __init__(self, bot):
        """Initializes the Cog.

        Args:
            bot (Bot): The bot for which this cog should be enabled.
        """
        self.bot = bot
        self._db_connector = DatabaseConnector(constants.DB_FILE_PATH,
                                               constants.DB_INIT_SCRIPT)

    @commands.command(name='modmail')
    async def modmail(self, ctx):
        """Command Handler for the `modmail` command.

        Allows users to write a message to all the moderators of the server. The message is going to be posted in a
        specified modmail channel which can (hopefully) only be accessed by said moderators. The user who invoked the
        command will get a confirmation via DM and the invocation will be deleted.

        Args:
            ctx (Context): The context in which the command was called.
        """
        msg_content = ctx.message.content
        msg_attachments = ctx.message.attachments
        await ctx.message.delete()

        ch_modmail = ctx.guild.get_channel(constants.CHANNEL_ID_MODMAIL)
        msg_content = msg_content[len(ctx.prefix + ctx.command.name):]

        embed = discord.Embed(title="Status: Offen",
                              color=constants.EMBED_COLOR_MODMAIL_OPEN,
                              timestamp=datetime.utcnow(),
                              description=msg_content)
        embed.set_author(name=ctx.author.name + "#" + ctx.author.discriminator,
                         icon_url=ctx.author.avatar_url)
        embed.set_footer(text="Erhalten am")

        msg_modmail = await ch_modmail.send(embed=embed, files=msg_attachments)
        self._db_connector.add_modmail(msg_modmail.id)
        await msg_modmail.add_reaction(constants.EMOJI_MODMAIL_DONE)
        await msg_modmail.add_reaction(constants.EMOJI_MODMAIL_ASSIGN)

        embed_confirmation = embed.to_dict()
        embed_confirmation["title"] = "Deine Nachricht:"
        embed_confirmation["color"] = constants.EMBED_COLOR_INFO
        embed_confirmation = discord.Embed.from_dict(embed_confirmation)
        await ctx.author.send(
            "Deine Nachricht wurde erfolgreich an die Moderatoren weitergeleitet!\n"
            "__Hier deine Bestätigung:__",
            embed=embed_confirmation)

    @commands.Cog.listener(name='on_raw_reaction_add')
    async def modmail_reaction_add(self, payload):
        """Event listener which triggers if a reaction has been added by a user.

        If the affected message is in the configured Modmail channel and the added reaction is one of the two emojis
        specified in constants.py, changes will be made to the current status of the modmail and visualized accordingly
        by the corresponding embed.

        Args:
            payload (discord.RawReactionActionEvent): The payload for the triggered event.
        """
        if not payload.member.bot and payload.channel_id == constants.CHANNEL_ID_MODMAIL:
            modmail = await self.bot.get_channel(
                payload.channel_id).fetch_message(payload.message_id)

            if payload.emoji.name == constants.EMOJI_MODMAIL_DONE or payload.emoji.name == constants.EMOJI_MODMAIL_ASSIGN:
                new_embed = await self.change_modmail_status(
                    modmail, payload.emoji.name, True)
                await modmail.edit(embed=new_embed)

    @commands.Cog.listener(name='on_raw_reaction_remove')
    async def modmail_reaction_remove(self, payload):
        """Event listener which triggers if a reaction has been removed.

        If the affected message is in the configured Modmail channel and the removed reaction is one of the two emojis
        specified in constants.py, changes will be made to the current status of the modmail and visualized accordingly
        by the corresponding embed.

        Args:
            payload (discord.RawReactionActionEvent): The payload for the triggered event.
        """
        if payload.channel_id == constants.CHANNEL_ID_MODMAIL:
            modmail = await self.bot.get_channel(
                payload.channel_id).fetch_message(payload.message_id)

            if payload.emoji.name == constants.EMOJI_MODMAIL_DONE or payload.emoji.name == constants.EMOJI_MODMAIL_ASSIGN:
                new_embed = await self.change_modmail_status(
                    modmail, payload.emoji.name, False)
                await modmail.edit(embed=new_embed)

    async def change_modmail_status(self, modmail, emoji, reacion_added):
        """Method which changes the status of a modmail depending on the given emoji.

        This is done by changing the StatusID in the database for the respective message and visualized by changing the
        color of the Embed posted on Discord.

        Args:
            modmail (discord.Message): The Discord message in the specified Modmail channel.
            emoji (str): A String containing the Unicode for a specific emoji.
            reacion_added (Boolean): A boolean indicating if a reaction has been added or removed.

        Returns:
            discord.Embed: An adapted Embed corresponding to the new modmail status.
        """
        curr_status = self._db_connector.get_modmail_status(modmail.id)
        dict_embed = modmail.embeds[0].to_dict()
        dict_embed["title"] = "Status: "

        if reacion_added and emoji == constants.EMOJI_MODMAIL_DONE and curr_status != ModmailStatus.CLOSED:
            await modmail.clear_reaction(constants.EMOJI_MODMAIL_ASSIGN)
            self._db_connector.change_modmail_status(modmail.id,
                                                     ModmailStatus.CLOSED)

            dict_embed["title"] += "Erledigt"
            dict_embed["color"] = constants.EMBED_COLOR_MODMAIL_CLOSED
        elif reacion_added and emoji == constants.EMOJI_MODMAIL_ASSIGN and curr_status != ModmailStatus.IN_PROGRESS:
            self._db_connector.change_modmail_status(modmail.id,
                                                     ModmailStatus.IN_PROGRESS)

            dict_embed["title"] += "In Bearbeitung"
            dict_embed["color"] = constants.EMBED_COLOR_MODMAIL_ASSIGNED
        else:
            dict_embed["title"] += "Offen"
            dict_embed["color"] = constants.EMBED_COLOR_MODMAIL_OPEN

            if emoji == constants.EMOJI_MODMAIL_DONE and curr_status != ModmailStatus.OPEN:
                self._db_connector.change_modmail_status(
                    modmail.id, ModmailStatus.OPEN)
                await modmail.add_reaction(constants.EMOJI_MODMAIL_ASSIGN)
            elif emoji == constants.EMOJI_MODMAIL_ASSIGN and curr_status != ModmailStatus.OPEN:
                self._db_connector.change_modmail_status(
                    modmail.id, ModmailStatus.OPEN)

        return discord.Embed.from_dict(dict_embed)
Exemple #10
0
class AdminCog(commands.Cog):
    """Cog for administrative Functions."""
    def __init__(self, bot):
        """Initializes the Cog.

        Args:
            bot (discord.ext.commands.Bot): The bot for which this cog should be enabled.
        """
        self.bot = bot
        self._db_connector = DatabaseConnector(constants.DB_FILE_PATH,
                                               constants.DB_INIT_SCRIPT)

        # Channel instances
        self.ch_bot = bot.get_guild(int(constants.SERVER_ID)).get_channel(
            int(constants.CHANNEL_ID_BOT))

    # A special method that registers as a commands.check() for every command and subcommand in this cog.
    async def cog_check(self, ctx):
        return await self.bot.is_owner(
            ctx.author
        )  # Only owners of the bot can use the commands defined in this Cog.

    @commands.command(name="echo", hidden=True)
    @command_log
    async def echo(self, ctx: commands.Context,
                   channel: Optional[discord.TextChannel], *, text: str):
        """Lets the bot post a simple message to the mentioned channel (or the current channel if none is mentioned).

        Args:
            ctx (discord.ext.commands.Context): The context from which this command is invoked.
            channel (Optional[str]): The channel where the message will be posted in.
            text (str): The text to be echoed.
        """
        await (channel or ctx).send(text)

    @commands.group(name='embed', hidden=True, invoke_without_command=True)
    @command_log
    async def embed(self, _ctx: commands.Context, channel: discord.TextChannel,
                    color: discord.Colour, *, text: str):
        """Command Handler for the embed command

        Creates and sends an embed in the specified channel with color, title and text. The Title and text are separated
        by a '|' character.

        Args:
            _ctx (Context): The context in which the command was called.
            channel (str): The channel where to post the message. Can be channel name (starting with #) or channel id.
            color (str): Color code for the color of the strip.
            text (str): The text to be posted in the embed. The string contains title and content, which are separated
                        by a '|' character. If this character is not found, no title will be assumed.
        """
        if '|' in text:
            title, description = text.split('|')
        else:
            title = ''
            description = text

        embed = discord.Embed(title=title,
                              description=description,
                              color=color)
        await channel.send(embed=embed)

    @embed.command(name='json', hidden=True)
    @command_log
    async def embed_by_json(self, _ctx: commands.Context,
                            channel: discord.TextChannel, *, json_string: str):
        """Command Handler for the embed command.

        Creates and sends an embed in the specified channel parsed from json.

        Args:
            _ctx (Context): The context in which the command was called.
            channel (str): The channel where to post the message. Can be channel name (starting with #) or channel id.
            json_string (str): The json string representing the embed. Alternatively it could also be a pastebin link.
        """
        if is_pastebin_link(json_string):
            json_string = parse_pastebin_link(json_string)
        embed_dict = json.loads(json_string)
        embed = discord.Embed.from_dict(embed_dict)
        await channel.send(embed=embed)

    @commands.group(name="edit", hidden=True, invoke_without_command=True)
    async def edit(self, ctx: commands.Context):
        """Command Handler for the edit command.

        This command has 2 subcommands,
        `edit content` to edit the content of a message and
        `edit embed` to edit the embed of a message.

        Args:
              ctx (commands.Context): The context in which this command was invoked.
        """
        await ctx.send_help(ctx.command)

    @edit.command(name="content", hidden=True)
    async def edit_msg_content(self, _ctx: commands.Context,
                               message: discord.Message, *, new_content: str):
        """Command handler for editing the content of a message posted by the bot.

        If the original message contained more than one embed only the first one will remain after editing.

        Args:
            _ctx (commands.Context): The context in which this command was invoked.
            message (discord.Message): The message to be edited.
            new_content (str). The new content to replace the original one.
        """
        if message.author != self.bot.user:
            raise commands.BadArgument(
                "Can only edit message from bot user. The message was from: %s"
                % str(message.author))
        # First entry in embed list is used,
        await message.edit(
            content=new_content,
            embed=(message.embeds[0] if message.embeds else None))

    @edit.command(name="embed", hidden=True)
    async def edit_msg_embed(self, _ctx: commands.Context,
                             message: discord.Message, *, new_embed: str):
        """Command handler for editing the content of a message posted by the bot.

        If the original message contained more than one embed only the first one will remain after editing.

        Args:
            _ctx (commands.Context): The context in which this command was invoked.
            message (discord.Message): The message to be edited.
            new_embed (str). The new embed in JSON format.
        """
        if message.author != self.bot.user:
            raise commands.BadArgument(
                "Can only edit message from bot user. The message was from: {0}"
                .format(str(message.author)))

        if is_pastebin_link(new_embed):
            new_embed = parse_pastebin_link(new_embed)
        embed_dict = json.loads(new_embed)
        embed = discord.Embed.from_dict(embed_dict)
        # Only first embed will be replaced.
        await message.edit(content=message.content, embed=embed)

    @embed.error
    @edit_msg_embed.error
    @edit_msg_content.error
    @embed_by_json.error
    async def embed_error(self, ctx: commands.Context,
                          error: commands.CommandError):
        """Error Handler for the 'embed' command and its subcommand 'embed json'

        Handles errors specific for the embed commands. Others will be handled globally

        Args:
            ctx (commands.Context): The context in which the command was called.
            error (commands.CommandError): The error raised during the execution of the command.
        """
        root_error = error if not isinstance(
            error, commands.CommandInvokeError) else error.original
        error_type = type(root_error)

        # put custom Error Handlers here
        async def handle_http_exception(ctx: commands.Context,
                                        _error: discord.HTTPException):
            await ctx.send(
                'Der übergebene JSON-String war entweder leer oder eines der Felder besaß einen ungültigen Typ.\n'
                +
                'Du kannst dein JSON auf folgender Seite validieren und gegebenenfalls anpassen: '
                + 'https://leovoel.github.io/embed-visualizer/.')

        async def handle_json_decode_error(ctx: commands.Context,
                                           error: json.JSONDecodeError):
            await ctx.send(
                "Der übergebene JSON-String konnte nicht geparsed werden. Hier die erhaltene Fehlermeldung:\n{0}"
                .format(str(error)))

        async def handle_bad_argument_error(ctx: commands.Context,
                                            _error: commands.BadArgument):
            await ctx.send(
                "Tut mir leid, aber anscheinend gibt es Probleme mit der von dir angegebenen ID. Bist du dir sicher "
                "dass du die richtige kopiert hast?")

        handler_mapper = {
            discord.errors.HTTPException: handle_http_exception,
            json.JSONDecodeError: handle_json_decode_error,
            commands.BadArgument: handle_bad_argument_error
        }

        if error_type in handler_mapper:
            await handler_mapper[error_type](ctx, root_error)

    @commands.group(name="bot", hidden=True, invoke_without_command=True)
    @command_log
    async def cmd_for_bot_stuff(self, ctx: commands.Context):
        """Command handler for the `bot` command.

        This is a command group regarding everything directly bot related. It provides a variety of subcommands for
        special tasks like rebooting the bot or changing its Discord presence. For every single subcommand administrator
        permissions are needed. If no subcommand has been provided, the corresponding help message will be posted
        instead.

        Args:
            ctx (discord.ext.commands.Context): The context from which this command is invoked.
        """
        await ctx.send_help(ctx.command)

    @cmd_for_bot_stuff.command(name="cogs", hidden=True)
    @command_log
    async def embed_available_cogs(self, _ctx: commands.Context):
        """Command handler for the `bot` subcommand `cogs`.

        Creates an Embed containing a list of all available Cogs and their current status (un-/loaded). This embed will
        then be posted in the configured bot channel.

        Args:
            _ctx (discord.ext.commands.Context): The context from which this command is invoked.
        """
        str_cogs = _create_cogs_embed_string(self.bot.cogs)
        description = "Auflistung sämtlich vorhandener \"Cogs\" des Bots. Die Farbe vor den Namen signalisiert, ob " \
                      "die jeweilige Erweiterung momentan geladen ist oder nicht."

        embed = discord.Embed(title="Verfügbare \"Cogs\"",
                              color=constants.EMBED_COLOR_SYSTEM,
                              description=description,
                              timestamp=datetime.utcnow())
        embed.set_footer(text="Erstellt am")
        embed.add_field(name="Status", value=str_cogs)

        await self.ch_bot.send(embed=embed)

    @cmd_for_bot_stuff.group(name="cog",
                             hidden=True,
                             invoke_without_command=True)
    @command_log
    async def management_cog(self, ctx: commands.Context):
        """Command handler for the `bot` subcommand group `cog`.

        This group contains subcommands for reloading, unloading or simply loading Cogs of the bot.

        Args:
            ctx (discord.ext.commands.Context): The context from which this command is invoked.
        """
        await ctx.send_help(ctx.command)

    @management_cog.command(name='load', hidden=True)
    @command_log
    async def load_extension(self, _ctx: commands.Context, extn_name: str):
        """Command handler for the `bot cog` subcommand `load`.

        Loads an extension (Cog) with the specified name into the bot.

        Args:
            _ctx (discord.ext.commands.Context): The context from which this command is invoked.
            extn_name (str): The name of the extension (Cog).
        """
        extn_name = _get_cog_name(extn_name)

        self.bot.load_extension(constants.INITIAL_EXTNS[extn_name])
        log.warning("%s has been loaded.", extn_name)
        await self.ch_bot.send(
            f":arrow_heading_down: `{extn_name}` has been successfully loaded."
        )

    @management_cog.command(name='unload', hidden=True)
    @command_log
    async def unload_extension(self, _ctx: commands.Context, extn_name: str):
        """Command handler for the `bot cog` subcommand `unload`.

        Removes an extension (Cog) with the specified name from the bot.

        Args:
            _ctx (discord.ext.commands.Context): The context from which this command is invoked.
            extn_name (str): The name of the extension (Cog).
        """
        extn_name = _get_cog_name(extn_name)

        self.bot.unload_extension(constants.INITIAL_EXTNS[extn_name])
        log.warning("%s has been unloaded.", extn_name)
        await self.ch_bot.send(
            f":arrow_heading_up: `{extn_name}` has been successfully unloaded."
        )

    @management_cog.group(name='reload',
                          hidden=True,
                          invoke_without_command=True)
    @command_log
    async def reload_extension(self, _ctx: commands.Context, extn_name: str):
        """Command handler for the `bot cog` subcommand `reload`.

        Reloads an extension (Cog) with the specified name from the bot. If changes to the code inside a Cog have been
        made, this is going to apply them without taking the bot offline.

        Args:
            _ctx (discord.ext.commands.Context): The context from which this command is invoked.
            extn_name (str): The name of the extension (Cog).
        """
        extn_name = _get_cog_name(extn_name)

        self.bot.reload_extension(constants.INITIAL_EXTNS[extn_name])
        log.warning("%s has been reloaded.", extn_name)
        await self.ch_bot.send(
            f":arrows_counterclockwise: `{extn_name}` has been successfully reloaded."
        )

    @reload_extension.command(name='all', hidden=True)
    @command_log
    async def reload_all_extension(self, _ctx: commands.Context):
        """Command handler for the `bot cog reload` subcommand `all`.

        Reloads all the extension (Cogs) from the bot. If changes to the code inside a Cog have been
        made, this is going to apply them without taking the bot offline.

        Args:
            _ctx (discord.ext.commands.Context): The context from which this command is invoked.
        """
        for cog_name, path in constants.INITIAL_EXTNS.items():
            self.bot.reload_extension(path)
            log.warning("%s has been reloaded.", cog_name)

        await self.ch_bot.send(
            ":arrows_counterclockwise: All cogs have been successfully reloaded."
        )

    @load_extension.error
    @unload_extension.error
    @reload_extension.error
    @reload_all_extension.error
    async def management_cog_error(self, _ctx: commands.Context,
                                   error: commands.CommandError):
        """Error handler for the `bot` subcommand group `cog`.

        Special errors occurring during reloading, unloading or loading of a Cog are handled in here.

        Args:
            _ctx (discord.ext.commands.Context): The context from which this command is invoked.
            error (commands.CommandError): The error raised during the execution of a command.
        """
        if isinstance(error, commands.CommandInvokeError) and isinstance(
                error.original, KeyError):
            await self.ch_bot.send(
                "Es konnte leider kein Cog mit diesem Namen gefunden werden.")

    @cmd_for_bot_stuff.group(name="presence", invoke_without_command=True)
    @command_log
    async def change_discord_presence(self, ctx: commands.Context):
        """Command handler for the `bot` subcommand `presence`.

        This is a command group for changing the bots Discord presence. For every user-settable activity type there is
        a corresponding subcommand.

        Args:
            ctx (discord.ext.commands.Context): The context from which this command is invoked.
        """
        await ctx.send_help(ctx.command)

    @change_discord_presence.command(name="watching")
    @command_log
    async def change_discord_presence_watching(
            self,
            ctx: commands.Context,
            status: Optional[discord.Status] = discord.Status.online,
            *,
            activity_name: str):
        """Command handler for the `presence` subcommand `watching`.

        This is a command that changes the bots Discord presence to a watching activity with the specified name. The
        Discord status can also be set via the optional status argument.

        Args:
            ctx (discord.ext.commands.Context): The context from which this command is invoked.
            status (Optional[discord.Status]): The status which should be displayed.
            activity_name (str): The name of whatever the bot should be watching.
        """
        activity = discord.Activity(type=discord.ActivityType.watching,
                                    name=activity_name)
        await self.bot.change_presence(activity=activity, status=status)
        await _notify_presence_change(ctx.channel, ctx.author)

    @change_discord_presence.command(name="listening")
    @command_log
    async def change_discord_presence_listening(
            self,
            ctx: commands.Context,
            status: Optional[discord.Status] = discord.Status.online,
            *,
            activity_name: str):
        """Command handler for the `presence` subcommand `listening`.

        This is a command that changes the bots Discord presence to a listening activity with the specified name. The
        Discord status can also be set via the optional status argument.

        Args:
            ctx (discord.ext.commands.Context): The context from which this command is invoked.
            status (Optional[discord.Status]): The status which should be displayed.
            activity_name (str): The name of what the bot should be listening to.
        """
        activity = discord.Activity(type=discord.ActivityType.listening,
                                    name=activity_name)
        await self.bot.change_presence(activity=activity, status=status)
        await _notify_presence_change(ctx.channel, ctx.author)

    @change_discord_presence.command(name="playing")
    @command_log
    async def change_discord_presence_playing(
            self,
            ctx: commands.Context,
            status: Optional[discord.Status] = discord.Status.online,
            *,
            activity_name: str):
        """Command handler for the `presence` subcommand `playing`.

        This is a command that changes the bots Discord presence to a playing activity with the specified name. The
        Discord status can also be set via the optional status argument.

        Args:
            ctx (discord.ext.commands.Context): The context from which this command is invoked.
            status (Optional[discord.Status]): The status which should be displayed.
            activity_name (str): The name of the game which the bot should play.
        """
        activity = discord.Game(name=activity_name)
        await self.bot.change_presence(activity=activity, status=status)
        await _notify_presence_change(ctx.channel, ctx.author)

    @change_discord_presence.command(name="streaming")
    @command_log
    async def change_discord_presence_streaming(
            self,
            ctx: commands.Context,
            stream_url: str,
            status: Optional[discord.Status] = discord.Status.online,
            *,
            activity_name: str):
        """Command handler for the `presence` subcommand `streaming`.

        This is a command that changes the bots Discord presence to a streaming activity with the specified name and
        stream URL. The Discord status can also be set via the optional status argument.

        Args:
            ctx (discord.ext.commands.Context): The context from which this command is invoked.
            stream_url (str): The URL of the stream. (The watch button will redirect to this link if clicked)
            status (Optional[discord.Status]): The status which should be displayed.
            activity_name (str): The name of whatever the bot should be streaming.
        """
        # Everything other than Twitch probably won't work because of a clientside bug in Discord.
        # More info here: https://github.com/Rapptz/discord.py/issues/5118
        activity = discord.Streaming(name=activity_name, url=stream_url)
        if "twitch" in stream_url:
            activity.platform = "Twitch"
        elif "youtube" in stream_url:
            activity.platform = "YouTube"
        else:
            activity.platform = None

        await self.bot.change_presence(activity=activity, status=status)
        await _notify_presence_change(ctx.channel, ctx.author)

    @change_discord_presence.command(name="clear")
    @command_log
    async def change_discord_presence_clear(self, ctx: commands.Context):
        """Command handler for the `presence` subcommand `clear`.

        This is a command that clears the currently set activity and sets the Discord status to "Online".

        Args:
            ctx (discord.ext.commands.Context): The context from which this command is invoked.
        """
        await self.bot.change_presence(activity=None)
        await _notify_presence_change(ctx.channel, ctx.author)

    @commands.command(name="botonly", hidden=True)
    @command_log
    async def botonly(self, ctx: commands.Context,
                      channel: Optional[discord.TextChannel]):
        """Command handler for the `botonly` command.

        This command marks a channel in the database as bot-only, so every message posted by someone else than the bot
        will be deleted immediately.

        Args:
            ctx (discord.ext.commands.Context): The context from which this command is invoked.
            channel (discord.Textchannel): The channel that is to be made bot-only
        """
        target_channel = channel if channel is not None else ctx.channel
        is_channel_botonly = self._db_connector.is_botonly(target_channel.id)

        if is_channel_botonly:
            log.info("Deactivated bot-only mode for channel [#%s]",
                     target_channel)
            self._db_connector.deactivate_botonly(target_channel.id)
        else:
            log.info("Activated bot-only mode for channel [#%s]",
                     target_channel)
            self._db_connector.activate_botonly(target_channel.id)

        is_enabled_string = 'aktiviert' if not is_channel_botonly else 'deaktiviert'
        embed = _build_botonly_embed(is_enabled_string)
        await target_channel.send(embed=embed)

    @commands.Cog.listener(name='on_message')
    async def on_message(self, message: discord.Message):
        """Event Handler for new messages.

        Deletes a message if the channel it was posted in is in bot-only mode and the author isn't SAM.

        Args:
            message (discord.Message): The context this method was called in. Must always be a message.
        """
        if not message.author.bot and self._db_connector.is_botonly(
                message.channel.id):
            await message.delete()
Exemple #11
0
class FeedbackCog(commands.Cog):
    """Cog for Feedback Functions."""
    def __init__(self, bot):
        """Initializes the Cog.

        Args:
            bot (discord.ext.commands.Bot): The bot for which this cog should be enabled.
        """
        self.bot = bot
        self._db_connector = DatabaseConnector(const.DB_FILE_PATH,
                                               const.DB_INIT_SCRIPT)

        # Channel instances
        self.ch_suggestion = bot.get_guild(int(const.SERVER_ID)).get_channel(
            int(const.CHANNEL_ID_SUGGESTIONS))

    @commands.group(name="suggestion",
                    invoke_without_command=True,
                    aliases=["suggest"])
    @command_log
    async def manage_suggestions(self, ctx: commands.Context, *,
                                 suggestion: str):
        """Command Handler for the `suggestion` command.

        Allows users to submit suggestions for improving the server. After submitting, an embed containing the provided
        information will be posted in the configured suggestion channel by SAM.
        A suggestion can be marked as "approved", "denied", "considered" or "implemented". For each of these statuses
        exists a corresponding subcommand.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            suggestion (str): The suggestion provided by the user.
        """
        if not self._db_connector.is_botonly(ctx.channel.id):
            await ctx.message.delete()

        suggestion_id = self._db_connector.add_suggestion(
            ctx.author.id, ctx.message.created_at)

        embed = _build_suggestion_embed(ctx.author, suggestion, suggestion_id)
        message = await self.ch_suggestion.send(embed=embed)

        self._db_connector.set_suggestion_message_id(suggestion_id, message.id)

        await message.add_reaction(const.EMOJI_UPVOTE)
        await message.add_reaction(const.EMOJI_DOWNVOTE)

    @manage_suggestions.command(name="approve", hidden=True)
    @commands.has_role(int(const.ROLE_ID_MODERATOR))
    @command_log
    async def suggestion_approve(self, ctx: commands.Context,
                                 suggestion_id: int, *, reason: Optional[str]):
        """Command Handler for the `suggestion` subcommand `approve`.

        Allows the owner to mark a suggestion as approved and add an optional reason to it. If this is the case, the
        corresponding embed will be adapted to represent this change and the user who submitted the idea will be
        notified via DM.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            suggestion_id (int): The id of the suggestion which should be approved.
            reason (Optional[str]): The reason for this decision.
        """
        if not self._db_connector.is_botonly(ctx.channel.id):
            await ctx.message.delete()

        await self._change_suggestion_status(suggestion_id,
                                             SuggestionStatus.APPROVED,
                                             ctx.author, reason)
        log.info("Suggestion #%s has been approved by %s.", suggestion_id,
                 ctx.author)

    @manage_suggestions.command(name="deny", hidden=True)
    @commands.has_role(int(const.ROLE_ID_MODERATOR))
    @command_log
    async def suggestion_deny(self, ctx: commands.Context, suggestion_id: int,
                              *, reason: Optional[str]):
        """Command Handler for the `suggestion` subcommand `deny`.

        Allows the owner to mark a suggestion as denied and add an optional reason to it. If this is the case, the
        corresponding embed will be adapted to represent this change and the user who submitted the idea will be
        notified via DM.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            suggestion_id (int): The id of the suggestion which should be approved.
            reason (Optional[str]): The reason for the decision.
        """
        if not self._db_connector.is_botonly(ctx.channel.id):
            await ctx.message.delete()

        await self._change_suggestion_status(suggestion_id,
                                             SuggestionStatus.DENIED,
                                             ctx.author, reason)
        log.info("Suggestion #%s has been denied by %s.", suggestion_id,
                 ctx.author)

    @manage_suggestions.command(name="consider", hidden=True)
    @commands.has_role(int(const.ROLE_ID_MODERATOR))
    @command_log
    async def suggestion_consider(self, ctx: commands.Context,
                                  suggestion_id: int, *,
                                  reason: Optional[str]):
        """Command Handler for the `suggestion` subcommand `consider`.

        Allows the owner to mark a suggestion as considered and add an optional reason to it. If this is the case, the
        corresponding embed will be adapted to represent this change and the user who submitted the idea will be
        notified via DM.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            suggestion_id (int): The id of the suggestion which should be approved.
            reason (Optional[str]): The reason for the decision.
        """
        if not self._db_connector.is_botonly(ctx.channel.id):
            await ctx.message.delete()

        await self._change_suggestion_status(suggestion_id,
                                             SuggestionStatus.CONSIDERED,
                                             ctx.author, reason)
        log.info("Suggestion #%s is being considered by %s.", suggestion_id,
                 ctx.author)

    @manage_suggestions.command(name="implemented", hidden=True)
    @commands.has_role(int(const.ROLE_ID_MODERATOR))
    @command_log
    async def suggestion_implemented(self, ctx: commands.Context,
                                     suggestion_id: int, *,
                                     reason: Optional[str]):
        """Command Handler for the `suggestion` subcommand `implemented`.

        Allows the owner to mark a suggestion as implemented  and add an optional reason to it. If this is the case, the
        corresponding embed will be adapted to represent this change and the user who submitted the idea will be
        notified via DM.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            suggestion_id (int): The id of the suggestion which should be approved.
            reason (Optional[str]): The reason for the decision.
        """
        if not self._db_connector.is_botonly(ctx.channel.id):
            await ctx.message.delete()

        await self._change_suggestion_status(suggestion_id,
                                             SuggestionStatus.IMPLEMENTED,
                                             ctx.author, reason)
        log.info("Suggestion #%s marked as implemented by %s.", suggestion_id,
                 ctx.author)

    @suggestion_approve.error
    @suggestion_deny.error
    @suggestion_consider.error
    @suggestion_implemented.error
    async def suggestion_error(self, ctx: commands.Context,
                               error: commands.CommandError):
        """Error Handler for the `suggestion` subcommands `approve`, `deny`, `consider` and `implemented`.

        Handles specific exceptions which occur during the execution of this command. The global error handler will
        still be called for every error thrown.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            error (commands.CommandError): The error raised during the execution of the command.
        """
        if isinstance(error, commands.BadArgument):
            await ctx.send(
                "Ich konnte leider keinen Vorschlag mit der von dir angegebenen Nummer finden. :frowning2:",
                delete_after=const.TIMEOUT_INFORMATION)

    async def _change_suggestion_status(self, suggestion_id: int,
                                        status: SuggestionStatus,
                                        author: discord.Member,
                                        reason: Optional[str]):
        """Method which changes the status of a suggestion.

        Changes the status of the suggestion in the db, adapts the corresponding embed in the suggestion channel to
        represent the newly made changes and finally notifies the user who submitted it via DM.

        Args:
            status (SuggestionStatus): The new status of the suggestion.
            author (discord.Member): The user who invoked the command to change it.
            suggestion_id (int): The id of the suggestion.
            reason (Optional[str]): The reason for the decision.
        """
        id_exists = self._db_connector.set_suggestion_status(
            suggestion_id, status)
        if not id_exists:
            raise commands.BadArgument(
                "The suggestion with the specified ID doesn't exist.")

        suggestion_data = self._db_connector.get_suggestion(suggestion_id)
        message = await self.ch_suggestion.fetch_message(suggestion_data[0])

        await _refresh_suggestion_embed(message, author, reason,
                                        SuggestionStatus(suggestion_data[1]))
        await self._notify_user_suggestion_change(int(suggestion_data[2]))

    @commands.Cog.listener(name='on_raw_reaction_add')
    async def suggestion_reaction_add(self,
                                      payload: discord.RawReactionActionEvent):
        """Event listener which triggers if a reaction has been added by a user.

        If the affected message is in the configured suggestion channel and the added reaction is one of the two vote
        emojis specified in constants.py, the color of the suggestion embed will be changed if enough up- or downvotes
        have been submitted.

        Args:
            payload (discord.RawReactionActionEvent): The payload for the triggered event.
        """
        if payload.channel_id == self.ch_suggestion.id and not payload.member.bot and \
                self._db_connector.get_suggestion_status(payload.message_id) == SuggestionStatus.UNDECIDED:
            message = await self.ch_suggestion.fetch_message(payload.message_id
                                                             )
            reactions = message.reactions

            if payload.emoji.name in (const.EMOJI_UPVOTE,
                                      const.EMOJI_DOWNVOTE):
                required_difference = (reactions[0].count +
                                       reactions[1].count) / 2
                actual_difference = abs(reactions[0].count -
                                        reactions[1].count)

                # Changes the color of the embed depending if one side received at least 10 votes and the difference
                # between them is bigger than 50% of total votes.
                if reactions[0].count > int(const.LIMIT_SUGGESTION_VOTES) and \
                        reactions[0].count > reactions[1].count and actual_difference > required_difference:
                    new_embed = _recolor_embed(
                        message.embeds[0],
                        const.EMBED_COLOR_SUGGESTION_MEMBERS_LIKE)
                    await message.edit(embed=new_embed)
                elif reactions[1].count > int(const.LIMIT_SUGGESTION_VOTES) and \
                        actual_difference > required_difference:
                    new_embed = _recolor_embed(
                        message.embeds[0],
                        const.EMBED_COLOR_SUGGESTION_MEMBERS_DISLIKE)
                    await message.edit(embed=new_embed)

    async def _notify_user_suggestion_change(self, user_id: int):
        """Method which notifies a user about changes regarding his suggestion.

        Gets the corresponding Discord member and sends a personalized DM to him informing him about changes regarding
        his suggestion.

        Args:
            user_id (int): The id of the user who submitted the suggestion.
        """
        member = self.bot.get_guild(int(const.SERVER_ID)).get_member(user_id)
        text = "Hey, {0}!\nEs gibt Neuigkeiten bezüglich deines Vorschlags. Sieh am besten gleich in {1} nach, wie " \
               "das Urteil ausgefallen ist. :fingers_crossed:".format(member.display_name, self.ch_suggestion.mention)

        await member.send(text)
Exemple #12
0
class CommunityCog(commands.Cog):
    """Cog for Community Functions."""

    def __init__(self, bot):
        """Initializes the Cog.

        Args:
            bot (discord.ext.commands.Bot): The bot for which this cog should be enabled.
        """
        self._db_connector = DatabaseConnector(const.DB_FILE_PATH, const.DB_INIT_SCRIPT)

        # Channel Category instances
        self.cat_gaming_rooms = bot.get_guild(int(const.SERVER_ID)).get_channel(int(const.CATEGORY_ID_GAMING_ROOMS))
        self.cat_study_rooms = bot.get_guild(int(const.SERVER_ID)).get_channel(int(const.CATEGORY_ID_STUDY_ROOMS))

    @commands.command(name='studyroom', aliases=["sr"])
    @command_log
    async def create_study_room(self, ctx: commands.Context, ch_name: Optional[str], user_limit: Optional[int]):
        """Command Handler for the `studyroom` command.

        Allows users to create temporary "Study Rooms" consisting of a voice and text channel in the configured study
        room category on the server. The member who created the room gets special permissions for muting/deafening
        members in the voice channel. If no members are left in the voice channel, it, as well as the corresponding text
        channel, will be automatically deleted.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            ch_name (Optional[str]): The name of the channel provided by the member.
            user_limit (int): The user limit for the voice channel provided by the member.
        """
        if not self._db_connector.is_botonly(ctx.channel.id):
            await ctx.message.delete()

        await self.create_community_room(ctx, self.cat_study_rooms, ch_name, user_limit)

    @commands.command(name='gameroom', aliases=["gr"])
    @command_log
    async def create_gaming_room(self, ctx: commands.Context, ch_name: Optional[str], user_limit: Optional[int]):
        """Command Handler for the `gameroom` command.

        Allows users to create temporary "Game Rooms" consisting of a voice and text channel in the configured game room
        category on the server. The member who created the room gets special permissions for muting/deafening members
        in the voice channel. If no members are left in the voice channel, it, as well as the corresponding text
        channel, will be automatically deleted.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            ch_name (Optional[str]): The name of the channel provided by the member.
            user_limit (int): The user limit for the voice channel provided by the member.
        """
        if not self._db_connector.is_botonly(ctx.channel.id):
            await ctx.message.delete()

        await self.create_community_room(ctx, self.cat_gaming_rooms, ch_name, user_limit)

    @create_gaming_room.error
    @create_study_room.error
    async def community_room_error(self, ctx: commands.Context, error: commands.CommandError):
        """Error Handler for the community room commands.

        Handles the exceptions which could occur during the execution of said command.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            error (commands.CommandError): The error raised during the execution of the command.
        """
        if isinstance(error, commands.CommandInvokeError) and isinstance(error.original, NotImplementedError):
            await ctx.send("Bitte lösche zuerst deinen bestehenden Study/Game Room, bevor du einen weiteren erstellst.",
                           delete_after=const.TIMEOUT_INFORMATION)
        elif isinstance(error, commands.CommandInvokeError) and isinstance(error.original, RuntimeWarning):
            await ctx.send("Es gibt zurzeit zu viele aktive Räume in dieser Kategorie. Bitte versuche es später noch "
                           "einmal. :hourglass:", delete_after=const.TIMEOUT_INFORMATION)
        elif isinstance(error, commands.CommandInvokeError) and isinstance(error.original, discord.InvalidArgument):
            await ctx.send("Das Nutzer-Limit für einen Sprachkanal muss zwischen 1 und 99 liegen. Bitte versuche es "
                           "noch einmal.", delete_after=const.TIMEOUT_INFORMATION)

    async def create_community_room(self, ctx: commands.Context, ch_category: discord.CategoryChannel,
                                    ch_name: Optional[str], user_limit: Optional[int]):
        """Method which creates a temporary community room requested via the study/game room commands.

        Additionally it validates the configured limits (max. amount of community rooms, valid user limit, one room
        per member) and raises an exception if needed.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            ch_name (Optional[str]): The name of the channel provided by the member.
            user_limit (int): The user limit for the voice channel provided by the member.
        """
        if len(ch_category.voice_channels) >= const.LIMIT_COMMUNITY_CHANNELS:
            raise RuntimeWarning("Too many Community Rooms of this kind at the moment.")
        if user_limit and (user_limit < 1 or user_limit > 99):
            raise discord.InvalidArgument("User limit cannot be outside range from 1 to 99.")
        if any(True for ch in self.cat_gaming_rooms.voice_channels if ctx.author in ch.overwrites) or \
           any(True for ch in self.cat_study_rooms.voice_channels if ctx.author in ch.overwrites):
            raise NotImplementedError("Member already has an active Community Room.")

        limit: Optional[int]
        if ch_name and not user_limit:
            try:
                limit = int(ch_name)
                name = f"{ctx.author.display_name}'s Room"
            except ValueError:
                limit = None
                name = ch_name
        else:
            name = f"{ctx.author.display_name}'s Room" if ch_name is None else ch_name
            limit = user_limit

        # Remove channel number if user has added it himself.
        regex = re.search(r"(\[#\d+])", name)
        if regex:
            name = name.replace(regex.group(1), "")

        ch_number_addition = _determine_channel_number(ch_category, name)
        if ch_number_addition:
            name += ch_number_addition

        if len(name) > 100:
            name = name[:100]

        reason = f"Manuell erstellt von {ctx.author} via SAM."

        # Voice Channel
        bitrate = 96000  # 96 Kbit/s
        overwrites_voice = ch_category.overwrites
        overwrites_voice[ctx.author] = discord.PermissionOverwrite(priority_speaker=True, move_members=True,
                                                                   mute_members=True, deafen_members=True)
        await ch_category.create_voice_channel(name=name, user_limit=limit, bitrate=bitrate,
                                               overwrites=overwrites_voice, reason=reason)

        # Text Channel
        channel_type = "Game" if ch_category == self.cat_gaming_rooms else "Study"
        topic = f"Temporärer {channel_type}-Channel. || Erstellt von: {ctx.author.display_name}"
        await ch_category.create_text_channel(name=name, topic=topic, reason=reason)

        log.info("Temporary %s Room created by %s", channel_type, ctx.author)
        await ctx.send(f":white_check_mark: Der {channel_type}-Room wurde erfolgreich erstellt!",
                       delete_after=const.TIMEOUT_INFORMATION)

    @commands.Cog.listener(name='on_voice_state_update')
    async def delete_community_room(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState):
        """Event listener which triggers if the VoiceState of a member changes.

        Deletes a Community Room (consisting of a voice and text channel) if no members are left in the corresponding
        voice channel.

        Args:
            member (discord.Member): The member whose VoiceState changed.
            before (discord.VoiceState): The previous VoiceState.
            after (discord.VoiceState): The new VoiceState.
        """
        if before.channel and before.channel.category_id in {self.cat_gaming_rooms.id, self.cat_study_rooms.id} and \
           before.channel != after.channel and len(before.channel.members) == 0:
            reason = "No one was left in Community Room."
            txt_ch_name = re.sub(r"[^\w\s-]", "", before.channel.name.lower())  # Remove non-word chars except WS
            txt_ch_name = re.sub(r"\s", "-", txt_ch_name)                       # Replace whitespaces with "-"
            txt_ch = next(ch for ch in before.channel.category.text_channels if ch.name == txt_ch_name)

            await before.channel.delete(reason=reason)
            await txt_ch.delete(reason=reason)
            log.info("Empty Community Room [%s] has been automatically deleted.", before.channel.name)
Exemple #13
0
class GamingCog(commands.Cog):
    """Cog for Gaming Functions."""
    def __init__(self, bot):
        """Initializes the Cog.

        Args:
            bot (discord.ext.commands.Bot): The bot for which this cog should be enabled.
        """
        self.bot = bot
        self._db_connector = DatabaseConnector(const.DB_FILE_PATH,
                                               const.DB_INIT_SCRIPT)

        # Channel Category instances
        self.cat_gaming_channels = bot.get_guild(int(
            const.SERVER_ID)).get_channel(int(const.CATEGORY_ID_GAMING_ROOMS))

    @commands.command(name='gameroom', aliases=["gr"])
    @command_log
    async def create_gaming_room(self, ctx: commands.Context,
                                 ch_name: Optional[str],
                                 user_limit: Optional[int]):
        """Command Handler for the `gameroom` command.

        Allows users to create temporary "Game Rooms" consisting of a voice and text channel in the configured game room
        category on the server. The member who created the room gets permissions for pinning/deleting messages in the
        text channel and for muting/deafening members in the voice channel.
        If no members are left in the voice channel, it will be deleted as well as the corresponding text channel.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            ch_name (Optional[str]): The name of the channel provided by the member.
            user_limit (int): The user limit for the voice channel provided by the member.
        """
        if not self._db_connector.is_botonly(ctx.channel.id):
            await ctx.message.delete()

        if len(self.cat_gaming_channels.channels
               ) >= const.LIMIT_GAMING_CHANNELS:
            raise RuntimeWarning("Too many game rooms at the moment.")
        if user_limit and (user_limit < 1 or user_limit > 99):
            raise discord.InvalidArgument(
                "User limit cannot be outside range from 1 to 99.")
        if any(True for ch in self.cat_gaming_channels.voice_channels
               if ctx.author in ch.overwrites):
            raise NotImplementedError(
                "Member already has an active Game Room.")

        limit: Optional[int]
        if ch_name and not user_limit:
            try:
                limit = int(ch_name)
                name = f"{ctx.author.display_name}'s Room"
            except ValueError:
                limit = None
                name = ch_name
        else:
            name = f"{ctx.author.display_name}'s Room" if ch_name is None else ch_name
            limit = user_limit

        # Remove channel number if user has added it himself.
        regex = re.search(r"(\[#\d+])", name)
        if regex:
            name = name.replace(regex.group(1), "")

        ch_number_addition = self._determine_channel_number(name)
        if ch_number_addition:
            name += ch_number_addition

        if len(name) > 100:
            name = name[:100]

        reason = f"Manuell erstellt von {ctx.author} via SAM."

        # Voice Channel
        bitrate = 96000  # 96 Kbit/s
        overwrites_voice = self.cat_gaming_channels.overwrites
        overwrites_voice[ctx.author] = discord.PermissionOverwrite(
            priority_speaker=True,
            move_members=True,
            mute_members=True,
            deafen_members=True)
        await self.cat_gaming_channels.create_voice_channel(
            name=name,
            user_limit=limit,
            bitrate=bitrate,
            overwrites=overwrites_voice,
            reason=reason)

        # Text Channel
        topic = f"Temporärer Gaming-Kanal. || Erstellt von: {ctx.author.display_name}"
        await self.cat_gaming_channels.create_text_channel(name=name,
                                                           topic=topic,
                                                           reason=reason)

        log.info("Temporary Game Room created by %s", ctx.author)
        await ctx.send(
            ":white_check_mark: Der Game-Room wurde erfolgreich erstellt!",
            delete_after=const.TIMEOUT_INFORMATION)

    @create_gaming_room.error
    async def gaming_room_error(self, ctx: commands.Context,
                                error: commands.CommandError):
        """Error Handler for the command `gameroom`.

        Handles the exceptions which could occur during the execution of this command.

        Args:
            ctx (discord.ext.commands.Context): The context in which the command was called.
            error (commands.CommandError): The error raised during the execution of the command.
        """
        if isinstance(error, commands.CommandInvokeError) and isinstance(
                error.original, NotImplementedError):
            await ctx.send(
                "Bitte lösche zuerst deinen bestehenden Game Room, bevor du einen neuen erstellst.",
                delete_after=const.TIMEOUT_INFORMATION)
        elif isinstance(error, commands.CommandInvokeError) and isinstance(
                error.original, RuntimeWarning):
            await ctx.send(
                "Es gibt zurzeit zu viele aktive Game Rooms. Bitte versuche es später noch einmal. "
                ":hourglass:",
                delete_after=const.TIMEOUT_INFORMATION)
        elif isinstance(error, commands.CommandInvokeError) and isinstance(
                error.original, discord.InvalidArgument):
            await ctx.send(
                "Das Nutzer-Limit für einen Sprachkanal muss zwischen 1 und 99 liegen. Bitte versuche es "
                "noch einmal.",
                delete_after=const.TIMEOUT_INFORMATION)

    def _determine_channel_number(self, name: str) -> Optional[str]:
        """Method which creates a string representing a channel number if needed.

        If a member tries to create a Game Room with a name that is currently being used, this method will determine
        the next free numeration and returns a corresponding string which will then be added to the channel name.
        If the name chosen is unique, `None` will be returned instead.

        Args:
            name (str): The channel name chosen by the member.

        Returns:
            Optional[str]: A string representing the next free numeration for a channel with the specified name.
        """
        channels = self.cat_gaming_channels.voice_channels

        try:
            existing_name = next(ch.name for ch in reversed(channels)
                                 if name in ch.name)
            regex = re.search(
                r"\[#(\d+)]",
                existing_name)  # Regex for getting channel number.
            ch_number = int(regex.group(1)) + 1 if regex else "2"

        except StopIteration:
            return None

        return f" [#{ch_number}]"

    @commands.Cog.listener(name='on_voice_state_update')
    async def delete_gaming_room(self, member: discord.Member,
                                 before: discord.VoiceState,
                                 after: discord.VoiceState):
        """Event listener which triggers if the VoiceState of a member changes.

        Deletes a Game Rome (consisting of a voice and text channel) if no members are left in the corresponding voice
        channel.

        Args:
            member (discord.Member): The member whose VoiceState changed.
            before (discord.VoiceState): The previous VoiceState.
            after (discord.VoiceState): The new VoiceState.
        """
        if before.channel and before.channel.category_id == self.cat_gaming_channels.id \
          and before.channel != after.channel and len(before.channel.members) == 0:
            reason = "No one was left in Game Room."
            txt_ch_name = re.sub(r"[^\w\s-]", "", before.channel.name.lower()
                                 )  # Remove non-word chars except whitespaces
            txt_ch_name = re.sub(r"\s", "-",
                                 txt_ch_name)  # Replace whitespaces with "-"
            txt_ch = next(ch for ch in self.cat_gaming_channels.text_channels
                          if ch.name == txt_ch_name)

            await before.channel.delete(reason=reason)
            await txt_ch.delete(reason=reason)
            log.info("Empty Game Room [%s] has been automatically deleted.",
                     before.channel.name)