diff options
| author | 2020-08-23 10:19:17 -0700 | |
|---|---|---|
| committer | 2020-08-23 10:30:08 -0700 | |
| commit | e70fed9b43c7f1e0ecb76556c3482273c8157158 (patch) | |
| tree | 1789a82e68b675a494e51332ac7ec05a59b2c4b5 | |
| parent | Code block: clarify that the original message can be edited (diff) | |
| parent | Merge pull request #1105 from python-discord/joseph/enhanced-user-command (diff) | |
Code block: fix conflict with wait_for_deletion args
| -rw-r--r-- | bot/cogs/antispam.py | 1 | ||||
| -rw-r--r-- | bot/cogs/codeblock/cog.py | 4 | ||||
| -rw-r--r-- | bot/cogs/doc.py | 4 | ||||
| -rw-r--r-- | bot/cogs/filtering.py | 19 | ||||
| -rw-r--r-- | bot/cogs/information.py | 117 | ||||
| -rw-r--r-- | bot/cogs/moderation/modlog.py | 4 | ||||
| -rw-r--r-- | bot/cogs/moderation/scheduler.py | 6 | ||||
| -rw-r--r-- | bot/cogs/moderation/utils.py | 2 | ||||
| -rw-r--r-- | bot/cogs/reddit.py | 3 | ||||
| -rw-r--r-- | bot/cogs/reminders.py | 26 | ||||
| -rw-r--r-- | bot/cogs/snekbox.py | 4 | ||||
| -rw-r--r-- | bot/cogs/tags.py | 4 | ||||
| -rw-r--r-- | bot/cogs/utils.py | 2 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/bigbrother.py | 2 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/talentpool.py | 54 | ||||
| -rw-r--r-- | bot/constants.py | 31 | ||||
| -rw-r--r-- | bot/resources/tags/ask.md | 9 | ||||
| -rw-r--r-- | bot/resources/tags/kindling-projects.md | 3 | ||||
| -rw-r--r-- | bot/resources/tags/traceback.md | 2 | ||||
| -rw-r--r-- | bot/utils/messages.py | 13 | ||||
| -rw-r--r-- | config-default.yml | 28 | ||||
| -rw-r--r-- | tests/bot/cogs/test_information.py | 87 |
22 files changed, 264 insertions, 161 deletions
diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 0bcca578d..bc31cbd95 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -219,7 +219,6 @@ class AntiSpam(Cog): # Get context and make sure the bot becomes the actor of infraction by patching the `author` attributes context = await self.bot.get_context(msg) context.author = self.bot.user - context.message.author = self.bot.user # Since we're going to invoke the tempmute command directly, we need to manually call the converter. dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S") diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 63b971b84..c29dcaa4f 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -113,9 +113,7 @@ class CodeBlockCog(Cog, name="Code Block"): bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed) self.codeblock_message_ids[message.id] = bot_message.id - self.bot.loop.create_task( - wait_for_deletion(bot_message, user_ids=(message.author.id,), client=self.bot) - ) + self.bot.loop.create_task(wait_for_deletion(bot_message, (message.author.id,), self.bot)) # Increase amount of codeblock correction in stats self.bot.stats.incr("codeblock_corrections") diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 204cffb37..30c793c75 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -23,6 +23,7 @@ from bot.constants import MODERATION_ROLES, RedirectOutput from bot.converters import ValidPythonIdentifier, ValidURL from bot.decorators import with_role from bot.pagination import LinePaginator +from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) @@ -391,7 +392,8 @@ class Doc(commands.Cog): await error_message.delete(delay=NOT_FOUND_DELETE_DELAY) await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY) else: - await ctx.send(embed=doc_embed) + msg = await ctx.send(embed=doc_embed) + await wait_for_deletion(msg, (ctx.author.id,), client=self.bot) @docs_group.command(name='set', aliases=('s',)) @with_role(*MODERATION_ROLES) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 4ec95ad73..99b659bff 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -11,6 +11,7 @@ from discord import Colour, HTTPException, Member, Message, NotFound, TextChanne from discord.ext.commands import Cog from discord.utils import escape_markdown +from bot.api import ResponseCodeError from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import ( @@ -124,7 +125,10 @@ class Filtering(Cog): async def on_message(self, msg: Message) -> None: """Invoke message filter for new messages.""" await self._filter_message(msg) - await self.check_bad_words_in_name(msg.author) + + # Ignore webhook messages. + if msg.webhook_id is None: + await self.check_bad_words_in_name(msg.author) @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: @@ -298,9 +302,16 @@ class Filtering(Cog): 'delete_date': delete_date } - await self.bot.api_client.post('bot/offensive-messages', json=data) - self.schedule_msg_delete(data) - log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") + try: + await self.bot.api_client.post('bot/offensive-messages', json=data) + except ResponseCodeError as e: + if e.status == 400 and "already exists" in e.response_json.get("id", [""])[0]: + log.debug(f"Offensive message {msg.id} already exists.") + else: + log.error(f"Offensive message {msg.id} failed to post: {e}") + else: + self.schedule_msg_delete(data) + log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") if is_private: channel_str = "via DM" diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 8982196d1..55ecb2836 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -4,9 +4,9 @@ import pprint import textwrap from collections import Counter, defaultdict from string import Template -from typing import Any, Mapping, Optional, Union +from typing import Any, Mapping, Optional, Tuple, Union -from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils +from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status, utils from discord.abc import GuildChannel from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group from discord.utils import escape_markdown @@ -20,6 +20,12 @@ from bot.utils.time import time_since log = logging.getLogger(__name__) +STATUS_EMOTES = { + Status.offline: constants.Emojis.status_offline, + Status.dnd: constants.Emojis.status_dnd, + Status.idle: constants.Emojis.status_idle +} + class Information(Cog): """A cog with commands for generating embeds with server info, such as server stats and user info.""" @@ -211,53 +217,88 @@ class Information(Cog): # Custom status custom_status = '' for activity in user.activities: - # Check activity.state for None value if user has a custom status set - # This guards against a custom status with an emoji but no text, which will cause - # escape_markdown to raise an exception - # This can be reworked after a move to d.py 1.3.0+, which adds a CustomActivity class - if activity.name == 'Custom Status' and activity.state: - state = escape_markdown(activity.state) - custom_status = f'Status: {state}\n' + if isinstance(activity, CustomActivity): + state = "" + + if activity.name: + state = escape_markdown(activity.name) + + emoji = "" + if activity.emoji: + # If an emoji is unicode use the emoji, else write the emote like :abc: + if not activity.emoji.id: + emoji += activity.emoji.name + " " + else: + emoji += f"`:{activity.emoji.name}:` " + + custom_status = f'Status: {emoji}{state}\n' name = str(user) if 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) + joined = time_since(user.joined_at, max_units=3) roles = ", ".join(role.mention for role in user.roles[1:]) - description = [ - textwrap.dedent(f""" - **User Information** - Created: {created} - Profile: {user.mention} - ID: {user.id} - {custom_status} - **Member Information** - Joined: {joined} - Roles: {roles or None} - """).strip() + desktop_status = STATUS_EMOTES.get(user.desktop_status, constants.Emojis.status_online) + web_status = STATUS_EMOTES.get(user.web_status, constants.Emojis.status_online) + mobile_status = STATUS_EMOTES.get(user.mobile_status, constants.Emojis.status_online) + + fields = [ + ( + "User information", + textwrap.dedent(f""" + Created: {created} + Profile: {user.mention} + ID: {user.id} + {custom_status} + """).strip() + ), + ( + "Member information", + textwrap.dedent(f""" + Joined: {joined} + Roles: {roles or None} + """).strip() + ), + ( + "Status", + textwrap.dedent(f""" + {desktop_status} Desktop + {web_status} Web + {mobile_status} Mobile + """).strip() + ) ] # Show more verbose output in moderation channels for infractions and nominations if ctx.channel.id in constants.MODERATION_CHANNELS: - description.append(await self.expanded_user_infraction_counts(user)) - description.append(await self.user_nomination_counts(user)) + fields.append(await self.expanded_user_infraction_counts(user)) + fields.append(await self.user_nomination_counts(user)) else: - description.append(await self.basic_user_infraction_counts(user)) + fields.append(await self.basic_user_infraction_counts(user)) # Let's build the embed now embed = Embed( title=name, - description="\n\n".join(description) + 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 basic_user_infraction_counts(self, member: Member) -> str: + async def basic_user_infraction_counts(self, member: Member) -> Tuple[str, str]: """Gets the total and active infraction counts for the given `member`.""" infractions = await self.bot.api_client.get( 'bot/infractions', @@ -270,11 +311,11 @@ class Information(Cog): total_infractions = len(infractions) active_infractions = sum(infraction['active'] for infraction in infractions) - infraction_output = f"**Infractions**\nTotal: {total_infractions}\nActive: {active_infractions}" + infraction_output = f"Total: {total_infractions}\nActive: {active_infractions}" - return infraction_output + return "Infractions", infraction_output - async def expanded_user_infraction_counts(self, member: Member) -> str: + async def expanded_user_infraction_counts(self, member: Member) -> Tuple[str, str]: """ Gets expanded infraction counts for the given `member`. @@ -288,9 +329,9 @@ class Information(Cog): } ) - infraction_output = ["**Infractions**"] + infraction_output = [] if not infractions: - infraction_output.append("This user has never received an infraction.") + infraction_output.append("No infractions") else: # Count infractions split by `type` and `active` status for this user infraction_types = set() @@ -313,9 +354,9 @@ class Information(Cog): infraction_output.append(line) - return "\n".join(infraction_output) + return "Infractions", "\n".join(infraction_output) - async def user_nomination_counts(self, member: Member) -> str: + async def user_nomination_counts(self, member: Member) -> Tuple[str, str]: """Gets the active and historical nomination counts for the given `member`.""" nominations = await self.bot.api_client.get( 'bot/nominations', @@ -324,21 +365,21 @@ class Information(Cog): } ) - output = ["**Nominations**"] + output = [] if not nominations: - output.append("This user has never been nominated.") + output.append("No nominations") else: count = len(nominations) is_currently_nominated = any(nomination["active"] for nomination in nominations) nomination_noun = "nomination" if count == 1 else "nominations" if is_currently_nominated: - output.append(f"This user is **currently** nominated ({count} {nomination_noun} in total).") + output.append(f"This user is **currently** nominated\n({count} {nomination_noun} in total)") else: output.append(f"This user has {count} historical {nomination_noun}, but is currently not nominated.") - return "\n".join(output) + return "Nominations", "\n".join(output) def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str: """Format a mapping to be readable to a human.""" @@ -376,7 +417,7 @@ class Information(Cog): return out.rstrip() @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) - @group(invoke_without_command=True) + @group(invoke_without_command=True, enabled=False) @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES) async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: """Shows information about the raw API response.""" @@ -411,7 +452,7 @@ class Information(Cog): for page in paginator.pages: await ctx.send(page) - @raw.command() + @raw.command(enabled=False) async def json(self, ctx: Context, message: Message) -> None: """Shows information about the raw API response in a copy-pasteable Python format.""" await ctx.invoke(self.raw, message=message, json=True) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 0a63f57b8..5f30d3744 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -120,6 +120,10 @@ class ModLog(Cog, name="ModLog"): else: content = "@everyone" + # Truncate content to 2000 characters and append an ellipsis. + if content and len(content) > 2000: + content = content[:2000 - 3] + "..." + channel = self.bot.get_channel(channel_id) log_message = await channel.send( content=content, diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 75028d851..051f6c52c 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -161,6 +161,7 @@ class InfractionScheduler: 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 @@ -190,6 +191,7 @@ class InfractionScheduler: 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, @@ -198,7 +200,7 @@ class InfractionScheduler: thumbnail=user.avatar_url_as(static_format="png"), text=textwrap.dedent(f""" Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author}{dm_log_text}{expiry_log_text} + Actor: {ctx.author}{dm_log_text}{expiry_log_text} Reason: {reason} """), content=log_content, @@ -242,7 +244,7 @@ class InfractionScheduler: log_text = await self.deactivate_infraction(response[0], send_log=False) log_text["Member"] = f"{user.mention}(`{user.id}`)" - log_text["Actor"] = str(ctx.message.author) + log_text["Actor"] = str(ctx.author) log_content = None id_ = response[0]['id'] footer = f"ID: {id_}" diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index fb55287b6..f21272102 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -70,7 +70,7 @@ async def post_infraction( log.trace(f"Posting {infr_type} infraction for {user} to the API.") payload = { - "actor": ctx.message.author.id, + "actor": ctx.author.id, # Don't use ctx.message.author; antispam only patches ctx.author. "hidden": hidden, "reason": reason, "type": infr_type, diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index d853ab2ea..5d9e2c20b 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -10,6 +10,7 @@ from aiohttp import BasicAuth, ClientError from discord import Colour, Embed, TextChannel from discord.ext.commands import Cog, Context, group from discord.ext.tasks import loop +from discord.utils import escape_markdown from bot.bot import Bot from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks @@ -187,6 +188,8 @@ class Reddit(Cog): author = data["author"] title = textwrap.shorten(data["title"], width=64, placeholder="...") + # Normal brackets interfere with Markdown. + title = escape_markdown(title).replace("[", "⦋").replace("]", "⦌") link = self.URL + data["permalink"] embed.description += ( diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 670493bcf..08bce2153 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -12,10 +12,10 @@ from dateutil.relativedelta import relativedelta from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot -from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES +from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_ROLES from bot.converters import Duration from bot.pagination import LinePaginator -from bot.utils.checks import without_role_check +from bot.utils.checks import with_role_check, without_role_check from bot.utils.messages import send_denial from bot.utils.scheduling import Scheduler from bot.utils.time import humanize_delta @@ -396,6 +396,8 @@ class Reminders(Cog): async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: """Edits a reminder with the given payload, then sends a confirmation message.""" + if not await self._can_modify(ctx, id_): + return reminder = await self._edit_reminder(id_, payload) # Parse the reminder expiration back into a datetime @@ -413,6 +415,8 @@ class Reminders(Cog): @remind_group.command("delete", aliases=("remove", "cancel")) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" + if not await self._can_modify(ctx, id_): + return await self._delete_reminder(id_) await self._send_confirmation( ctx, @@ -421,6 +425,24 @@ class Reminders(Cog): delivery_dt=None, ) + async def _can_modify(self, ctx: Context, reminder_id: t.Union[str, int]) -> bool: + """ + Check whether the reminder can be modified by the ctx author. + + The check passes when the user is an admin, or if they created the reminder. + """ + if with_role_check(ctx, Roles.admins): + return True + + api_response = await self.bot.api_client.get(f"bot/reminders/{reminder_id}") + if not api_response["author"] == ctx.author.id: + log.debug(f"{ctx.author} is not the reminder author and does not pass the check.") + await send_denial(ctx, "You can't modify reminders of other users!") + return False + + log.debug(f"{ctx.author} is the reminder author and passes the check.") + return True + def setup(bot: Bot) -> None: """Load the Reminders cog.""" diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 52c8b6f88..63e6d7f31 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -220,9 +220,7 @@ class Snekbox(Cog): response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") else: response = await ctx.send(msg) - self.bot.loop.create_task( - wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) - ) + self.bot.loop.create_task(wait_for_deletion(response, (ctx.author.id,), ctx.bot)) log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") return response diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 3d76c5c08..d01647312 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -236,7 +236,7 @@ class Tags(Cog): await wait_for_deletion( await ctx.send(embed=Embed.from_dict(tag['embed'])), [ctx.author.id], - client=self.bot + self.bot ) elif founds and len(tag_name) >= 3: await wait_for_deletion( @@ -247,7 +247,7 @@ class Tags(Cog): ) ), [ctx.author.id], - client=self.bot + self.bot ) else: diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 91c6cb36e..d96abbd5a 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -232,6 +232,8 @@ class Utils(Cog): A maximum of 20 options can be provided, as Discord supports a max of 20 reactions on a single message. """ + if len(title) > 256: + raise BadArgument("The title cannot be longer than 256 characters.") if len(options) < 2: raise BadArgument("Please provide at least 2 options.") if len(options) > 20: diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 4d27a6333..7aa9cec58 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -131,8 +131,8 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): active_watches = await self.bot.api_client.get( self.api_endpoint, params=ChainMap( + {"user__id": str(user.id)}, self.api_default_params, - {"user__id": str(user.id)} ) ) if active_watches: diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 89256e92e..a6df84c23 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -1,8 +1,9 @@ import logging import textwrap from collections import ChainMap +from typing import Union -from discord import Color, Embed, Member +from discord import Color, Embed, Member, User from discord.ext.commands import Cog, Context, group from bot.api import ResponseCodeError @@ -164,25 +165,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): Providing a `reason` is required. """ - active_nomination = await self.bot.api_client.get( - self.api_endpoint, - params=ChainMap( - self.api_default_params, - {"user__id": str(user.id)} - ) - ) - - if not active_nomination: + if await self.unwatch(user.id, reason): + await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") + else: await ctx.send(":x: The specified user does not have an active nomination") - return - - [nomination] = active_nomination - await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination['id']}", - json={'end_reason': reason, 'active': False} - ) - await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") - self._remove_user(user.id) @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) @with_role(*MODERATION_ROLES) @@ -220,6 +206,36 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await ctx.send(f":white_check_mark: Updated the {field} of the nomination!") + @Cog.listener() + async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None: + """Remove `user` from the talent pool after they are banned.""" + await self.unwatch(user.id, "User was banned.") + + async def unwatch(self, user_id: int, reason: str) -> bool: + """End the active nomination of a user with the given reason and return True on success.""" + active_nomination = await self.bot.api_client.get( + self.api_endpoint, + params=ChainMap( + {"user__id": str(user_id)}, + self.api_default_params, + ) + ) + + if not active_nomination: + log.debug(f"No active nominate exists for {user_id=}") + return False + + log.info(f"Ending nomination: {user_id=} {reason=}") + + nomination = active_nomination[0] + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination['id']}", + json={'end_reason': reason, 'active': False} + ) + self._remove_user(user_id) + + return True + def _nomination_to_string(self, nomination_object: dict) -> str: """Creates a string representation of a nomination.""" guild = self.bot.get_guild(Guild.id) diff --git a/bot/constants.py b/bot/constants.py index 06db8afe9..a28588427 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -268,6 +268,17 @@ class Emojis(metaclass=YAMLGetter): status_idle: str status_dnd: str + badge_staff: str + badge_partner: str + badge_hypesquad: str + badge_bug_hunter: str + badge_hypesquad_bravery: str + badge_hypesquad_brilliance: str + badge_hypesquad_balance: str + badge_early_supporter: str + badge_bug_hunter_level_2: str + badge_verified_bot_developer: str + incident_actioned: str incident_unactioned: str incident_investigating: str @@ -485,25 +496,13 @@ class URLs(metaclass=YAMLGetter): bot_avatar: str github_bot_repo: str - # Site endpoints + # Base site vars site: str site_api: str - site_superstarify_api: str - site_logs_api: str - site_logs_view: str - site_reminders_api: str - site_reminders_user_api: str site_schema: str - site_settings_api: str - site_tags_api: str - site_user_api: str - site_user_complete_api: str - site_infractions: str - site_infractions_user: str - site_infractions_type: str - site_infractions_by_id: str - site_infractions_user_type_current: str - site_infractions_user_type: str + + # Site endpoints + site_logs_view: str paste_service: str diff --git a/bot/resources/tags/ask.md b/bot/resources/tags/ask.md deleted file mode 100644 index e2c2a88f6..000000000 --- a/bot/resources/tags/ask.md +++ /dev/null @@ -1,9 +0,0 @@ -Asking good questions will yield a much higher chance of a quick response: - -• Don't ask to ask your question, just go ahead and tell us your problem. -• Don't ask if anyone is knowledgeable in some area, filtering serves no purpose. -• Try to solve the problem on your own first, we're not going to write code for you. -• Show us the code you've tried and any errors or unexpected results it's giving. -• Be patient while we're helping you. - -You can find a much more detailed explanation [on our website](https://pythondiscord.com/pages/asking-good-questions/). diff --git a/bot/resources/tags/kindling-projects.md b/bot/resources/tags/kindling-projects.md new file mode 100644 index 000000000..54ed8c961 --- /dev/null +++ b/bot/resources/tags/kindling-projects.md @@ -0,0 +1,3 @@ +**Kindling Projects** + +The [Kindling projects page](https://nedbatchelder.com/text/kindling.html) on Ned Batchelder's website contains a list of projects and ideas programmers can tackle to build their skills and knowledge. diff --git a/bot/resources/tags/traceback.md b/bot/resources/tags/traceback.md index 46ef40aa1..e770fa86d 100644 --- a/bot/resources/tags/traceback.md +++ b/bot/resources/tags/traceback.md @@ -11,7 +11,7 @@ ZeroDivisionError: integer division or modulo by zero ``` The best way to read your traceback is bottom to top. -• Identify the exception raised (e.g. ZeroDivisonError) +• Identify the exception raised (e.g. ZeroDivisionError) • Make note of the line number, and navigate there in your program. • Try to understand why the error occurred. diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 670289941..aa8f17f75 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -19,25 +19,20 @@ log = logging.getLogger(__name__) async def wait_for_deletion( message: Message, user_ids: Sequence[Snowflake], + client: Client, deletion_emojis: Sequence[str] = (Emojis.trashcan,), timeout: float = 60 * 5, attach_emojis: bool = True, - client: Optional[Client] = None ) -> None: """ Wait for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message. An `attach_emojis` bool may be specified to determine whether to attach the given - `deletion_emojis` to the message in the given `context` - - A `client` instance may be optionally specified, otherwise client will be taken from the - guild of the message. + `deletion_emojis` to the message in the given `context`. """ - if message.guild is None and client is None: + if message.guild is None: raise ValueError("Message must be sent on a guild") - bot = client or message.guild.me - if attach_emojis: for emoji in deletion_emojis: await message.add_reaction(emoji) @@ -51,7 +46,7 @@ async def wait_for_deletion( ) with contextlib.suppress(asyncio.TimeoutError): - await bot.wait_for('reaction_add', check=check, timeout=timeout) + await client.wait_for('reaction_add', check=check, timeout=timeout) await message.delete() diff --git a/config-default.yml b/config-default.yml index ca4be74e5..324e39346 100644 --- a/config-default.yml +++ b/config-default.yml @@ -38,6 +38,17 @@ style: status_dnd: "<:status_dnd:470326272082313216>" status_offline: "<:status_offline:470326266537705472>" + badge_staff: "<:discord_staff:743882896498098226>" + badge_partner: "<:partner:743882897131569323>" + badge_hypesquad: "<:hypesquad_events:743882896892362873>" + badge_bug_hunter: "<:bug_hunter_lvl1:743882896372269137>" + badge_hypesquad_bravery: "<:hypesquad_bravery:743882896745693335>" + badge_hypesquad_brilliance: "<:hypesquad_brilliance:743882896938631248>" + badge_hypesquad_balance: "<:hypesquad_balance:743882896460480625>" + badge_early_supporter: "<:early_supporter:743882896909140058>" + badge_bug_hunter_level_2: "<:bug_hunter_lvl2:743882896611344505>" + badge_verified_bot_developer: "<:verified_bot_dev:743882897299210310>" + incident_actioned: "<:incident_actioned:719645530128646266>" incident_unactioned: "<:incident_unactioned:719645583245180960>" incident_investigating: "<:incident_investigating:719645658671480924>" @@ -309,24 +320,7 @@ urls: site_staff: &STAFF !JOIN ["staff.", *DOMAIN] site_schema: &SCHEMA "https://" - site_bigbrother_api: !JOIN [*SCHEMA, *API, "/bot/bigbrother"] - site_docs_api: !JOIN [*SCHEMA, *API, "/bot/docs"] - site_superstarify_api: !JOIN [*SCHEMA, *API, "/bot/superstarify"] - site_infractions: !JOIN [*SCHEMA, *API, "/bot/infractions"] - site_infractions_user: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}"] - site_infractions_type: !JOIN [*SCHEMA, *API, "/bot/infractions/type/{infraction_type}"] - site_infractions_by_id: !JOIN [*SCHEMA, *API, "/bot/infractions/id/{infraction_id}"] - site_infractions_user_type_current: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}/current"] - site_infractions_user_type: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}"] - site_logs_api: !JOIN [*SCHEMA, *API, "/bot/logs"] site_logs_view: !JOIN [*SCHEMA, *STAFF, "/bot/logs"] - site_off_topic_names_api: !JOIN [*SCHEMA, *API, "/bot/off-topic-names"] - site_reminders_api: !JOIN [*SCHEMA, *API, "/bot/reminders"] - site_reminders_user_api: !JOIN [*SCHEMA, *API, "/bot/reminders/user"] - site_settings_api: !JOIN [*SCHEMA, *API, "/bot/settings"] - site_tags_api: !JOIN [*SCHEMA, *API, "/bot/tags"] - site_user_api: !JOIN [*SCHEMA, *API, "/bot/users"] - site_user_complete_api: !JOIN [*SCHEMA, *API, "/bot/users/complete"] paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"] # Snekbox diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 79c0e0ad3..77b0ddf17 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -215,10 +215,10 @@ class UserInfractionHelperMethodTests(unittest.TestCase): with self.subTest(method=method, api_response=api_response, expected_lines=expected_lines): self.bot.api_client.get.return_value = api_response - expected_output = "\n".join(default_header + expected_lines) + expected_output = "\n".join(expected_lines) actual_output = asyncio.run(method(self.member)) - self.assertEqual(expected_output, actual_output) + self.assertEqual((default_header, expected_output), actual_output) def test_basic_user_infraction_counts_returns_correct_strings(self): """The method should correctly list both the total and active number of non-hidden infractions.""" @@ -249,7 +249,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): }, ) - header = ["**Infractions**"] + header = "Infractions" self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) @@ -258,7 +258,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): test_values = ( { "api response": [], - "expected_lines": ["This user has never received an infraction."], + "expected_lines": ["No infractions"], }, # Shows non-hidden inactive infraction as expected { @@ -304,7 +304,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): }, ) - header = ["**Infractions**"] + header = "Infractions" self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) @@ -313,15 +313,15 @@ class UserInfractionHelperMethodTests(unittest.TestCase): test_values = ( { "api response": [], - "expected_lines": ["This user has never been nominated."], + "expected_lines": ["No nominations"], }, { "api response": [{'active': True}], - "expected_lines": ["This user is **currently** nominated (1 nomination in total)."], + "expected_lines": ["This user is **currently** nominated", "(1 nomination in total)"], }, { "api response": [{'active': True}, {'active': False}], - "expected_lines": ["This user is **currently** nominated (2 nominations in total)."], + "expected_lines": ["This user is **currently** nominated", "(2 nominations in total)"], }, { "api response": [{'active': False}], @@ -334,7 +334,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): ) - header = ["**Nominations**"] + header = "Nominations" self._method_subtests(self.cog.user_nomination_counts, test_values, header) @@ -350,7 +350,10 @@ class UserEmbedTests(unittest.TestCase): self.bot.api_client.get = unittest.mock.AsyncMock() self.cog = information.Information(self.bot) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): """The embed should use the string representation of the user if they don't have a nick.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -362,7 +365,10 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.title, "Mr. Hemlock") - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_uses_nick_in_title_if_available(self): """The embed should use the nick if it's available.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -374,7 +380,10 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_ignores_everyone_role(self): """Created `!user` embeds should not contain mention of the @everyone-role.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -386,8 +395,8 @@ class UserEmbedTests(unittest.TestCase): embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - self.assertIn("&Admins", embed.description) - self.assertNotIn("&Everyone", embed.description) + self.assertIn("&Admins", embed.fields[1].value) + self.assertNotIn("&Everyone", embed.fields[1].value) @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock) @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock) @@ -398,8 +407,8 @@ class UserEmbedTests(unittest.TestCase): moderators_role = helpers.MockRole(name='Moderators') moderators_role.colour = 100 - infraction_counts.return_value = "expanded infractions info" - nomination_counts.return_value = "nomination info" + infraction_counts.return_value = ("Infractions", "expanded infractions info") + nomination_counts.return_value = ("Nominations", "nomination info") user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) embed = asyncio.run(self.cog.create_user_embed(ctx, user)) @@ -409,20 +418,19 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual( textwrap.dedent(f""" - **User Information** Created: {"1 year ago"} Profile: {user.mention} ID: {user.id} + """).strip(), + embed.fields[0].value + ) - **Member Information** + self.assertEqual( + textwrap.dedent(f""" Joined: {"1 year ago"} Roles: &Moderators - - expanded infractions info - - nomination info """).strip(), - embed.description + embed.fields[1].value ) @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) @@ -433,7 +441,7 @@ class UserEmbedTests(unittest.TestCase): moderators_role = helpers.MockRole(name='Moderators') moderators_role.colour = 100 - infraction_counts.return_value = "basic infractions info" + infraction_counts.return_value = ("Infractions", "basic infractions info") user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) embed = asyncio.run(self.cog.create_user_embed(ctx, user)) @@ -442,21 +450,30 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual( textwrap.dedent(f""" - **User Information** Created: {"1 year ago"} Profile: {user.mention} ID: {user.id} + """).strip(), + embed.fields[0].value + ) - **Member Information** + self.assertEqual( + textwrap.dedent(f""" Joined: {"1 year ago"} Roles: &Moderators - - basic infractions info """).strip(), - embed.description + embed.fields[1].value + ) + + self.assertEqual( + "basic infractions info", + embed.fields[3].value ) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): """The embed should be created with the colour of the top role, if a top role is available.""" ctx = helpers.MockContext() @@ -469,7 +486,10 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): """The embed should be created with a blurple colour if the user has no assigned roles.""" ctx = helpers.MockContext() @@ -479,7 +499,10 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.colour, discord.Colour.blurple()) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): """The embed thumbnail should be set to the user's avatar in `png` format.""" ctx = helpers.MockContext() |