async def on_thread_update(self, before: Thread, after: Thread) -> None: """Log thread archiving, un-archiving and name edits.""" if self.is_channel_ignored(after.id): log.trace("Ignoring update of thread %s (%d)", after.mention, after.id) return if before.name != after.name: await self.send_log_message(Icons.hash_blurple, Colour.og_blurple( ), "Thread name edited", ( f"Thread {after.mention} (`{after.id}`) from {after.parent.mention} (`{after.parent.id}`): " f"`{before.name}` -> `{after.name}`")) return if not before.archived and after.archived: colour = Colours.soft_red action = "archived" icon = Icons.hash_red elif before.archived and not after.archived: colour = Colours.soft_green action = "un-archived" icon = Icons.hash_green else: return await self.send_log_message(icon, colour, f"Thread {action}", ( f"Thread {after.mention} ({after.name}, `{after.id}`) from {after.parent.mention} " f"(`{after.parent.id}`) was {action}"))
async def get_top_posts( self, subreddit: Subreddit, time: str = "all", amount: int = 5, paginate: bool = False) -> Union[Embed, list[tuple]]: """ Get the top amount of posts for a given subreddit within a specified timeframe. A time of "all" will get posts from all time, "day" will get top daily posts and "week" will get the top weekly posts. The amount should be between 0 and 25 as Reddit's JSON requests only provide 25 posts at most. """ embed = Embed() posts = await self.fetch_posts(route=f"{subreddit}/top", amount=amount, params={"t": time}) if not posts: embed.title = random.choice(ERROR_REPLIES) embed.colour = Colour.red() embed.description = ( "Sorry! We couldn't find any SFW posts from that subreddit. " "If this problem persists, please let us know.") return embed if paginate: return self.build_pagination_pages(posts, paginate=True) # Use only starting summary page for #reddit channel posts. embed.description = self.build_pagination_pages(posts, paginate=False) embed.colour = Colour.og_blurple() return embed
async def list_command(self, ctx: Context) -> None: """ Get a list of all extensions, including their loaded status. Grey indicates that the extension is unloaded. Green indicates that the extension is currently loaded. """ embed = Embed(colour=Colour.og_blurple()) embed.set_author( name="Extensions List", url=URLs.github_bot_repo, icon_url=URLs.bot_avatar ) lines = [] categories = self.group_extension_statuses() for category, extensions in sorted(categories.items()): # Treat each category as a single line by concatenating everything. # This ensures the paginator will not cut off a page in the middle of a category. category = category.replace("_", " ").title() extensions = "\n".join(sorted(extensions)) lines.append(f"**{category}**\n{extensions}\n") log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") await LinePaginator.paginate(lines, ctx, embed, scale_to_size=700, empty=False)
async def on_raw_message_edit( self, event: discord.RawMessageUpdateEvent) -> None: """Log raw message edit event to message change log.""" try: channel = self.bot.get_channel(int(event.data["channel_id"])) message = await channel.fetch_message(event.message_id) except discord.NotFound: # Was deleted before we got the event return if self.is_message_blacklisted(message): return await asyncio.sleep(1) # Wait here in case the normal event was fired if event.message_id in self._cached_edits: # It was in the cache and the normal event was fired, so we can just ignore it self._cached_edits.remove(event.message_id) return channel = message.channel channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}" before_response = ( f"**Author:** {format_user(message.author)}\n" f"**Channel:** {channel_name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" "This message was not cached, so the message content cannot be displayed." ) after_response = (f"**Author:** {format_user(message.author)}\n" f"**Channel:** {channel_name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" f"{message.clean_content}") await self.send_log_message(Icons.message_edit, Colour.og_blurple(), "Message edited (Before)", before_response, channel_id=Channels.message_log) await self.send_log_message(Icons.message_edit, Colour.og_blurple(), "Message edited (After)", after_response, channel_id=Channels.message_log)
async def status(self, ctx: Context) -> None: """Check the current status of DEFCON mode.""" embed = Embed(colour=Colour.og_blurple(), title="DEFCON Status", description=f""" **Threshold:** {humanize_delta(self.threshold) if self.threshold else "-"} **Expires:** {discord_timestamp(self.expiry, TimestampFormats.RELATIVE) if self.expiry else "-"} **Verification level:** {ctx.guild.verification_level.name} """) await ctx.send(embed=embed)
async def server_info(self, ctx: Context) -> None: """Returns an embed full of server information.""" embed = Embed(colour=Colour.og_blurple(), title="Server Information") created = discord_timestamp(ctx.guild.created_at, TimestampFormats.RELATIVE) 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, constants.Channels.bot_commands): 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 = (f"Created: {created}" f"{features}" f"\nRoles: {num_roles}" f"\nMember status: {member_status}") embed.set_thumbnail(url=ctx.guild.icon.url) # Members total_members = f"{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)
class Action(Enum): """Defcon Action.""" ActionInfo = namedtuple('LogInfoDetails', ['icon', 'emoji', 'color', 'template']) SERVER_OPEN = ActionInfo(Icons.defcon_unshutdown, Emojis.defcon_unshutdown, Colours.soft_green, "") SERVER_SHUTDOWN = ActionInfo(Icons.defcon_shutdown, Emojis.defcon_shutdown, Colours.soft_red, "") DURATION_UPDATE = ActionInfo(Icons.defcon_update, Emojis.defcon_update, Colour.og_blurple(), "**Threshold:** {threshold}\n\n")
async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: """Send the top posts of this week from a given subreddit.""" async with ctx.typing(): pages = await self.get_top_posts(subreddit=subreddit, time="week", paginate=True) await ctx.send(f"Here are this week's top {subreddit} posts!") embed = Embed(color=Colour.og_blurple()) await ImagePaginator.paginate(pages, ctx, embed)
async def on_guild_update(self, before: discord.Guild, after: discord.Guild) -> None: """Log guild update event to mod log.""" if before.id != GuildConstant.id: return diff = DeepDiff(before, after) changes = [] done = [] diff_values = diff.get("values_changed", {}) diff_values.update(diff.get("type_changes", {})) for key, value in diff_values.items(): if not key: # Not sure why, but it happens continue key = key[5:] # Remove "root." prefix if "[" in key: key = key.split("[", 1)[0] if "." in key: key = key.split(".", 1)[0] if key in done: continue new = value["new_value"] old = value["old_value"] changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") done.append(key) if not changes: return message = "" for item in sorted(changes): message += f"{Emojis.bullet} {item}\n" message = f"**{after.name}** (`{after.id}`)\n{message}" await self.send_log_message( Icons.guild_update, Colour.og_blurple(), "Guild updated", message, thumbnail=after.icon.with_static_format("png"))
async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: """Log member update event to user log.""" if before.guild.id != GuildConstant.id: return if before.id in self._ignored[Event.member_update]: self._ignored[Event.member_update].remove(before.id) return changes = self.get_role_diff(before.roles, after.roles) # The regex is a simple way to exclude all sequence and mapping types. diff = DeepDiff(before, after, exclude_regex_paths=r".*\[.*") # A type change seems to always take precedent over a value change. Furthermore, it will # include the value change along with the type change anyway. Therefore, it's OK to # "overwrite" values_changed; in practice there will never even be anything to overwrite. diff_values = { **diff.get("values_changed", {}), **diff.get("type_changes", {}) } for attr, value in diff_values.items(): if not attr: # Not sure why, but it happens. continue attr = attr[5:] # Remove "root." prefix. attr = attr.replace("_", " ").replace(".", " ").capitalize() new = value.get("new_value") old = value.get("old_value") changes.append(f"**{attr}:** `{old}` **→** `{new}`") if not changes: return message = "" for item in sorted(changes): message += f"{Emojis.bullet} {item}\n" message = f"{format_user(after)}\n{message}" await self.send_log_message(icon_url=Icons.user_update, colour=Colour.og_blurple(), title="Member updated", text=message, thumbnail=after.display_avatar.url, channel_id=Channels.user_log)
async def subreddits_command(self, ctx: Context) -> None: """Send a paginated embed of all the subreddits we're relaying.""" embed = Embed() embed.title = "Relayed subreddits." embed.colour = Colour.og_blurple() await LinePaginator.paginate( RedditConfig.subreddits, ctx, embed, footer_text= "Use the reddit commands along with these to view their posts.", empty=False, max_lines=15)
async def on_guild_role_update(self, before: discord.Role, after: discord.Role) -> None: """Log role update event to mod log.""" if before.guild.id != GuildConstant.id: return diff = DeepDiff(before, after) changes = [] done = [] diff_values = diff.get("values_changed", {}) diff_values.update(diff.get("type_changes", {})) for key, value in diff_values.items(): if not key: # Not sure why, but it happens continue key = key[5:] # Remove "root." prefix if "[" in key: key = key.split("[", 1)[0] if "." in key: key = key.split(".", 1)[0] if key in done or key == "color": continue if key in ROLE_CHANGES_UNSUPPORTED: changes.append(f"**{key.title()}** updated") else: new = value["new_value"] old = value["old_value"] changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") done.append(key) if not changes: return message = "" for item in sorted(changes): message += f"{Emojis.bullet} {item}\n" message = f"**{after.name}** (`{after.id}`)\n{message}" await self.send_log_message(Icons.crown_blurple, Colour.og_blurple(), "Role updated", message)
async def on_member_unban(self, guild: discord.Guild, member: discord.User) -> None: """Log member unban event to mod log.""" if guild.id != GuildConstant.id: return if member.id in self._ignored[Event.member_unban]: self._ignored[Event.member_unban].remove(member.id) return await self.send_log_message(Icons.user_unban, Colour.og_blurple(), "User unbanned", format_user(member), thumbnail=member.display_avatar.url, channel_id=Channels.mod_log)
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.og_blurple()) await LinePaginator.paginate(role_list, ctx, embed, empty=False)
async def info(self, ctx: commands.Context, member: Member) -> None: """ Send an info embed about the member with the team they're in. The team is found by searching the permissions of the team channels. """ channel = self.team_channel(ctx.guild, member) if not channel: await ctx.send(":x: I can't find the team channel for this member." ) return embed = Embed(title=str(member), colour=Colour.og_blurple()) embed.add_field(name="Team", value=self.team_name(channel), inline=True) await ctx.send(embed=embed)
async def rules(self, ctx: Context, rules: Greedy[int]) -> None: """Provides a link to all rules or, if specified, displays specific rule(s).""" rules_embed = Embed(title="Rules", color=Colour.og_blurple(), url="https://www.pythondiscord.com/pages/rules") if not rules: # Rules were not submitted. Return the default description. rules_embed.description = ( "The rules and guidelines that apply to this community can be found on" " our [rules page](https://www.pythondiscord.com/pages/rules). We expect" " all members of the community to have read and understood these." ) await ctx.send(embed=rules_embed) return full_rules = await self.bot.api_client.get( "rules", params={"link_format": "md"}) # Remove duplicates and sort the rule indices rules = sorted(set(rules)) invalid = ", ".join( str(index) for index in rules if index < 1 or index > len(full_rules)) if invalid: await ctx.send( shorten(":x: Invalid rule indices: " + invalid, 75, placeholder=" ...")) return for rule in rules: self.bot.stats.incr(f"rule_uses.{rule}") final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules) await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3)
# Show more verbose output in moderation channels for infractions and nominations if is_mod_channel(ctx.channel): 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.display_avatar.url) embed.colour = user.colour if user.colour != Colour.default( ) else Colour.og_blurple() return embed async def basic_user_infraction_counts( self, user: MemberOrUser) -> Tuple[str, str]: """Gets the total and active infraction counts for the given `member`.""" infractions = await self.bot.api_client.get('bot/infractions', params={ 'hidden': 'False', 'user__id': str(user.id) }) total_infractions = len(infractions) active_infractions = sum(infraction['active']
async def zen(self, ctx: Context, *, search_value: Union[int, str, None] = None) -> None: """ Show the Zen of Python. Without any arguments, the full Zen will be produced. If an integer is provided, the line with that index will be produced. If a string is provided, the line which matches best will be produced. """ embed = Embed(colour=Colour.og_blurple(), title="The Zen of Python", description=ZEN_OF_PYTHON) if search_value is None: embed.title += ", by Tim Peters" await ctx.send(embed=embed) return zen_lines = ZEN_OF_PYTHON.splitlines() # handle if it's an index int if isinstance(search_value, int): upper_bound = len(zen_lines) - 1 lower_bound = -1 * len(zen_lines) if not (lower_bound <= search_value <= upper_bound): raise BadArgument( f"Please provide an index between {lower_bound} and {upper_bound}." ) embed.title += f" (line {search_value % len(zen_lines)}):" embed.description = zen_lines[search_value] await ctx.send(embed=embed) return # Try to handle first exact word due difflib.SequenceMatched may use some other similar word instead # exact word. for i, line in enumerate(zen_lines): for word in line.split(): if word.lower() == search_value.lower(): embed.title += f" (line {i}):" embed.description = line await ctx.send(embed=embed) return # handle if it's a search string and not exact word matcher = difflib.SequenceMatcher(None, search_value.lower()) best_match = "" match_index = 0 best_ratio = 0 for index, line in enumerate(zen_lines): matcher.set_seq2(line.lower()) # the match ratio needs to be adjusted because, naturally, # longer lines will have worse ratios than shorter lines when # fuzzy searching for keywords. this seems to work okay. adjusted_ratio = (len(line) - 5)**0.5 * matcher.ratio() if adjusted_ratio > best_ratio: best_ratio = adjusted_ratio best_match = line match_index = index if not best_match: raise BadArgument( "I didn't get a match! Please try again with a different search term." ) embed.title += f" (line {match_index}):" embed.description = best_match await ctx.send(embed=embed)
async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState) -> None: """Log member voice state changes to the voice log channel.""" if (member.guild.id != GuildConstant.id or (before.channel and self.is_channel_ignored(before.channel.id)) or (after.channel and self.is_channel_ignored(after.channel.id))): return if member.id in self._ignored[Event.voice_state_update]: self._ignored[Event.voice_state_update].remove(member.id) return # Exclude all channel attributes except the name. diff = DeepDiff( before, after, exclude_paths=("root.session_id", "root.afk"), exclude_regex_paths=r"root\.channel\.(?!name)", ) # A type change seems to always take precedent over a value change. Furthermore, it will # include the value change along with the type change anyway. Therefore, it's OK to # "overwrite" values_changed; in practice there will never even be anything to overwrite. diff_values = { **diff.get("values_changed", {}), **diff.get("type_changes", {}) } icon = Icons.voice_state_blue colour = Colour.og_blurple() changes = [] for attr, values in diff_values.items(): if not attr: # Not sure why, but it happens. continue old = values["old_value"] new = values["new_value"] attr = attr[5:] # Remove "root." prefix. attr = VOICE_STATE_ATTRIBUTES.get( attr, attr.replace("_", " ").capitalize()) changes.append(f"**{attr}:** `{old}` **→** `{new}`") # Set the embed icon and colour depending on which attribute changed. if any(name in attr for name in ("Channel", "deaf", "mute")): if new is None or new is True: # Left a channel or was muted/deafened. icon = Icons.voice_state_red colour = Colours.soft_red elif old is None or old is True: # Joined a channel or was unmuted/undeafened. icon = Icons.voice_state_green colour = Colours.soft_green if not changes: return message = "\n".join(f"{Emojis.bullet} {item}" for item in sorted(changes)) message = f"{format_user(member)}\n{message}" await self.send_log_message(icon_url=icon, colour=colour, title="Voice state updated", text=message, thumbnail=member.display_avatar.url, channel_id=Channels.voice_log)
async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None: """Log message edit event to message change log.""" if self.is_message_blacklisted(msg_before): return self._cached_edits.append(msg_before.id) if msg_before.content == msg_after.content: return channel = msg_before.channel channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}" cleaned_contents = (escape_markdown(msg.clean_content).split() for msg in (msg_before, msg_after)) # Getting the difference per words and group them by type - add, remove, same # Note that this is intended grouping without sorting diff = difflib.ndiff(*cleaned_contents) diff_groups = tuple( (diff_type, tuple(s[2:] for s in diff_words)) for diff_type, diff_words in itertools.groupby(diff, key=lambda s: s[0])) content_before: t.List[str] = [] content_after: t.List[str] = [] for index, (diff_type, words) in enumerate(diff_groups): sub = ' '.join(words) if diff_type == '-': content_before.append(f"[{sub}](http://o.hi)") elif diff_type == '+': content_after.append(f"[{sub}](http://o.hi)") elif diff_type == ' ': if len(words) > 2: sub = ( f"{words[0] if index > 0 else ''}" " ... " f"{words[-1] if index < len(diff_groups) - 1 else ''}") content_before.append(sub) content_after.append(sub) response = (f"**Author:** {format_user(msg_before.author)}\n" f"**Channel:** {channel_name} (`{channel.id}`)\n" f"**Message ID:** `{msg_before.id}`\n" "\n" f"**Before**:\n{' '.join(content_before)}\n" f"**After**:\n{' '.join(content_after)}\n" "\n" f"[Jump to message]({msg_after.jump_url})") if msg_before.edited_at: # Message was previously edited, to assist with self-bot detection, use the edited_at # datetime as the baseline and create a human-readable delta between this edit event # and the last time the message was edited timestamp = msg_before.edited_at delta = humanize_delta( relativedelta(msg_after.edited_at, msg_before.edited_at)) footer = f"Last edited {delta} ago" else: # Message was not previously edited, use the created_at datetime as the baseline, no # delta calculation needed timestamp = msg_before.created_at footer = None await self.send_log_message(Icons.message_edit, Colour.og_blurple(), "Message edited", response, channel_id=Channels.message_log, timestamp_override=timestamp, footer=footer)
async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChannel) -> None: """Log channel update event to mod log.""" if before.guild.id != GuildConstant.id: return if before.id in self._ignored[Event.guild_channel_update]: self._ignored[Event.guild_channel_update].remove(before.id) return # Two channel updates are sent for a single edit: 1 for topic and 1 for category change. # TODO: remove once support is added for ignoring multiple occurrences for the same channel. help_categories = (Categories.help_available, Categories.help_dormant, Categories.help_in_use) if after.category and after.category.id in help_categories: return diff = DeepDiff(before, after) changes = [] done = [] diff_values = diff.get("values_changed", {}) diff_values.update(diff.get("type_changes", {})) for key, value in diff_values.items(): if not key: # Not sure why, but it happens continue key = key[5:] # Remove "root." prefix if "[" in key: key = key.split("[", 1)[0] if "." in key: key = key.split(".", 1)[0] if key in done or key in CHANNEL_CHANGES_SUPPRESSED: continue if key in CHANNEL_CHANGES_UNSUPPORTED: changes.append(f"**{key.title()}** updated") else: new = value["new_value"] old = value["old_value"] # Discord does not treat consecutive backticks ("``") as an empty inline code block, so the markdown # formatting is broken when `new` and/or `old` are empty values. "None" is used for these cases so # formatting is preserved. changes.append( f"**{key.title()}:** `{old or 'None'}` **→** `{new or 'None'}`" ) done.append(key) if not changes: return message = "" for item in sorted(changes): message += f"{Emojis.bullet} {item}\n" if after.category: message = f"**{after.category}/#{after.name} (`{after.id}`)**\n{message}" else: message = f"**#{after.name}** (`{after.id}`)\n{message}" await self.send_log_message(Icons.hash_blurple, Colour.og_blurple(), "Channel updated", message)