Skip to content

Commit 85c1f42

Browse files
Merge pull request #88 from discord-modmail/feat-responses
feat: add helper responses module
2 parents 9c5d9dd + bbef578 commit 85c1f42

File tree

3 files changed

+269
-1
lines changed

3 files changed

+269
-1
lines changed

modmail/extensions/utils/error_handler.py

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

99
from modmail.bot import ModmailBot
1010
from modmail.log import ModmailLogger
11+
from modmail.utils import responses
1112
from modmail.utils.cogs import BotModes, ExtMetadata, ModmailCog
1213
from modmail.utils.extensions import BOT_MODE
1314

@@ -16,7 +17,7 @@
1617

1718
EXT_METADATA = ExtMetadata()
1819

19-
ERROR_COLOUR = discord.Colour.red()
20+
ERROR_COLOUR = responses.DEFAULT_FAILURE_COLOUR
2021

2122
ERROR_TITLE_REGEX = re.compile(r"((?<=[a-z])[A-Z]|(?<=[a-zA-Z])[A-Z](?=[a-z]))")
2223

modmail/utils/responses.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""
2+
Helper methods for responses from the bot to the user.
3+
4+
These help ensure consistency between errors, as they will all be consistent between different uses.
5+
6+
Note: these are to used for general success or general errors. Typically, the error handler will make a
7+
response if a command raises a discord.ext.commands.CommandError exception.
8+
"""
9+
import logging
10+
import random
11+
import typing
12+
13+
import discord
14+
from discord.ext import commands
15+
16+
from modmail.log import ModmailLogger
17+
18+
19+
__all__ = (
20+
"DEFAULT_SUCCESS_COLOUR",
21+
"DEFAULT_SUCCESS_COLOR",
22+
"SUCCESS_HEADERS",
23+
"DEFAULT_FAILURE_COLOUR",
24+
"DEFAULT_FAILURE_COLOR",
25+
"FAILURE_HEADERS",
26+
"send_general_response",
27+
"send_positive_response",
28+
"send_negatory_response",
29+
)
30+
31+
_UNSET = object()
32+
33+
logger: ModmailLogger = logging.getLogger(__name__)
34+
35+
36+
DEFAULT_SUCCESS_COLOUR = discord.Colour.green()
37+
DEFAULT_SUCCESS_COLOR = DEFAULT_SUCCESS_COLOUR
38+
SUCCESS_HEADERS: typing.List[str] = [
39+
"Affirmative",
40+
"As you wish",
41+
"Done",
42+
"Fine by me",
43+
"There we go",
44+
"Sure!",
45+
"Okay",
46+
"You got it",
47+
"Your wish is my command",
48+
]
49+
50+
DEFAULT_FAILURE_COLOUR = discord.Colour.red()
51+
DEFAULT_FAILURE_COLOR = DEFAULT_FAILURE_COLOUR
52+
FAILURE_HEADERS: typing.List[str] = [
53+
"Abort!",
54+
"I cannot do that",
55+
"Hold up!",
56+
"I was unable to interpret that",
57+
"Not understood",
58+
"Oops",
59+
"Something went wrong",
60+
"\U0001f914",
61+
"Unable to complete your command",
62+
]
63+
64+
65+
async def send_general_response(
66+
channel: discord.abc.Messageable,
67+
response: str,
68+
*,
69+
message: discord.Message = None,
70+
embed: discord.Embed = _UNSET,
71+
colour: discord.Colour = None,
72+
title: str = None,
73+
tag_as: typing.Literal["general", "affirmative", "negatory"] = "general",
74+
**kwargs,
75+
) -> discord.Message:
76+
"""
77+
Helper method to send a response.
78+
79+
Shortcuts are provided as `send_positive_response` and `send_negatory_response` which
80+
fill in the title and colour automatically.
81+
"""
82+
kwargs["allowed_mentions"] = kwargs.get("allowed_mentions", discord.AllowedMentions.none())
83+
84+
if isinstance(channel, commands.Context): # pragma: nocover
85+
channel = channel.channel
86+
87+
logger.debug(f"Requested to send {tag_as} response message to {channel!s}. Response: {response!s}")
88+
89+
if embed is None:
90+
if message is None:
91+
return await channel.send(response, **kwargs)
92+
else:
93+
return await message.edit(response, **kwargs)
94+
95+
if embed is _UNSET: # pragma: no branch
96+
embed = discord.Embed(colour=colour or discord.Embed.Empty)
97+
98+
if title is not None:
99+
embed.title = title
100+
101+
embed.description = response
102+
103+
if message is None:
104+
return await channel.send(embed=embed, **kwargs)
105+
else:
106+
return await message.edit(embed=embed, **kwargs)
107+
108+
109+
async def send_positive_response(
110+
channel: discord.abc.Messageable,
111+
response: str,
112+
*,
113+
colour: discord.Colour = _UNSET,
114+
**kwargs,
115+
) -> discord.Message:
116+
"""
117+
Send an affirmative response.
118+
119+
Requires a messageable, and a response.
120+
If embed is set to None, this will send response as a plaintext message, with no allowed_mentions.
121+
If embed is provided, this method will send a response using the provided embed, edited in place.
122+
Extra kwargs are passed to Messageable.send()
123+
124+
If message is provided, it will attempt to edit that message rather than sending a new one.
125+
"""
126+
if colour is _UNSET: # pragma: no branch
127+
colour = DEFAULT_SUCCESS_COLOUR
128+
129+
kwargs["title"] = kwargs.get("title", random.choice(SUCCESS_HEADERS))
130+
131+
return await send_general_response(
132+
channel=channel,
133+
response=response,
134+
colour=colour,
135+
tag_as="affirmative",
136+
**kwargs,
137+
)
138+
139+
140+
async def send_negatory_response(
141+
channel: discord.abc.Messageable,
142+
response: str,
143+
*,
144+
colour: discord.Colour = _UNSET,
145+
**kwargs,
146+
) -> discord.Message:
147+
"""
148+
Send a negatory response.
149+
150+
Requires a messageable, and a response.
151+
If embed is set to None, this will send response as a plaintext message, with no allowed_mentions.
152+
If embed is provided, this method will send a response using the provided embed, edited in place.
153+
Extra kwargs are passed to Messageable.send()
154+
"""
155+
if colour is _UNSET: # pragma: no branch
156+
colour = DEFAULT_FAILURE_COLOUR
157+
158+
kwargs["title"] = kwargs.get("title", random.choice(FAILURE_HEADERS))
159+
160+
return await send_general_response(
161+
channel=channel,
162+
response=response,
163+
colour=colour,
164+
tag_as="negatory",
165+
**kwargs,
166+
)
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import typing
2+
import unittest.mock
3+
4+
import discord
5+
import pytest
6+
7+
from modmail.utils import responses
8+
from tests import mocks
9+
10+
11+
@pytest.fixture
12+
async def mock_channel() -> mocks.MockTextChannel:
13+
"""Fixture for a channel."""
14+
return mocks.MockTextChannel()
15+
16+
17+
@pytest.fixture
18+
async def mock_message() -> mocks.MockMessage:
19+
"""Fixture for a message."""
20+
return mocks.MockMessage()
21+
22+
23+
@pytest.mark.asyncio
24+
async def test_general_response_embed(mock_channel: mocks.MockTextChannel) -> None:
25+
"""Test the positive response embed is correct when sending a new message."""
26+
content = "success!"
27+
_ = await responses.send_general_response(mock_channel, content)
28+
29+
assert len(mock_channel.send.mock_calls) == 1
30+
31+
_, _, called_kwargs = mock_channel.send.mock_calls[0]
32+
sent_embed: discord.Embed = called_kwargs["embed"]
33+
34+
assert content == sent_embed.description
35+
36+
37+
@pytest.mark.asyncio
38+
async def test_no_embed(mock_channel: mocks.MockTextChannel):
39+
"""Test general response without an embed."""
40+
content = "Look ma, no embed!"
41+
_ = await responses.send_general_response(mock_channel, content, embed=None)
42+
43+
assert len(mock_channel.send.mock_calls) == 1
44+
45+
_, called_args, _ = mock_channel.send.mock_calls[0]
46+
assert content == called_args[0]
47+
48+
49+
@pytest.mark.asyncio
50+
async def test_no_embed_edit(mock_message: mocks.MockMessage):
51+
"""Test general response without an embed."""
52+
content = "Look ma, no embed!"
53+
_ = await responses.send_general_response(None, content, embed=None, message=mock_message)
54+
55+
assert len(mock_message.edit.mock_calls) == 1
56+
57+
_, called_args, _ = mock_message.edit.mock_calls[0]
58+
assert content == called_args[0]
59+
60+
61+
@pytest.mark.asyncio
62+
async def test_general_response_embed_edit(mock_message: mocks.MockMessage) -> None:
63+
"""Test the positive response embed is correct when editing a message."""
64+
content = "hello, the code worked I guess!"
65+
_ = await responses.send_general_response(None, content, message=mock_message)
66+
67+
assert len(mock_message.edit.mock_calls) == 1
68+
69+
_, _, called_kwargs = mock_message.edit.mock_calls[0]
70+
sent_embed: discord.Embed = called_kwargs["embed"]
71+
72+
assert content == sent_embed.description
73+
74+
75+
def test_colour_aliases():
76+
"""Test colour aliases are the same."""
77+
assert responses.DEFAULT_FAILURE_COLOR == responses.DEFAULT_FAILURE_COLOUR
78+
assert responses.DEFAULT_SUCCESS_COLOR == responses.DEFAULT_SUCCESS_COLOUR
79+
80+
81+
@pytest.mark.parametrize(
82+
["coro", "color", "title_list"],
83+
[
84+
[responses.send_positive_response, responses.DEFAULT_SUCCESS_COLOUR.value, responses.SUCCESS_HEADERS],
85+
[responses.send_negatory_response, responses.DEFAULT_FAILURE_COLOUR.value, responses.FAILURE_HEADERS],
86+
],
87+
)
88+
@pytest.mark.asyncio
89+
async def test_special_responses(
90+
mock_channel: mocks.MockTextChannel, coro, color: int, title_list: typing.List
91+
):
92+
"""Test the positive and negatory response methods."""
93+
_: unittest.mock.AsyncMock = await coro(mock_channel, "")
94+
95+
assert len(mock_channel.send.mock_calls) == 1
96+
97+
_, _, kwargs = mock_channel.send.mock_calls[0]
98+
embed_dict: dict = kwargs["embed"].to_dict()
99+
100+
assert color == embed_dict.get("color")
101+
assert embed_dict.get("title") in title_list

0 commit comments

Comments
 (0)