Пример #1
0
 async def cog_check(self, ctx: Context) -> bool:
     """Only allow admins inside moderator channels to invoke the commands in this cog."""
     checks = [
         await has_any_role(Roles.admins).predicate(ctx),
         is_mod_channel(ctx.channel)
     ]
     return all(checks)
Пример #2
0
 async def cog_check(self, ctx: Context) -> bool:
     """Only allow moderators inside moderator channels to invoke the commands in this cog."""
     checks = [
         await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx),
         is_mod_channel(ctx.channel)
     ]
     return all(checks)
Пример #3
0
    async def server_info(self, ctx: Context) -> None:
        """Returns an embed full of server information."""
        embed = Embed(colour=Colour.blurple(), title="Server Information")

        created = time_since(ctx.guild.created_at, precision="days")
        region = ctx.guild.region
        num_roles = len(ctx.guild.roles) - 1  # Exclude @everyone

        # Server Features are only useful in certain channels
        if ctx.channel.id in (*constants.MODERATION_CHANNELS,
                              constants.Channels.dev_core,
                              constants.Channels.dev_contrib):
            features = f"\nFeatures: {', '.join(ctx.guild.features)}"
        else:
            features = ""

        # Member status
        py_invite = await self.bot.fetch_invite(constants.Guild.invite)
        online_presences = py_invite.approximate_presence_count
        offline_presences = py_invite.approximate_member_count - online_presences
        member_status = (
            f"{constants.Emojis.status_online} {online_presences} "
            f"{constants.Emojis.status_offline} {offline_presences}")

        embed.description = textwrap.dedent(f"""
            Created: {created}
            Voice region: {region}\
            {features}
            Roles: {num_roles}
            Member status: {member_status}
        """)
        embed.set_thumbnail(url=ctx.guild.icon_url)

        # Members
        total_members = ctx.guild.member_count
        member_counts = self.get_member_counts(ctx.guild)
        member_info = "\n".join(f"{role}: {count}"
                                for role, count in member_counts.items())
        embed.add_field(name=f"Members: {total_members}", value=member_info)

        # Channels
        total_channels = len(ctx.guild.channels)
        channel_counts = self.get_channel_type_counts(ctx.guild)
        channel_info = "\n".join(
            f"{channel.title()}: {count}"
            for channel, count in sorted(channel_counts.items()))
        embed.add_field(name=f"Channels: {total_channels}", value=channel_info)

        # Additional info if ran in moderation channels
        if is_mod_channel(ctx.channel):
            embed.add_field(name="Moderation:",
                            value=self.get_extended_server_info(ctx))

        await ctx.send(embed=embed)
Пример #4
0
 async def _delete_invocation(self, ctx: Context) -> None:
     """Delete the command invocation if it's not in a mod channel."""
     if not is_mod_channel(ctx.channel):
         self.mod_log.ignore(Event.message_delete, ctx.message.id)
         try:
             await ctx.message.delete()
         except errors.NotFound:
             # Invocation message has already been deleted
             log.info(
                 "Tried to delete invocation message, but it was already deleted."
             )
Пример #5
0
class Information(Cog):
    """A cog with commands for generating embeds with server info, such as server stats and user info."""

    def __init__(self, bot: Bot):
        self.bot = bot

    @staticmethod
    def role_can_read(channel: GuildChannel, role: Role) -> bool:
        """Return True if `role` can read messages in `channel`."""
        overwrites = channel.overwrites_for(role)
        return overwrites.read_messages is True

    def get_staff_channel_count(self, guild: Guild) -> int:
        """
        Get the number of channels that are staff-only.

        We need to know two things about a channel:
        - Does the @everyone role have explicit read deny permissions?
        - Do staff roles have explicit read allow permissions?

        If the answer to both of these questions is yes, it's a staff channel.
        """
        channel_ids = set()
        for channel in guild.channels:
            if channel.type is ChannelType.category:
                continue

            everyone_can_read = self.role_can_read(channel, guild.default_role)

            for role in constants.STAFF_ROLES:
                role_can_read = self.role_can_read(channel, guild.get_role(role))
                if role_can_read and not everyone_can_read:
                    channel_ids.add(channel.id)
                    break

        return len(channel_ids)

    @staticmethod
    def get_channel_type_counts(guild: Guild) -> str:
        """Return the total amounts of the various types of channels in `guild`."""
        channel_counter = Counter(c.type for c in guild.channels)
        channel_type_list = []
        for channel, count in channel_counter.items():
            channel_type = str(channel).title()
            channel_type_list.append(f"{channel_type} channels: {count}")

        channel_type_list = sorted(channel_type_list)
        return "\n".join(channel_type_list)

    @has_any_role(*constants.STAFF_ROLES)
    @command(name="roles")
    async def roles_info(self, ctx: Context) -> None:
        """Returns a list of all roles and their corresponding IDs."""
        # Sort the roles alphabetically and remove the @everyone role
        roles = sorted(ctx.guild.roles[1:], key=lambda role: role.name)

        # Build a list
        role_list = []
        for role in roles:
            role_list.append(f"`{role.id}` - {role.mention}")

        # Build an embed
        embed = Embed(
            title=f"Role information (Total {len(roles)} role{'s' * (len(role_list) > 1)})",
            colour=Colour.blurple()
        )

        await LinePaginator.paginate(role_list, ctx, embed, empty=False)

    @has_any_role(*constants.STAFF_ROLES)
    @command(name="role")
    async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None:
        """
        Return information on a role or list of roles.

        To specify multiple roles just add to the arguments, delimit roles with spaces in them using quotation marks.
        """
        parsed_roles = []
        failed_roles = []

        for role_name in roles:
            if isinstance(role_name, Role):
                # Role conversion has already succeeded
                parsed_roles.append(role_name)
                continue

            role = utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles)

            if not role:
                failed_roles.append(role_name)
                continue

            parsed_roles.append(role)

        if failed_roles:
            await ctx.send(f":x: Could not retrieve the following roles: {', '.join(failed_roles)}")

        for role in parsed_roles:
            h, s, v = colorsys.rgb_to_hsv(*role.colour.to_rgb())

            embed = Embed(
                title=f"{role.name} info",
                colour=role.colour,
            )
            embed.add_field(name="ID", value=role.id, inline=True)
            embed.add_field(name="Colour (RGB)", value=f"#{role.colour.value:0>6x}", inline=True)
            embed.add_field(name="Colour (HSV)", value=f"{h:.2f} {s:.2f} {v}", inline=True)
            embed.add_field(name="Member count", value=len(role.members), inline=True)
            embed.add_field(name="Position", value=role.position)
            embed.add_field(name="Permission code", value=role.permissions.value, inline=True)

            await ctx.send(embed=embed)

    @command(name="server", aliases=["server_info", "guild", "guild_info"])
    async def server_info(self, ctx: Context) -> None:
        """Returns an embed full of server information."""
        created = time_since(ctx.guild.created_at, precision="days")
        features = ", ".join(ctx.guild.features)
        region = ctx.guild.region

        roles = len(ctx.guild.roles)
        member_count = ctx.guild.member_count
        channel_counts = self.get_channel_type_counts(ctx.guild)

        # How many of each user status?
        py_invite = await self.bot.fetch_invite(constants.Guild.invite)
        online_presences = py_invite.approximate_presence_count
        offline_presences = py_invite.approximate_member_count - online_presences
        embed = Embed(colour=Colour.blurple())

        # How many staff members and staff channels do we have?
        staff_member_count = len(ctx.guild.get_role(constants.Roles.helpers).members)
        staff_channel_count = self.get_staff_channel_count(ctx.guild)

        # Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the
        # f-string. While this is correctly formatted by Discord, it makes unit testing difficult. To keep the
        # formatting without joining a tuple of strings we can use a Template string to insert the already-formatted
        # channel_counts after the dedent is made.
        embed.description = Template(
            textwrap.dedent(f"""
                **Server information**
                Created: {created}
                Voice region: {region}
                Features: {features}

                **Channel counts**
                $channel_counts
                Staff channels: {staff_channel_count}

                **Member counts**
                Members: {member_count:,}
                Staff members: {staff_member_count}
                Roles: {roles}

                **Member statuses**
                {constants.Emojis.status_online} {online_presences:,}
                {constants.Emojis.status_offline} {offline_presences:,}
            """)
        ).substitute({"channel_counts": channel_counts})
        embed.set_thumbnail(url=ctx.guild.icon_url)

        await ctx.send(embed=embed)

    @command(name="user", aliases=["user_info", "member", "member_info"])
    async def user_info(self, ctx: Context, user: FetchedMember = None) -> None:
        """Returns info about a user."""
        if user is None:
            user = ctx.author

        # Do a role check if this is being executed on someone other than the caller
        elif user != ctx.author and await has_no_roles_check(ctx, *constants.MODERATION_ROLES):
            await ctx.send("You may not use this command on users other than yourself.")
            return

        # Will redirect to #bot-commands if it fails.
        if in_whitelist_check(ctx, roles=constants.STAFF_ROLES):
            embed = await self.create_user_embed(ctx, user)
            await ctx.send(embed=embed)

    async def create_user_embed(self, ctx: Context, user: FetchedMember) -> Embed:
        """Creates an embed containing information on the `user`."""
        on_server = bool(ctx.guild.get_member(user.id))

        created = time_since(user.created_at, max_units=3)

        name = str(user)
        if on_server and user.nick:
            name = f"{user.nick} ({name})"

        badges = []

        for badge, is_set in user.public_flags:
            if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)):
                badges.append(emoji)

        activity = await self.user_messages(user)

        if on_server:
            joined = time_since(user.joined_at, max_units=3)
            roles = ", ".join(role.mention for role in user.roles[1:])
            membership = {"Joined": joined, "Pending": user.pending, "Roles": roles or None}
            if not is_mod_channel(ctx.channel):
                membership.pop("Pending")

            membership = textwrap.dedent("\n".join([f"{key}: {value}" for key, value in membership.items()]))
        else:
            roles = None
            membership = "The user is not a member of the server"

        fields = [
            (
                "User information",
                textwrap.dedent(f"""
                    Created: {created}
                    Profile: {user.mention}
                    ID: {user.id}
                """).strip()
            ),
            (
                "Member information",
                membership
            ),
        ]

        # Show more verbose output in moderation channels for infractions and nominations
        if is_mod_channel(ctx.channel):
            fields.append(activity)

            fields.append(await self.expanded_user_infraction_counts(user))
            fields.append(await self.user_nomination_counts(user))
        else:
            fields.append(await self.basic_user_infraction_counts(user))

        # Let's build the embed now
        embed = Embed(
            title=name,
            description=" ".join(badges)
        )

        for field_name, field_content in fields:
            embed.add_field(name=field_name, value=field_content, inline=False)

        embed.set_thumbnail(url=user.avatar_url_as(static_format="png"))
        embed.colour = user.top_role.colour if roles else Colour.blurple()

        return embed
Пример #6
0
    async def apply_infraction(
        self,
        ctx: Context,
        infraction: _utils.Infraction,
        user: UserSnowflake,
        action_coro: t.Optional[t.Awaitable] = None,
        user_reason: t.Optional[str] = None,
        additional_info: str = "",
    ) -> bool:
        """
        Apply an infraction to the user, log the infraction, and optionally notify the user.

        `user_reason`, if provided, will be sent to the user in place of the infraction reason.
        `additional_info` will be attached to the text field in the mod-log embed.

        Returns whether or not the infraction succeeded.
        """
        infr_type = infraction["type"]
        icon = _utils.INFRACTION_ICONS[infr_type][0]
        reason = infraction["reason"]
        expiry = time.format_infraction_with_duration(infraction["expires_at"])
        id_ = infraction['id']

        if user_reason is None:
            user_reason = reason

        log.trace(f"Applying {infr_type} infraction #{id_} to {user}.")

        # Default values for the confirmation message and mod log.
        confirm_msg = ":ok_hand: applied"

        # Specifying an expiry for a note or warning makes no sense.
        if infr_type in ("note", "warning"):
            expiry_msg = ""
        else:
            expiry_msg = f" until {expiry}" if expiry else " permanently"

        dm_result = ""
        dm_log_text = ""
        expiry_log_text = f"\nExpires: {expiry}" if expiry else ""
        log_title = "applied"
        log_content = None
        failed = False

        # DM the user about the infraction if it's not a shadow/hidden infraction.
        # This needs to happen before we apply the infraction, as the bot cannot
        # send DMs to user that it doesn't share a guild with. If we were to
        # apply kick/ban infractions first, this would mean that we'd make it
        # impossible for us to deliver a DM. See python-discord/bot#982.
        if not infraction["hidden"]:
            dm_result = f"{constants.Emojis.failmail} "
            dm_log_text = "\nDM: **Failed**"

            # Sometimes user is a discord.Object; make it a proper user.
            try:
                if not isinstance(user, (discord.Member, discord.User)):
                    user = await self.bot.fetch_user(user.id)
            except discord.HTTPException as e:
                log.error(
                    f"Failed to DM {user.id}: could not fetch user (status {e.status})"
                )
            else:
                # Accordingly display whether the user was successfully notified via DM.
                if await _utils.notify_infraction(
                        user,
                        infr_type.replace("_", " ").title(), expiry,
                        user_reason, icon):
                    dm_result = ":incoming_envelope: "
                    dm_log_text = "\nDM: Sent"

        end_msg = ""
        if infraction["actor"] == self.bot.user.id:
            log.trace(
                f"Infraction #{id_} actor is bot; including the reason in the confirmation message."
            )
            if reason:
                end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})"
        elif is_mod_channel(ctx.channel):
            log.trace(f"Fetching total infraction count for {user}.")

            infractions = await self.bot.api_client.get(
                "bot/infractions", params={"user__id": str(user.id)})
            total = len(infractions)
            end_msg = f" (#{id_} ; {total} infraction{ngettext('', 's', total)} total)"

        # Execute the necessary actions to apply the infraction on Discord.
        if action_coro:
            log.trace(
                f"Awaiting the infraction #{id_} application action coroutine."
            )
            try:
                await action_coro
                if expiry:
                    # Schedule the expiration of the infraction.
                    self.schedule_expiration(infraction)
            except discord.HTTPException as e:
                # Accordingly display that applying the infraction failed.
                # Don't use ctx.message.author; antispam only patches ctx.author.
                confirm_msg = ":x: failed to apply"
                expiry_msg = ""
                log_content = ctx.author.mention
                log_title = "failed to apply"

                log_msg = f"Failed to apply {' '.join(infr_type.split('_'))} infraction #{id_} to {user}"
                if isinstance(e, discord.Forbidden):
                    log.warning(f"{log_msg}: bot lacks permissions.")
                elif e.code == 10007 or e.status == 404:
                    log.info(
                        f"Can't apply {infraction['type']} to user {infraction['user']} because user left from guild."
                    )
                else:
                    log.exception(log_msg)
                failed = True

        if failed:
            log.trace(
                f"Deleted infraction {infraction['id']} from database because applying infraction failed."
            )
            try:
                await self.bot.api_client.delete(f"bot/infractions/{id_}")
            except ResponseCodeError as e:
                confirm_msg += " and failed to delete"
                log_title += " and failed to delete"
                log.error(
                    f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}."
                )
            infr_message = ""
        else:
            infr_message = f" **{' '.join(infr_type.split('_'))}** to {user.mention}{expiry_msg}{end_msg}"

        # Send a confirmation message to the invoking context.
        log.trace(f"Sending infraction #{id_} confirmation message.")
        await ctx.send(f"{dm_result}{confirm_msg}{infr_message}.")

        # Send a log message to the mod log.
        # Don't use ctx.message.author for the actor; antispam only patches ctx.author.
        log.trace(f"Sending apply mod log for infraction #{id_}.")
        await self.mod_log.send_log_message(
            icon_url=icon,
            colour=Colours.soft_red,
            title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}",
            thumbnail=user.avatar_url_as(static_format="png"),
            text=textwrap.dedent(f"""
                Member: {messages.format_user(user)}
                Actor: {ctx.author.mention}{dm_log_text}{expiry_log_text}
                Reason: {reason}
                {additional_info}
            """),
            content=log_content,
            footer=f"ID {infraction['id']}")

        log.info(f"Applied {infr_type} infraction #{id_} to {user}.")
        return not failed
Пример #7
0
        if on_server:
            if user.joined_at:
                joined = discord_timestamp(user.joined_at,
                                           TimestampFormats.RELATIVE)
            else:
                joined = "Unable to get join date"

            # The 0 is for excluding the default @everyone role,
            # and the -1 is for reversing the order of the roles to highest to lowest in hierarchy.
            roles = ", ".join(role.mention for role in user.roles[:0:-1])
            membership = {
                "Joined": joined,
                "Verified": not user.pending,
                "Roles": roles or None
            }
            if not is_mod_channel(ctx.channel):
                membership.pop("Verified")

            membership = textwrap.dedent("\n".join(
                [f"{key}: {value}" for key, value in membership.items()]))
        else:
            roles = None
            membership = "The user is not a member of the server"

        fields = [
            ("User information",
             textwrap.dedent(f"""
                    Created: {created}
                    Profile: {user.mention}
                    ID: {user.id}
                """).strip()),
Пример #8
0
 async def cog_check(self, ctx: Context) -> bool:
     """Only allow moderators to invoke the commands in this cog in mod channels."""
     return (await has_any_role(*MODERATION_ROLES).predicate(ctx)
             and is_mod_channel(ctx.channel))
Пример #9
0
    async def _clean_messages(
        self,
        ctx: Context,
        channels: Optional[CleanChannels],
        bots_only: bool = False,
        users: Optional[list[User]] = None,
        regex: Optional[re.Pattern] = None,
        first_limit: Optional[CleanLimit] = None,
        second_limit: Optional[CleanLimit] = None,
    ) -> None:
        """A helper function that does the actual message cleaning."""
        self._validate_input(channels, bots_only, users, first_limit,
                             second_limit)

        # Are we already performing a clean?
        if self.cleaning:
            await self._send_expiring_message(
                ctx,
                ":x: Please wait for the currently ongoing clean operation to complete."
            )
            return
        self.cleaning = True

        deletion_channels = self._channels_set(channels, ctx, first_limit,
                                               second_limit)

        if isinstance(first_limit, Message):
            first_limit = first_limit.created_at
        if isinstance(second_limit, Message):
            second_limit = second_limit.created_at
        if first_limit and second_limit:
            first_limit, second_limit = sorted([first_limit, second_limit])

        # Needs to be called after standardizing the input.
        predicate = self._build_predicate(first_limit, second_limit, bots_only,
                                          users, regex)

        # Delete the invocation first
        await self._delete_invocation(ctx)

        if self._use_cache(first_limit):
            log.trace(
                f"Messages for cleaning by {ctx.author.id} will be searched in the cache."
            )
            message_mappings, message_ids = self._get_messages_from_cache(
                channels=deletion_channels,
                to_delete=predicate,
                lower_limit=first_limit)
        else:
            log.trace(
                f"Messages for cleaning by {ctx.author.id} will be searched in channel histories."
            )
            message_mappings, message_ids = await self._get_messages_from_channels(
                channels=deletion_channels,
                to_delete=predicate,
                before=second_limit,
                after=first_limit  # Remember first is the earlier datetime.
            )

        if not self.cleaning:
            # Means that the cleaning was canceled
            return

        # Now let's delete the actual messages with purge.
        self.mod_log.ignore(Event.message_delete, *message_ids)
        deleted_messages = await self._delete_found(message_mappings)
        self.cleaning = False

        if not channels:
            channels = deletion_channels
        logged = await self._modlog_cleaned_messages(deleted_messages,
                                                     channels, ctx)

        if logged and is_mod_channel(ctx.channel):
            with suppress(
                    NotFound
            ):  # Can happen if the invoker deleted their own messages.
                await ctx.message.add_reaction(Emojis.check_mark)
Пример #10
0
 async def _send_expiring_message(ctx: Context, content: str) -> None:
     """Send `content` to the context channel. Automatically delete if it's not a mod channel."""
     delete_after = None if is_mod_channel(
         ctx.channel) else MESSAGE_DELETE_DELAY
     await ctx.send(content, delete_after=delete_after)