Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e7780bd
Reminders: Add a button for others to opt-in to a ping
Mar 24, 2024
cdaa59f
Reminders: More robust implementation of mention opt-in button
Mar 25, 2024
e753586
Reminders: Refactor all opt-in button related logic into View class
Mar 26, 2024
8efc22d
Reminders: Simplify helper function to get button embed
Mar 27, 2024
8813f2d
Added the possibility of slicing for the zen command
LeandroVandari Mar 16, 2025
4d63449
Fixed off-by-one error, as the end index can be equal to the length o…
LeandroVandari Mar 16, 2025
a886997
Added support for negative signs and replaced re.search by re.match
LeandroVandari Mar 18, 2025
6d68dd7
Allows for end_index == len(zen_lines). Previously, in that case, end…
LeandroVandari Mar 19, 2025
6d3cbfb
Allows for slicing without a specified end index (e.g. "1:" will retu…
LeandroVandari Mar 19, 2025
3e75e1f
Update end index display when slicing in the zen command
LeandroVandari Mar 20, 2025
7564191
Added tests for the zen command
LeandroVandari Mar 24, 2025
0f18d70
Correct dependabot config
ChrisLovering Mar 29, 2025
9806725
Merge branch 'main' into feat/reminder-add-notify
ChrisLovering Mar 29, 2025
964ee58
Merge branch 'main' into feat/reminder-add-notify
ChrisLovering Mar 29, 2025
2101946
Merge pull request #2973 from python-discord/feat/reminder-add-notify
ChrisLovering Mar 29, 2025
816ca0a
Merge branch 'main' into zen_slicing
ChrisLovering Mar 29, 2025
6c90ab7
Zen slicing (#3297)
ChrisLovering Mar 29, 2025
90a7f74
Bump getsentry/action-release from 1 to 3 (#3305)
dependabot[bot] Mar 29, 2025
72e8fe9
Remove pings when auto-banning in filters
ChrisLovering Mar 30, 2025
6236a4c
Merge pull request #3306 from python-discord/remove-ping-on-auto-ban
mbaruh Mar 31, 2025
479df30
Remove unused `ValidDiscordServerInvite` converter (#3307)
decorator-factory Apr 6, 2025
b3a6af4
Founders Talentpool permissions (#3308)
jb3 Apr 6, 2025
46cbc19
Actually used capped duration
jb3 Apr 8, 2025
7eb0416
Add step to zen command slicing
MeGaGiGaGon Apr 11, 2025
794663f
fix whitespace and file end lints
MeGaGiGaGon Apr 11, 2025
fb4ae66
fix ruff lints
MeGaGiGaGon Apr 11, 2025
4c169a5
fix tests
MeGaGiGaGon Apr 11, 2025
2d4f688
Merge pull request #3311 from MeGaGiGaGon/main
jb3 Apr 14, 2025
1e0ad7b
Add main application entry point and requirements file
play2berich Apr 14, 2025
937f832
Merge branch 'main' into default
play2berich Apr 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ updates:
schedule:
interval: "daily"
ignore:
update-types:
- sem-ver:patch
- sem-ver:minor
- dependency-name: "*"
update-types:
- version-update:semver-patch
- version-update:semver-minor
- package-ecosystem: "github-actions"
directory: "/"
schedule:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/sentry_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
uses: actions/checkout@v4

- name: Create a Sentry.io release
uses: getsentry/action-release@v1
uses: getsentry/action-release@v3
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: python-discord
Expand Down
1 change: 1 addition & 0 deletions bot/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ class _Roles(EnvConfig, env_prefix="roles_"):
mod_team: int = 267629731250176001
owners: int = 267627879762755584
project_leads: int = 815701647526330398
founders: int = 1069394343867199590

# Code Jam
jammers: int = 737249140966162473
Expand Down
39 changes: 0 additions & 39 deletions bot/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,8 @@
from discord.utils import snowflake_time
from pydis_core.site_api import ResponseCodeError
from pydis_core.utils import unqualify
from pydis_core.utils.regex import DISCORD_INVITE

from bot import exts, instance as bot_instance
from bot.constants import URLs
from bot.errors import InvalidInfractionError
from bot.exts.info.doc import _inventory_parser
from bot.log import get_logger
Expand All @@ -31,42 +29,6 @@
RE_USER_MENTION = re.compile(r"<@!?([0-9]+)>$")


class ValidDiscordServerInvite(Converter):
"""
A converter that validates whether a given string is a valid Discord server invite.

Raises 'BadArgument' if:
- The string is not a valid Discord server invite.
- The string is valid, but is an invite for a group DM.
- The string is valid, but is expired.

Returns a (partial) guild object if:
- The string is a valid vanity
- The string is a full invite URI
- The string contains the invite code (the stuff after discord.gg/)

See the Discord API docs for documentation on the guild object:
https://discord.com/developers/docs/resources/guild#guild-object
"""

async def convert(self, ctx: Context, server_invite: str) -> dict:
"""Check whether the string is a valid Discord server invite."""
invite_code = DISCORD_INVITE.match(server_invite)
if invite_code:
response = await ctx.bot.http_session.get(
f"{URLs.discord_invite_api}/{invite_code.group('invite')}"
)
if response.status != 404:
invite_data = await response.json()
return invite_data.get("guild")

id_converter = IDConverter()
if id_converter._get_id_match(server_invite):
raise BadArgument("Guild IDs are not supported, only invites.")

raise BadArgument("This does not appear to be a valid Discord server invite.")


class Extension(Converter):
"""
Fully qualify the name of an extension and ensure it exists.
Expand Down Expand Up @@ -466,7 +428,6 @@ async def convert(self, ctx: Context, arg: str) -> dict | None:


if t.TYPE_CHECKING:
ValidDiscordServerInvite = dict
ValidFilterListType = str
Extension = str
PackageName = str
Expand Down
5 changes: 4 additions & 1 deletion bot/exts/filtering/filtering.py
Original file line number Diff line number Diff line change
Expand Up @@ -978,7 +978,10 @@ async def _resolve_action(
result_actions = None
if actions:
result_actions = reduce(ActionSettings.union, actions)

# If the action is a ban, mods don't want to be pinged.
if infr_action := result_actions.get("infraction_and_notification"):
if infr_action.infraction_type == Infraction.BAN:
result_actions.pop("mentions", None)
return result_actions, messages, triggers

async def _send_alert(self, ctx: FilterContext, triggered_filters: dict[FilterList, Iterable[str]]) -> None:
Expand Down
2 changes: 1 addition & 1 deletion bot/exts/moderation/infraction/management.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ async def infraction_edit(
capped, duration = _utils.cap_timeout_duration(expiry)
if capped:
await _utils.notify_timeout_cap(self.bot, ctx, user)
await user.edit(reason=reason, timed_out_until=expiry)
await user.edit(reason=reason, timed_out_until=duration)

log_text += f"""
Previous expiry: {time.until_expiration(infraction['expires_at'])}
Expand Down
20 changes: 10 additions & 10 deletions bot/exts/recruitment/talentpool/_cog.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,13 @@ async def nomination_group(self, ctx: Context) -> None:
await ctx.send_help(ctx.command)

@nomination_group.group(name="autoreview", aliases=("ar",), invoke_without_command=True)
@has_any_role(*MODERATION_ROLES)
@has_any_role(*MODERATION_ROLES, Roles.founders)
async def nomination_autoreview_group(self, ctx: Context) -> None:
"""Commands for enabling or disabling autoreview."""
await ctx.send_help(ctx.command)

@nomination_autoreview_group.command(name="enable", aliases=("on",))
@has_any_role(Roles.admins)
@has_any_role(Roles.admins, Roles.founders)
@commands.max_concurrency(1)
async def autoreview_enable(self, ctx: Context) -> None:
"""
Expand All @@ -167,7 +167,7 @@ async def autoreview_enable(self, ctx: Context) -> None:
await ctx.send(":white_check_mark: Autoreview enabled.")

@nomination_autoreview_group.command(name="disable", aliases=("off",))
@has_any_role(Roles.admins)
@has_any_role(Roles.admins, Roles.founders)
@commands.max_concurrency(1)
async def autoreview_disable(self, ctx: Context) -> None:
"""Disable automatic posting of reviews."""
Expand All @@ -183,7 +183,7 @@ async def autoreview_disable(self, ctx: Context) -> None:
await ctx.send(":white_check_mark: Autoreview disabled.")

@nomination_autoreview_group.command(name="status")
@has_any_role(*MODERATION_ROLES)
@has_any_role(*MODERATION_ROLES, Roles.founders)
async def autoreview_status(self, ctx: Context) -> None:
"""Show whether automatic posting of reviews is enabled or disabled."""
if await self.autoreview_enabled():
Expand Down Expand Up @@ -246,7 +246,7 @@ async def prune_talentpool(self) -> None:
aliases=("nominated", "nominees"),
invoke_without_command=True
)
@has_any_role(*MODERATION_ROLES)
@has_any_role(*MODERATION_ROLES, Roles.founders)
async def list_group(
self,
ctx: Context,
Expand Down Expand Up @@ -553,7 +553,7 @@ async def _nominate_user(self, ctx: Context, user: MemberOrUser, reason: str) ->
await self.maybe_relay_update(user.id, thread_update)

@nomination_group.command(name="history", aliases=("info", "search"))
@has_any_role(*MODERATION_ROLES)
@has_any_role(*MODERATION_ROLES, Roles.founders)
async def history_command(self, ctx: Context, user: MemberOrUser) -> None:
"""Shows the specified user's nomination history."""
result = await self.api.get_nominations(user.id, ordering="-active,-inserted_at")
Expand All @@ -577,7 +577,7 @@ async def history_command(self, ctx: Context, user: MemberOrUser) -> None:
)

@nomination_group.command(name="end", aliases=("unwatch", "unnominate"), root_aliases=("unnominate",))
@has_any_role(*MODERATION_ROLES)
@has_any_role(*MODERATION_ROLES, Roles.founders)
async def end_nomination_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None:
"""
Ends the active nomination of the specified user with the given reason.
Expand Down Expand Up @@ -769,7 +769,7 @@ async def _edit_nomination_reason(
await self.maybe_relay_update(nomination.user_id, thread_update)

@nomination_edit_group.command(name="end_reason")
@has_any_role(*MODERATION_ROLES)
@has_any_role(*MODERATION_ROLES, Roles.founders)
async def edit_end_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None:
"""Edits the unnominate reason for the nomination with the given `id`."""
if len(reason) > REASON_MAX_CHARS:
Expand All @@ -792,7 +792,7 @@ async def edit_end_reason_command(self, ctx: Context, nomination_id: int, *, rea
await ctx.send(f":white_check_mark: Updated the nomination end reason for <@{nomination.user_id}>.")

@nomination_group.command(aliases=("gr",))
@has_any_role(*MODERATION_ROLES)
@has_any_role(*MODERATION_ROLES, Roles.founders)
async def get_review(self, ctx: Context, user_id: int) -> None:
"""Get the user's review as a markdown file."""
nominations = await self.api.get_nominations(user_id, active=True)
Expand All @@ -808,7 +808,7 @@ async def get_review(self, ctx: Context, user_id: int) -> None:
await ctx.send(files=[review_file, nominations_file])

@nomination_group.command(aliases=("review",))
@has_any_role(*MODERATION_ROLES)
@has_any_role(*MODERATION_ROLES, Roles.founders)
async def post_review(self, ctx: Context, user_id: int) -> None:
"""Post the automatic review for the user ahead of time."""
nominations = await self.api.get_nominations(user_id, active=True)
Expand Down
159 changes: 153 additions & 6 deletions bot/exts/utils/reminders.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
WHITELISTED_CHANNELS = Guild.reminder_whitelist
MAXIMUM_REMINDERS = 5
REMINDER_EDIT_CONFIRMATION_TIMEOUT = 60
REMINDER_MENTION_BUTTON_TIMEOUT = 5*60
# The number of mentions that can be sent when a reminder arrives is limited by
# the 2000-character message limit.
MAXIMUM_REMINDER_MENTION_OPT_INS = 80

Mentionable = discord.Member | discord.Role
ReminderMention = UnambiguousUser | discord.Role
Expand Down Expand Up @@ -75,6 +79,137 @@ async def cancel(self, interaction: Interaction, button: discord.ui.Button) -> N
self.stop()


class OptInReminderMentionView(discord.ui.View):
"""A button to opt-in to get notified of someone else's reminder."""

def __init__(self, cog: "Reminders", reminder: dict, expiration: Duration):
super().__init__()

self.cog = cog
self.reminder = reminder

self.timeout = min(
(expiration - datetime.now(UTC)).total_seconds(),
REMINDER_MENTION_BUTTON_TIMEOUT
)

async def get_embed(
self,
message: str = "Click on the button to add yourself to the list of mentions."
) -> discord.Embed:
"""Return an embed to show the button together with."""
description = "The following user(s) will be notified when the reminder arrives:\n"
description += " ".join([
mentionable.mention async for mentionable in self.cog.get_mentionables(
[self.reminder["author"]] + self.reminder["mentions"]
)
])

if message:
description += f"\n\n{message}"

return discord.Embed(description=description)

@discord.ui.button(emoji="🔔", label="Notify me", style=discord.ButtonStyle.green)
async def button_callback(self, interaction: Interaction, button: discord.ui.Button) -> None:
"""The button callback."""
# This is required in case the reminder was edited/deleted between
# creation and the opt-in button click.
try:
api_response = await self.cog.bot.api_client.get(f"bot/reminders/{self.reminder['id']}")
except ResponseCodeError as e:
await self.handle_api_error(interaction, button, e)
return

self.reminder = api_response

# Check whether the user should be added.
if interaction.user.id == self.reminder["author"]:
await interaction.response.send_message(
"As the author of that reminder, you will already be notified when the reminder arrives.",
ephemeral=True,
)
return

if interaction.user.id in self.reminder["mentions"]:
await interaction.response.send_message(
"You are already in the list of mentions for that reminder.",
ephemeral=True,
delete_after=5,
)
return

if len(self.reminder["mentions"]) >= MAXIMUM_REMINDER_MENTION_OPT_INS:
await interaction.response.send_message(
"Sorry, this reminder has reached the maximum number of allowed mentions.",
ephemeral=True,
delete_after=5,
)
await self.disable(interaction, button, "Maximum number of allowed mentions reached!")
return

# Add the user to the list of mentions.
try:
api_response = await self.cog.add_mention_opt_in(self.reminder, interaction.user.id)
except ResponseCodeError as e:
await self.handle_api_error(interaction, button, e)
return

self.reminder = api_response

# Confirm that it was successful.
await interaction.response.send_message(
"You were successfully added to the list of mentions for that reminder.",
ephemeral=True,
delete_after=5,
)

# Update the embed to show the new list of mentions.
await interaction.message.edit(embed=await self.get_embed())

async def handle_api_error(
self,
interaction: Interaction,
button: discord.ui.Button,
error: ResponseCodeError
) -> None:
"""Handle a ResponseCodeError from the API responsibly."""
log.trace(f"API returned {error.status} for reminder #{self.reminder['id']}.")

if error.status == 404:
# This might happen if the reminder was edited to arrive before the
# button was initially scheduled to timeout.
await interaction.response.send_message(
"This reminder was either deleted or has already arrived.",
ephemeral=True,
delete_after=5,
)
# Don't delete the whole interaction message here or the user will
# see the above response message seemingly without context.
await self.disable(interaction, button)

else:
await interaction.response.send_message(
"Sorry, an unexpected error occurred when performing this operation.\n"
"Please create your own reminder instead.",
ephemeral=True,
delete_after=5,
)
await self.disable(
interaction,
button,
"An unexpected error occurred when attempting to add users."
)

async def disable(self, interaction: Interaction, button: discord.ui.Button, reason: str = "") -> None:
"""Disable the button and add an optional reason to the original interaction message."""
button.disabled = True
await interaction.message.edit(
embed=await self.get_embed(reason),
view=self,
)


class Reminders(Cog):
"""Provide in-channel reminder functionality."""

Expand Down Expand Up @@ -207,6 +342,18 @@ async def _reschedule_reminder(self, reminder: dict) -> None:
log.trace(f"Scheduling new task #{reminder['id']}")
self.schedule_reminder(reminder)

@lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True)
async def add_mention_opt_in(self, reminder: dict, user_id: int) -> dict:
"""Add an opt-in user to a reminder's mentions and return the edited reminder."""
if user_id in reminder["mentions"] or user_id == reminder["author"]:
return reminder

reminder["mentions"].append(user_id)
reminder = await self._edit_reminder(reminder["id"], {"mentions": reminder["mentions"]})

await self._reschedule_reminder(reminder)
return reminder

@lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True)
async def send_reminder(self, reminder: dict, expected_time: time.Timestamp | None = None) -> None:
"""Send the reminder."""
Expand Down Expand Up @@ -360,19 +507,19 @@ async def new_reminder(
)

formatted_time = time.discord_timestamp(expiration, time.TimestampFormats.DAY_TIME)
mention_string = f"Your reminder will arrive on {formatted_time}"

if mentions:
mention_string += f" and will mention {len(mentions)} other(s)"
mention_string += "!"
success_message = f"Your reminder will arrive on {formatted_time}!"

# Confirm to the user that it worked.
await self._send_confirmation(
ctx,
on_success=mention_string,
on_success=success_message,
reminder_id=reminder["id"]
)

# Add a button for others to also get notified.
view = OptInReminderMentionView(self, reminder, expiration)
await ctx.send(embed=await view.get_embed(), view=view, delete_after=view.timeout)

self.schedule_reminder(reminder)

@remind_group.command(name="list")
Expand Down
Loading