aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar MarkKoz <[email protected]>2020-08-23 10:19:17 -0700
committerGravatar MarkKoz <[email protected]>2020-08-23 10:30:08 -0700
commite70fed9b43c7f1e0ecb76556c3482273c8157158 (patch)
tree1789a82e68b675a494e51332ac7ec05a59b2c4b5
parentCode block: clarify that the original message can be edited (diff)
parentMerge 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.py1
-rw-r--r--bot/cogs/codeblock/cog.py4
-rw-r--r--bot/cogs/doc.py4
-rw-r--r--bot/cogs/filtering.py19
-rw-r--r--bot/cogs/information.py117
-rw-r--r--bot/cogs/moderation/modlog.py4
-rw-r--r--bot/cogs/moderation/scheduler.py6
-rw-r--r--bot/cogs/moderation/utils.py2
-rw-r--r--bot/cogs/reddit.py3
-rw-r--r--bot/cogs/reminders.py26
-rw-r--r--bot/cogs/snekbox.py4
-rw-r--r--bot/cogs/tags.py4
-rw-r--r--bot/cogs/utils.py2
-rw-r--r--bot/cogs/watchchannels/bigbrother.py2
-rw-r--r--bot/cogs/watchchannels/talentpool.py54
-rw-r--r--bot/constants.py31
-rw-r--r--bot/resources/tags/ask.md9
-rw-r--r--bot/resources/tags/kindling-projects.md3
-rw-r--r--bot/resources/tags/traceback.md2
-rw-r--r--bot/utils/messages.py13
-rw-r--r--config-default.yml28
-rw-r--r--tests/bot/cogs/test_information.py87
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()