Beispiel #1
0
    async def deactivate_infraction(self,
                                    infraction: _utils.Infraction,
                                    send_log: bool = True) -> t.Dict[str, str]:
        """
        Deactivate an active infraction and return a dictionary of lines to send in a mod log.

        The infraction is removed from Discord, marked as inactive in the database, and has its
        expiration task cancelled. If `send_log` is True, a mod log is sent for the
        deactivation of the infraction.

        Infractions of unsupported types will raise a ValueError.
        """
        guild = self.bot.get_guild(constants.Guild.id)
        mod_role = guild.get_role(constants.Roles.moderators)
        user_id = infraction["user"]
        actor = infraction["actor"]
        type_ = infraction["type"]
        id_ = infraction["id"]
        inserted_at = infraction["inserted_at"]
        expiry = infraction["expires_at"]

        log.info(f"Marking infraction #{id_} as inactive (expired).")

        expiry = dateutil.parser.isoparse(expiry).replace(
            tzinfo=None) if expiry else None
        created = time.format_infraction_with_duration(inserted_at, expiry)

        log_content = None
        log_text = {
            "Member": f"<@{user_id}>",
            "Actor": f"<@{actor}>",
            "Reason": infraction["reason"],
            "Created": created,
        }

        try:
            log.trace("Awaiting the pardon action coroutine.")
            returned_log = await self._pardon_action(infraction)

            if returned_log is not None:
                log_text = {
                    **log_text,
                    **returned_log
                }  # Merge the logs together
            else:
                raise ValueError(
                    f"Attempted to deactivate an unsupported infraction #{id_} ({type_})!"
                )
        except discord.Forbidden:
            log.warning(
                f"Failed to deactivate infraction #{id_} ({type_}): bot lacks permissions."
            )
            log_text[
                "Failure"] = "The bot lacks permissions to do this (role hierarchy?)"
            log_content = mod_role.mention
        except discord.HTTPException as e:
            if e.code == 10007 or e.status == 404:
                log.info(
                    f"Can't pardon {infraction['type']} for user {infraction['user']} because user left the guild."
                )
                log_text["Failure"] = "User left the guild."
                log_content = mod_role.mention
            else:
                log.exception(
                    f"Failed to deactivate infraction #{id_} ({type_})")
                log_text[
                    "Failure"] = f"HTTPException with status {e.status} and code {e.code}."
                log_content = mod_role.mention

        # Check if the user is currently being watched by Big Brother.
        try:
            log.trace(
                f"Determining if user {user_id} is currently being watched by Big Brother."
            )

            active_watch = await self.bot.api_client.get("bot/infractions",
                                                         params={
                                                             "active": "true",
                                                             "type": "watch",
                                                             "user__id":
                                                             user_id
                                                         })

            log_text["Watching"] = "Yes" if active_watch else "No"
        except ResponseCodeError:
            log.exception(f"Failed to fetch watch status for user {user_id}")
            log_text["Watching"] = "Unknown - failed to fetch watch status."

        try:
            # Mark infraction as inactive in the database.
            log.trace(
                f"Marking infraction #{id_} as inactive in the database.")
            await self.bot.api_client.patch(f"bot/infractions/{id_}",
                                            json={"active": False})
        except ResponseCodeError as e:
            log.exception(f"Failed to deactivate infraction #{id_} ({type_})")
            log_line = f"API request failed with code {e.status}."
            log_content = mod_role.mention

            # Append to an existing failure message if possible
            if "Failure" in log_text:
                log_text["Failure"] += f" {log_line}"
            else:
                log_text["Failure"] = log_line

        # Cancel the expiration task.
        if infraction["expires_at"] is not None:
            self.scheduler.cancel(infraction["id"])

        # Send a log message to the mod log.
        if send_log:
            log_title = "expiration failed" if "Failure" in log_text else "expired"

            user = self.bot.get_user(user_id)
            avatar = user.avatar_url_as(static_format="png") if user else None

            # Move reason to end so when reason is too long, this is not gonna cut out required items.
            log_text["Reason"] = log_text.pop("Reason")

            log.trace(f"Sending deactivation mod log for infraction #{id_}.")
            await self.mod_log.send_log_message(
                icon_url=_utils.INFRACTION_ICONS[type_][1],
                colour=Colours.soft_green,
                title=f"Infraction {log_title}: {type_}",
                thumbnail=avatar,
                text="\n".join(f"{k}: {v}" for k, v in log_text.items()),
                footer=f"ID: {id_}",
                content=log_content,
            )

        return log_text
Beispiel #2
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.

        `action_coro`, if not provided, will result in the infraction not getting scheduled for deletion.
        `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)"

        purge = infraction.get("purge", "")

        # 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" **{purge}{' '.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 {purge}{infr_type} infraction #{id_} to {user}.")
        return not failed
Beispiel #3
0
    async def apply_infraction(
            self,
            ctx: Context,
            infraction: utils.Infraction,
            user: UserSnowflake,
            action_coro: t.Optional[t.Awaitable] = None) -> None:
        """Apply an infraction to the user, log the infraction, and optionally notify the user."""
        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']

        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"Expires: {expiry}" if expiry else ""
        log_title = "applied"
        log_content = None

        # DM the user about the infraction if it's not a shadow/hidden infraction.
        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, expiry,
                                                 reason, icon):
                    dm_result = ":incoming_envelope: "
                    dm_log_text = "\nDM: Sent"

        if infraction["actor"] == self.bot.user.id:
            log.trace(
                f"Infraction #{id_} actor is bot; including the reason in the confirmation message."
            )

            end_msg = f" (reason: {infraction['reason']})"
        elif ctx.channel.id not in STAFF_CHANNELS:
            log.trace(
                f"Infraction #{id_} context is not in a staff channel; omitting infraction count."
            )

            end_msg = ""
        else:
            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" ({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_task(infraction["id"], infraction)
            except discord.HTTPException as e:
                # Accordingly display that applying the infraction failed.
                confirm_msg = ":x: failed to apply"
                expiry_msg = ""
                log_content = ctx.author.mention
                log_title = "failed to apply"

                log_msg = f"Failed to apply {infr_type} infraction #{id_} to {user}"
                if isinstance(e, discord.Forbidden):
                    log.warning(f"{log_msg}: bot lacks permissions.")
                else:
                    log.exception(log_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_type}** to {user.mention}{expiry_msg}{end_msg}."
        )

        # Send a log message to the mod log.
        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}: {infr_type}",
            thumbnail=user.avatar_url_as(static_format="png"),
            text=textwrap.dedent(f"""
                Member: {user.mention} (`{user.id}`)
                Actor: {ctx.message.author}{dm_log_text}
                Reason: {reason}
                {expiry_log_text}
            """),
            content=log_content,
            footer=f"ID {infraction['id']}")

        log.info(f"Applied {infr_type} infraction #{id_} to {user}.")
Beispiel #4
0
    async def infraction_edit(
            self,
            ctx: Context,
            infraction_id: t.Union[
                int, allowed_strings("l", "last", "recent")],  # noqa: F821
            duration: t.Union[Expiry,
                              allowed_strings("p", "permanent"),
                              None],  # noqa: F821
            *,
            reason: str = None) -> None:
        """
        Edit the duration and/or the reason of an infraction.

        Durations are relative to the time of updating and should be appended with a unit of time.
        Units (∗case-sensitive):
        \u2003`y` - years
        \u2003`m` - months∗
        \u2003`w` - weeks
        \u2003`d` - days
        \u2003`h` - hours
        \u2003`M` - minutes∗
        \u2003`s` - seconds

        Use "l", "last", or "recent" as the infraction ID to specify that the most recent infraction
        authored by the command invoker should be edited.

        Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601
        timestamp can be provided for the duration.
        """
        if duration is None and reason is None:
            # Unlike UserInputError, the error handler will show a specified message for BadArgument
            raise commands.BadArgument(
                "Neither a new expiry nor a new reason was specified.")

        # Retrieve the previous infraction for its information.
        if isinstance(infraction_id, str):
            params = {"actor__id": ctx.author.id, "ordering": "-inserted_at"}
            infractions = await self.bot.api_client.get("bot/infractions",
                                                        params=params)

            if infractions:
                old_infraction = infractions[0]
                infraction_id = old_infraction["id"]
            else:
                await ctx.send(
                    ":x: Couldn't find most recent infraction; you have never given an infraction."
                )
                return
        else:
            old_infraction = await self.bot.api_client.get(
                f"bot/infractions/{infraction_id}")

        request_data = {}
        confirm_messages = []
        log_text = ""

        if duration is not None and not old_infraction['active']:
            if reason is None:
                await ctx.send(
                    ":x: Cannot edit the expiration of an expired infraction.")
                return
            confirm_messages.append(
                "expiry unchanged (infraction already expired)")
        elif isinstance(duration, str):
            request_data['expires_at'] = None
            confirm_messages.append("marked as permanent")
        elif duration is not None:
            request_data['expires_at'] = duration.isoformat()
            expiry = time.format_infraction_with_duration(
                request_data['expires_at'])
            confirm_messages.append(f"set to expire on {expiry}")
        else:
            confirm_messages.append("expiry unchanged")

        if reason:
            request_data['reason'] = reason
            confirm_messages.append("set a new reason")
            log_text += f"""
                Previous reason: {old_infraction['reason']}
                New reason: {reason}
            """.rstrip()
        else:
            confirm_messages.append("reason unchanged")

        # Update the infraction
        new_infraction = await self.bot.api_client.patch(
            f'bot/infractions/{infraction_id}',
            json=request_data,
        )

        # Re-schedule infraction if the expiration has been updated
        if 'expires_at' in request_data:
            # A scheduled task should only exist if the old infraction wasn't permanent
            if old_infraction['expires_at']:
                self.infractions_cog.scheduler.cancel(new_infraction['id'])

            # If the infraction was not marked as permanent, schedule a new expiration task
            if request_data['expires_at']:
                self.infractions_cog.schedule_expiration(new_infraction)

            log_text += f"""
                Previous expiry: {old_infraction['expires_at'] or "Permanent"}
                New expiry: {new_infraction['expires_at'] or "Permanent"}
            """.rstrip()

        changes = ' & '.join(confirm_messages)
        await ctx.send(
            f":ok_hand: Updated infraction #{infraction_id}: {changes}")

        # Get information about the infraction's user
        user_id = new_infraction['user']
        user = ctx.guild.get_member(user_id)

        if user:
            user_text = f"{user.mention} (`{user.id}`)"
            thumbnail = user.avatar_url_as(static_format="png")
        else:
            user_text = f"`{user_id}`"
            thumbnail = None

        # The infraction's actor
        actor_id = new_infraction['actor']
        actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`"

        await self.mod_log.send_log_message(icon_url=constants.Icons.pencil,
                                            colour=discord.Colour.blurple(),
                                            title="Infraction edited",
                                            thumbnail=thumbnail,
                                            text=textwrap.dedent(f"""
                Member: {user_text}
                Actor: {actor}
                Edited by: {ctx.message.author}{log_text}
            """))
Beispiel #5
0
    async def stream(self,
                     ctx: commands.Context,
                     member: discord.Member,
                     duration: Expiry = None) -> None:
        """
        Temporarily grant streaming permissions to a member for a given duration.

        A unit of time should be appended to the duration.
        Units (∗case-sensitive):
        \u2003`y` - years
        \u2003`m` - months∗
        \u2003`w` - weeks
        \u2003`d` - days
        \u2003`h` - hours
        \u2003`M` - minutes∗
        \u2003`s` - seconds

        Alternatively, an ISO 8601 timestamp can be provided for the duration.
        """
        log.trace(
            f"Attempting to give temporary streaming permission to {member} ({member.id})."
        )

        if duration is None:
            # Use default duration and convert back to datetime as Embed.timestamp doesn't support Arrow
            duration = arrow.utcnow() + timedelta(
                minutes=VideoPermission.default_permission_duration)
            duration = duration.datetime
        elif duration.tzinfo is None:
            # Make duration tz-aware.
            # ISODateTime could already include tzinfo, this check is so it isn't overwritten.
            duration.replace(tzinfo=timezone.utc)

        # Check if the member already has streaming permission
        already_allowed = any(Roles.video == role.id for role in member.roles)
        if already_allowed:
            await ctx.send(
                f"{Emojis.cross_mark} {member.mention} can already stream.")
            log.debug(
                f"{member} ({member.id}) already has permission to stream.")
            return

        # Schedule task to remove streaming permission from Member and add it to task cache
        self.scheduler.schedule_at(duration, member.id,
                                   self._revoke_streaming_permission(member))
        await self.task_cache.set(member.id, duration.timestamp())

        await member.add_roles(discord.Object(Roles.video),
                               reason="Temporary streaming access granted")

        # Use embed as embed timestamps do timezone conversions.
        embed = discord.Embed(
            description=f"{Emojis.check_mark} {member.mention} can now stream.",
            colour=Colours.soft_green)
        embed.set_footer(
            text=f"Streaming permission has been given to {member} until")
        embed.timestamp = duration

        # Mention in content as mentions in embeds don't ping
        await ctx.send(content=member.mention, embed=embed)

        # Convert here for nicer logging
        revoke_time = format_infraction_with_duration(str(duration))
        log.debug(
            f"Successfully gave {member} ({member.id}) permission to stream until {revoke_time}."
        )
Beispiel #6
0
            user_str = messages.format_user(user_obj)
        else:
            # Use the user data retrieved from the DB.
            name = escape_markdown(user['name'])
            user_str = f"<@{user['id']}> ({name}#{user['discriminator']:04})"

        if active:
            remaining = time.until_expiration(expires_at) or "Expired"
        else:
            remaining = "Inactive"

        if expires_at is None:
            expires = "*Permanent*"
        else:
            date_from = datetime.strptime(created, time.INFRACTION_FORMAT)
            expires = time.format_infraction_with_duration(expires_at, date_from)

        lines = textwrap.dedent(f"""
            {"**===============**" if active else "==============="}
            Status: {"__**Active**__" if active else "Inactive"}
            User: {user_str}
            Type: **{infraction["type"]}**
            Shadow: {infraction["hidden"]}
            Created: {created}
            Expires: {expires}
            Remaining: {remaining}
            Actor: <@{infraction["actor"]["id"]}>
            ID: `{infraction["id"]}`
            Reason: {infraction["reason"] or "*None*"}
            {"**===============**" if active else "==============="}
        """)