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)
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)
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)
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." )
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
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
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()),
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))
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)
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)