Skip to content

Commit 955f415

Browse files
authored
feat: handle interaction errors (#336)
This PR aims to have better error handling. When a `ui.Modal` has an error, it will now properly respond to that error. The `followup` section closes any open `thinking` portions. Meaning, you can safely add in `thinking` without it stalling on an error (message is replaced with note of something going wrong.) That same `followup` catch is applied to the tree error handler. For the `ui.View`, I am only informing the user that something went wrong if the interaction was completed. This ensures that a deferred message is responded to. But more so, that if an interaction is not handled in 5 seconds, the built-in failure message is sent (a much cleaner looking error message) Ideally, this closes #150 <details><summary>Why new file for errors?</summary> <p> To avoid circular imports. If I put that logic in `core.py` it would cause circular import hell. </p> </details> ## Decision I don't love the names `ErrorModal` and `ErrorView` ,,, open to much better suggestions for names!
2 parents 2b12caf + b5edf4e commit 955f415

File tree

6 files changed

+92
-52
lines changed

6 files changed

+92
-52
lines changed

app/__main__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import sentry_sdk
22

3-
from app.core import bot, config # pyright: ignore[reportPrivateLocalImportUsage]
3+
# Import bot from `core` instead of `setup` as core is otherwise never loaded.
4+
from app.core import bot # pyright: ignore[reportPrivateLocalImportUsage]
5+
from app.setup import config
46

57
if config.sentry_dsn is not None:
68
sentry_sdk.init(

app/common/hooks.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import discord as dc
99

10+
from app.errors import SafeView
1011
from app.utils import is_dm, is_mod, safe_edit
1112

1213

@@ -80,7 +81,7 @@ def is_expired(self, message: dc.Message) -> bool:
8081
return message.created_at < self.expiry_threshold
8182

8283

83-
class ItemActions(dc.ui.View):
84+
class ItemActions(SafeView):
8485
linker: ClassVar[MessageLinker]
8586
action_singular: ClassVar[str]
8687
action_plural: ClassVar[str]

app/components/autoclose.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,12 @@ async def autoclose_solved_posts() -> None:
2424
continue
2525
one_day_ago = dt.datetime.now(tz=dt.UTC) - dt.timedelta(hours=24)
2626
if dc.utils.snowflake_time(post.last_message_id) < one_day_ago:
27-
await post.edit(archived=True)
28-
closed_posts.append(post)
27+
try:
28+
await post.edit(archived=True)
29+
closed_posts.append(post)
30+
except dc.HTTPException:
31+
failures.append(post)
32+
continue
2933

3034
bot_status.last_scan_results = (
3135
dt.datetime.now(tz=dt.UTC),

app/components/move_message.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
message_can_be_moved,
1616
move_message_via_webhook,
1717
)
18+
from app.errors import SafeModal, SafeView
1819
from app.setup import bot, config
1920
from app.utils import (
2021
MAX_ATTACHMENT_SIZE,
@@ -120,7 +121,7 @@
120121

121122

122123
@final
123-
class DeleteInstead(dc.ui.View):
124+
class DeleteInstead(SafeView):
124125
def __init__(self, message: dc.Message) -> None:
125126
super().__init__()
126127
self.message = message
@@ -237,7 +238,7 @@ async def _remove_edit_thread_after_timeout(thread: dc.Thread, author: Account)
237238

238239

239240
@final
240-
class SelectChannel(dc.ui.View):
241+
class SelectChannel(SafeView):
241242
def __init__(self, message: dc.Message, executor: dc.Member) -> None:
242243
super().__init__()
243244
self.message = message
@@ -283,7 +284,7 @@ async def select_channel(
283284

284285

285286
@final
286-
class Ghostping(dc.ui.View):
287+
class Ghostping(SafeView):
287288
def __init__(self, author: dc.Member, channel: GuildTextChannel) -> None:
288289
super().__init__()
289290
self._author = author
@@ -306,7 +307,7 @@ async def ghostping(
306307

307308

308309
@final
309-
class HelpPostTitle(dc.ui.Modal, title="Turn into #help post"):
310+
class HelpPostTitle(SafeModal, title="Turn into #help post"):
310311
title_ = dc.ui.TextInput[Self](
311312
label="#help post title", style=dc.TextStyle.short, max_length=100
312313
)
@@ -317,7 +318,7 @@ def __init__(self, message: dc.Message) -> None:
317318

318319
@override
319320
async def on_submit(self, interaction: dc.Interaction) -> None:
320-
await interaction.response.defer(ephemeral=True)
321+
await interaction.response.defer(ephemeral=True, thinking=True)
321322

322323
webhook = await get_or_create_webhook(config.help_channel)
323324
msg = await move_message_via_webhook(
@@ -335,7 +336,7 @@ async def on_submit(self, interaction: dc.Interaction) -> None:
335336

336337

337338
@final
338-
class ChooseMessageAction(dc.ui.View):
339+
class ChooseMessageAction(SafeView):
339340
thread_button: dc.ui.Button[Self]
340341
help_button: dc.ui.Button[Self]
341342

@@ -491,7 +492,7 @@ async def show_help(self, interaction: dc.Interaction) -> None:
491492

492493

493494
@final
494-
class EditMessage(dc.ui.Modal, title="Edit Message"):
495+
class EditMessage(SafeModal, title="Edit Message"):
495496
new_text = dc.ui.TextInput[Self](
496497
label="New message content",
497498
style=dc.TextStyle.long,
@@ -518,7 +519,7 @@ async def on_submit(self, interaction: dc.Interaction) -> None:
518519

519520

520521
@final
521-
class DeleteAttachments(dc.ui.View):
522+
class DeleteAttachments(SafeView):
522523
select: dc.ui.Select[Self]
523524

524525
def __init__(
@@ -555,7 +556,7 @@ async def remove_attachments(self, interaction: dc.Interaction) -> None:
555556

556557

557558
@final
558-
class CancelEditing(dc.ui.View):
559+
class CancelEditing(SafeView):
559560
def __init__(self, thread: dc.Thread) -> None:
560561
super().__init__()
561562
self._thread = thread
@@ -579,7 +580,7 @@ async def cancel_editing(
579580

580581

581582
@final
582-
class ContinueEditing(dc.ui.View):
583+
class ContinueEditing(SafeView):
583584
def __init__(self, thread: dc.Thread) -> None:
584585
super().__init__()
585586
self._thread = thread
@@ -605,7 +606,7 @@ async def cancel_editing(
605606

606607

607608
@final
608-
class SkipLargeAttachments(dc.ui.View):
609+
class SkipLargeAttachments(SafeView):
609610
def __init__(
610611
self, message: dc.Message, state: ThreadState, new_content: str
611612
) -> None:
@@ -641,7 +642,7 @@ async def skip_large_attachments(
641642

642643

643644
@final
644-
class AttachmentChoice(dc.ui.View):
645+
class AttachmentChoice(SafeView):
645646
def __init__(self, message: dc.Message, state: ThreadState) -> None:
646647
super().__init__()
647648
self._message = message

app/core.py

Lines changed: 2 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
import asyncio
22
import datetime as dt
3-
import sys
4-
from contextlib import suppress
5-
from typing import cast
63

74
import discord as dc
85
from discord.ext import commands
96
from loguru import logger
10-
from sentry_sdk import capture_exception
117

128
from app.components.activity_status import randomize_activity_status
139
from app.components.autoclose import autoclose_solved_posts
@@ -46,7 +42,8 @@
4642
zig_codeblock_delete_hook,
4743
zig_codeblock_edit_hook,
4844
)
49-
from app.setup import bot, config
45+
from app.errors import handle_task_error
46+
from app.setup import bot
5047
from app.utils import is_dm, is_mod, try_dm
5148

5249
TASKS = (autoclose_solved_posts, randomize_activity_status, update_recent_mentions)
@@ -70,20 +67,6 @@ async def on_ready() -> None:
7067
logger.info("logged in as {}", bot.user)
7168

7269

73-
@bot.event
74-
async def on_error(*_: object) -> None:
75-
handle_error(cast("BaseException", sys.exc_info()[1]))
76-
77-
78-
@bot.tree.error
79-
async def on_app_command_error(interaction: dc.Interaction, error: Exception) -> None:
80-
if not interaction.response.is_done():
81-
await interaction.response.send_message(
82-
"Something went wrong :(", ephemeral=True
83-
)
84-
handle_error(error)
85-
86-
8770
@bot.event
8871
async def on_message(message: dc.Message) -> None:
8972
# Ignore our own messages
@@ -156,20 +139,3 @@ async def sync(bot: commands.Bot, message: dc.Message) -> None:
156139
refresh_sitemap()
157140
await bot.tree.sync()
158141
await try_dm(message.author, "Command tree synced.")
159-
160-
161-
def handle_task_error(task: asyncio.Task[None]) -> None:
162-
with suppress(asyncio.CancelledError):
163-
if exc := task.exception():
164-
handle_error(exc)
165-
166-
167-
def handle_error(error: BaseException) -> None:
168-
if config.sentry_dsn is not None:
169-
capture_exception(error)
170-
return
171-
logger.exception(error)
172-
for note in getattr(error, "__notes__", []):
173-
logger.error(note)
174-
if isinstance(error, dc.app_commands.CommandInvokeError):
175-
handle_error(error.original)

app/errors.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import asyncio
2+
import sys
3+
from contextlib import suppress
4+
from typing import Any, cast, override
5+
6+
import discord as dc
7+
from loguru import logger
8+
from sentry_sdk import capture_exception
9+
10+
from app.setup import bot, config
11+
12+
13+
def handle_task_error(task: asyncio.Task[None]) -> None:
14+
with suppress(asyncio.CancelledError):
15+
if exc := task.exception():
16+
handle_error(exc)
17+
18+
19+
def handle_error(error: BaseException) -> None:
20+
if config.sentry_dsn is not None:
21+
capture_exception(error)
22+
return
23+
logger.exception(error)
24+
for note in getattr(error, "__notes__", []):
25+
logger.error(note)
26+
if isinstance(error, dc.app_commands.CommandInvokeError):
27+
handle_error(error.original)
28+
29+
30+
@bot.event
31+
async def on_error(*_: object) -> None:
32+
handle_error(cast("BaseException", sys.exc_info()[1]))
33+
34+
35+
async def interaction_error_handler(
36+
interaction: dc.Interaction, error: Exception, /
37+
) -> None:
38+
if not interaction.response.is_done():
39+
await interaction.response.send_message(
40+
"Something went wrong :(", ephemeral=True
41+
)
42+
else:
43+
await interaction.followup.send("Something went wrong :(", ephemeral=True)
44+
handle_error(error)
45+
46+
47+
bot.tree.on_error = interaction_error_handler
48+
49+
50+
class SafeModal(dc.ui.Modal):
51+
@override
52+
async def on_error(self, interaction: dc.Interaction, error: Exception, /) -> None:
53+
return await interaction_error_handler(interaction, error)
54+
55+
56+
class SafeView(dc.ui.View):
57+
@override
58+
async def on_error(
59+
self, interaction: dc.Interaction, error: Exception, item: dc.ui.Item[Any], /
60+
) -> None:
61+
if interaction.response.is_done():
62+
await interaction.followup.send("Something went wrong :(", ephemeral=True)
63+
# else: don't complete interaction,
64+
# letting discord client send red error message
65+
66+
handle_error(error)

0 commit comments

Comments
 (0)