simcord 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simcord/__init__.py +50 -0
- simcord/_dpy_internals.py +86 -0
- simcord/actors.py +393 -0
- simcord/backend/__init__.py +11 -0
- simcord/backend/cdn.py +30 -0
- simcord/backend/errors.py +84 -0
- simcord/backend/models/__init__.py +31 -0
- simcord/backend/models/channel.py +53 -0
- simcord/backend/models/guild.py +28 -0
- simcord/backend/models/interaction.py +97 -0
- simcord/backend/models/member.py +15 -0
- simcord/backend/models/message.py +61 -0
- simcord/backend/models/role.py +15 -0
- simcord/backend/models/user.py +10 -0
- simcord/backend/models/webhook.py +14 -0
- simcord/backend/permissions.py +70 -0
- simcord/backend/serializers.py +291 -0
- simcord/backend/state.py +666 -0
- simcord/builders.py +230 -0
- simcord/enums.py +58 -0
- simcord/env.py +381 -0
- simcord/gateway.py +31 -0
- simcord/http/__init__.py +11 -0
- simcord/http/_helpers.py +76 -0
- simcord/http/client.py +158 -0
- simcord/http/router.py +141 -0
- simcord/http/routes/__init__.py +13 -0
- simcord/http/routes/application.py +48 -0
- simcord/http/routes/channels.py +130 -0
- simcord/http/routes/commands.py +27 -0
- simcord/http/routes/guilds.py +202 -0
- simcord/http/routes/interactions.py +140 -0
- simcord/http/routes/messages.py +112 -0
- simcord/http/routes/reactions.py +45 -0
- simcord/interactions.py +177 -0
- simcord/parity.py +63 -0
- simcord/py.typed +0 -0
- simcord/pytest_plugin.py +59 -0
- simcord/results.py +118 -0
- simcord-0.1.0.dist-info/METADATA +210 -0
- simcord-0.1.0.dist-info/RECORD +44 -0
- simcord-0.1.0.dist-info/WHEEL +4 -0
- simcord-0.1.0.dist-info/entry_points.txt +2 -0
- simcord-0.1.0.dist-info/licenses/LICENSE +21 -0
simcord/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""discord-py-test: offline testing framework for discord.py bots.
|
|
2
|
+
|
|
3
|
+
Run your real, unmodified bot against a virtual in-memory Discord — no
|
|
4
|
+
network, no tokens, no Terms of Service concerns — and test prefix commands,
|
|
5
|
+
slash commands, components, permissions and events the way a user exercises
|
|
6
|
+
them.
|
|
7
|
+
|
|
8
|
+
Typical usage::
|
|
9
|
+
|
|
10
|
+
import simcord as dpt
|
|
11
|
+
|
|
12
|
+
async with dpt.run(bot) as env:
|
|
13
|
+
guild = env.create_guild()
|
|
14
|
+
channel = guild.create_text_channel("general")
|
|
15
|
+
alice = guild.add_member(env.create_user("alice"))
|
|
16
|
+
|
|
17
|
+
await alice.send(channel, "!ping")
|
|
18
|
+
assert channel.last_message.content == "Pong!"
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from . import _dpy_internals
|
|
22
|
+
|
|
23
|
+
_dpy_internals.verify()
|
|
24
|
+
|
|
25
|
+
from .actors import MemberActor # noqa: E402
|
|
26
|
+
from .backend import Backend # noqa: E402, F401 — importable for advanced use, but NOT public API:
|
|
27
|
+
|
|
28
|
+
# Backend's methods and payload shapes are internal and may change in any release.
|
|
29
|
+
from .backend.errors import BackendError, SetupError # noqa: E402
|
|
30
|
+
from .builders import ChannelHandle, GuildHandle, RoleHandle, UserHandle # noqa: E402
|
|
31
|
+
from .env import Env, run # noqa: E402
|
|
32
|
+
from .http import RouteNotImplemented # noqa: E402
|
|
33
|
+
from .results import InteractionResult, ResponseMessage # noqa: E402
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
|
|
37
|
+
__all__ = (
|
|
38
|
+
"BackendError",
|
|
39
|
+
"ChannelHandle",
|
|
40
|
+
"Env",
|
|
41
|
+
"GuildHandle",
|
|
42
|
+
"InteractionResult",
|
|
43
|
+
"MemberActor",
|
|
44
|
+
"ResponseMessage",
|
|
45
|
+
"RoleHandle",
|
|
46
|
+
"RouteNotImplemented",
|
|
47
|
+
"SetupError",
|
|
48
|
+
"UserHandle",
|
|
49
|
+
"run",
|
|
50
|
+
)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Every touch of a private discord.py API, quarantined in one module.
|
|
2
|
+
|
|
3
|
+
If a discord.py release changes one of these internals, the import-time
|
|
4
|
+
self-check below fails with a clear message instead of users' tests breaking
|
|
5
|
+
mysteriously. Keep this inventory in sync with what the framework touches.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import discord
|
|
13
|
+
from discord.http import HTTPClient
|
|
14
|
+
from discord.state import ConnectionState
|
|
15
|
+
from discord.ui.view import View
|
|
16
|
+
from discord.webhook.async_ import async_context
|
|
17
|
+
|
|
18
|
+
#: discord.py coroutine qualnames that are long-lived background machinery
|
|
19
|
+
#: (not work to settle on). Their leaf names are matched against running tasks
|
|
20
|
+
#: in :meth:`Env.settle`; keep them here so a discord.py rename is caught by
|
|
21
|
+
#: ``verify()`` rather than by users' tests hanging mysteriously.
|
|
22
|
+
BACKGROUND_CORO_NAMES = ("__timeout_task_impl",)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def verify() -> None:
|
|
26
|
+
"""Sanity-check the discord.py internals this framework relies on."""
|
|
27
|
+
if discord.version_info.major != 2 or discord.version_info.minor < 7:
|
|
28
|
+
raise ImportError(
|
|
29
|
+
f"discord-py-test requires discord.py 2.7+; found {discord.__version__}. "
|
|
30
|
+
"Check https://github.com/SilentHacks/discord-py-test for supported versions."
|
|
31
|
+
)
|
|
32
|
+
problems = []
|
|
33
|
+
for cls, attr in (
|
|
34
|
+
(HTTPClient, "request"),
|
|
35
|
+
(HTTPClient, "static_login"),
|
|
36
|
+
(HTTPClient, "get_from_cdn"),
|
|
37
|
+
(ConnectionState, "parse_ready"),
|
|
38
|
+
(ConnectionState, "parse_message_create"),
|
|
39
|
+
(ConnectionState, "parse_interaction_create"),
|
|
40
|
+
):
|
|
41
|
+
if not hasattr(cls, attr):
|
|
42
|
+
problems.append(f"{cls.__name__}.{attr}")
|
|
43
|
+
# The background-coro names are matched by leaf qualname; confirm they still
|
|
44
|
+
# exist on View so a rename surfaces here instead of in settle().
|
|
45
|
+
for name in BACKGROUND_CORO_NAMES:
|
|
46
|
+
if not any(attr.endswith(name) for attr in dir(View)):
|
|
47
|
+
problems.append(f"View.*{name}")
|
|
48
|
+
if problems:
|
|
49
|
+
raise ImportError(
|
|
50
|
+
"This discord.py version changed internals discord-py-test depends on: "
|
|
51
|
+
+ ", ".join(problems)
|
|
52
|
+
+ ". Please report this at https://github.com/SilentHacks/discord-py-test/issues."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_state(client: discord.Client) -> Any:
|
|
57
|
+
"""The client's ConnectionState (cache + gateway event parsers)."""
|
|
58
|
+
return client._connection
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parsers(client: discord.Client) -> dict[str, Any]:
|
|
62
|
+
return get_state(client).parsers
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def install_http(client: discord.Client, http: HTTPClient) -> None:
|
|
66
|
+
"""Point every captured HTTP reference at the fake transport."""
|
|
67
|
+
client.http = http # type: ignore[misc]
|
|
68
|
+
get_state(client).http = http
|
|
69
|
+
tree = getattr(client, "tree", None)
|
|
70
|
+
if tree is not None:
|
|
71
|
+
# CommandTree captures its own HTTP reference at construction time.
|
|
72
|
+
tree._http = http
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def set_guild_ready_timeout(client: discord.Client, timeout: float) -> None:
|
|
76
|
+
"""No guilds arrive before our READY; don't wait for stragglers."""
|
|
77
|
+
get_state(client).guild_ready_timeout = timeout
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def set_webhook_adapter(adapter: Any) -> Any:
|
|
81
|
+
"""Interaction responses go through this context-local adapter, not HTTPClient."""
|
|
82
|
+
return async_context.set(adapter)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def reset_webhook_adapter(token: Any) -> None:
|
|
86
|
+
async_context.reset(token)
|
simcord/actors.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Actors: simulated humans that drive the bot through the gateway.
|
|
2
|
+
|
|
3
|
+
Everything an actor does is permission-checked and validated against what a
|
|
4
|
+
real user could physically do in the Discord client (no clicking disabled or
|
|
5
|
+
missing buttons, no invoking unsynced commands, no speaking in hidden
|
|
6
|
+
channels), then delivered as authentic gateway events.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
import discord
|
|
15
|
+
|
|
16
|
+
from . import interactions as _interactions
|
|
17
|
+
from .backend import serializers
|
|
18
|
+
from .backend.errors import SetupError
|
|
19
|
+
from .builders import ChannelHandle, GuildHandle, UserHandle
|
|
20
|
+
from .enums import ComponentType, InteractionType
|
|
21
|
+
from .results import InteractionResult, ResponseMessage, to_discord_message
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from .env import Env
|
|
25
|
+
|
|
26
|
+
MessageLike = discord.Message | ResponseMessage
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MemberActor:
|
|
30
|
+
"""A guild member that acts like a real human user."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, env: Env, guild: GuildHandle, user: UserHandle) -> None:
|
|
33
|
+
self._env = env
|
|
34
|
+
self.guild = guild
|
|
35
|
+
self.user = user
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def id(self) -> int:
|
|
39
|
+
return self.user.id
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def name(self) -> str:
|
|
43
|
+
return self.user.name
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def mention(self) -> str:
|
|
47
|
+
return self.user.mention
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def member(self) -> discord.Member | None:
|
|
51
|
+
cached = self._env.bot.get_guild(self.guild.id)
|
|
52
|
+
return cached.get_member(self.id) if cached else None
|
|
53
|
+
|
|
54
|
+
def _check(self, channel: ChannelHandle, *permissions: str) -> None:
|
|
55
|
+
self._env.backend.require_permissions(self.guild.id, self.id, channel.id, *permissions)
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------ text
|
|
58
|
+
|
|
59
|
+
async def send(
|
|
60
|
+
self,
|
|
61
|
+
channel: ChannelHandle,
|
|
62
|
+
content: str = "",
|
|
63
|
+
*,
|
|
64
|
+
reply_to: MessageLike | None = None,
|
|
65
|
+
attachments: Sequence[tuple[str, bytes]] = (),
|
|
66
|
+
) -> discord.Message:
|
|
67
|
+
backend = self._env.backend
|
|
68
|
+
perm = "send_messages_in_threads" if channel.is_thread else "send_messages"
|
|
69
|
+
self._check(channel, perm)
|
|
70
|
+
reference = None
|
|
71
|
+
if reply_to is not None:
|
|
72
|
+
reference = {"channel_id": str(channel.id), "message_id": str(reply_to.id)}
|
|
73
|
+
attachment_payloads = [
|
|
74
|
+
backend.cdn.store_attachment(backend.snowflake(), channel.id, filename, data, None)
|
|
75
|
+
for filename, data in attachments
|
|
76
|
+
]
|
|
77
|
+
message = backend.create_message(
|
|
78
|
+
channel.id,
|
|
79
|
+
self.id,
|
|
80
|
+
content,
|
|
81
|
+
reference=reference,
|
|
82
|
+
attachments=attachment_payloads,
|
|
83
|
+
)
|
|
84
|
+
await self._env.settle()
|
|
85
|
+
return to_discord_message(self._env, message)
|
|
86
|
+
|
|
87
|
+
async def edit(self, message: MessageLike, content: str) -> None:
|
|
88
|
+
stored = self._env.backend.get_message(_channel_id_of(message), message.id)
|
|
89
|
+
if stored.author_id != self.id:
|
|
90
|
+
raise SetupError("Users can only edit their own messages")
|
|
91
|
+
self._env.backend.edit_message(stored.channel_id, stored.id, {"content": content})
|
|
92
|
+
await self._env.settle()
|
|
93
|
+
|
|
94
|
+
async def delete(self, message: MessageLike) -> None:
|
|
95
|
+
stored = self._env.backend.get_message(_channel_id_of(message), message.id)
|
|
96
|
+
if stored.author_id != self.id:
|
|
97
|
+
self._env.backend.require_permissions(
|
|
98
|
+
self.guild.id, self.id, stored.channel_id, "manage_messages"
|
|
99
|
+
)
|
|
100
|
+
self._env.backend.delete_message(stored.channel_id, stored.id)
|
|
101
|
+
await self._env.settle()
|
|
102
|
+
|
|
103
|
+
async def typing(self, channel: ChannelHandle) -> None:
|
|
104
|
+
self._check(channel, "send_messages")
|
|
105
|
+
backend = self._env.backend
|
|
106
|
+
guild = backend.get_guild(self.guild.id)
|
|
107
|
+
payload = {
|
|
108
|
+
"channel_id": str(channel.id),
|
|
109
|
+
"user_id": str(self.id),
|
|
110
|
+
"timestamp": 0,
|
|
111
|
+
"guild_id": str(self.guild.id),
|
|
112
|
+
"member": serializers.member_payload(backend, guild, guild.members[self.id]),
|
|
113
|
+
}
|
|
114
|
+
backend.emit("TYPING_START", payload)
|
|
115
|
+
await self._env.settle()
|
|
116
|
+
|
|
117
|
+
async def react(self, message: MessageLike, emoji: str) -> None:
|
|
118
|
+
backend = self._env.backend
|
|
119
|
+
stored = backend.get_message(_channel_id_of(message), message.id)
|
|
120
|
+
backend.require_permissions(self.guild.id, self.id, stored.channel_id, "add_reactions")
|
|
121
|
+
backend.add_reaction(stored.channel_id, stored.id, emoji, self.id)
|
|
122
|
+
await self._env.settle()
|
|
123
|
+
|
|
124
|
+
async def unreact(self, message: MessageLike, emoji: str) -> None:
|
|
125
|
+
backend = self._env.backend
|
|
126
|
+
stored = backend.get_message(_channel_id_of(message), message.id)
|
|
127
|
+
backend.remove_reaction(stored.channel_id, stored.id, emoji, self.id)
|
|
128
|
+
await self._env.settle()
|
|
129
|
+
|
|
130
|
+
async def send_dm(self, content: str = "", **kwargs: Any) -> discord.Message:
|
|
131
|
+
return await self.user.send_dm(content, **kwargs)
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------- app commands
|
|
134
|
+
|
|
135
|
+
def _resolve_command(self, name: str, type: int = 1) -> tuple[dict[str, Any], dict[str, Any], list[str]]:
|
|
136
|
+
"""Resolve "root [group] [sub]" to (root command, leaf spec, nesting path)."""
|
|
137
|
+
backend = self._env.backend
|
|
138
|
+
parts = name.split()
|
|
139
|
+
root = backend.find_command(parts[0], self.guild.id, type=type)
|
|
140
|
+
if root is None:
|
|
141
|
+
root = self._unsynced_fallback(parts[0], type)
|
|
142
|
+
leaf, nesting = _interactions.walk_to_subcommand(root, parts[1:])
|
|
143
|
+
return root, leaf, nesting
|
|
144
|
+
|
|
145
|
+
def _unsynced_fallback(self, name: str, type: int) -> dict[str, Any]:
|
|
146
|
+
tree = getattr(self._env.bot, "tree", None)
|
|
147
|
+
in_tree = None
|
|
148
|
+
if tree is not None:
|
|
149
|
+
for scope in (None, discord.Object(self.guild.id)):
|
|
150
|
+
for cmd in tree.get_commands(guild=scope, type=discord.AppCommandType(type)):
|
|
151
|
+
if cmd.name == name:
|
|
152
|
+
in_tree = (cmd, scope)
|
|
153
|
+
if in_tree is None:
|
|
154
|
+
raise SetupError(f"No application command named '{name}' exists")
|
|
155
|
+
if self._env.strict_sync:
|
|
156
|
+
raise SetupError(
|
|
157
|
+
f"Command '{name}' exists in the command tree but was never synced — "
|
|
158
|
+
"did you forget `await bot.tree.sync()`? "
|
|
159
|
+
"(Pass strict_sync=False to dpt.run to auto-register unsynced commands.)"
|
|
160
|
+
)
|
|
161
|
+
cmd, scope = in_tree
|
|
162
|
+
guild_id = None if scope is None else self.guild.id
|
|
163
|
+
registered = self._env.backend.register_commands(
|
|
164
|
+
guild_id,
|
|
165
|
+
[c.to_dict(tree) for c in self._env.bot.tree.get_commands(guild=scope)], # type: ignore[union-attr]
|
|
166
|
+
)
|
|
167
|
+
return next(c for c in registered if c["name"] == name and c.get("type", 1) == type)
|
|
168
|
+
|
|
169
|
+
async def slash(self, channel: ChannelHandle, name: str, /, **options: Any) -> InteractionResult:
|
|
170
|
+
"""Invoke a synced slash command (use spaces for subcommands: "config set")."""
|
|
171
|
+
self._check(channel, "use_application_commands")
|
|
172
|
+
root, leaf, nesting = self._resolve_command(name)
|
|
173
|
+
leaf_options, resolved = _interactions.build_options(self, name, leaf, options)
|
|
174
|
+
data: dict[str, Any] = {
|
|
175
|
+
"id": root["id"],
|
|
176
|
+
"name": root["name"],
|
|
177
|
+
"type": root.get("type", 1),
|
|
178
|
+
"options": _interactions.nest_options(root, nesting, leaf_options),
|
|
179
|
+
}
|
|
180
|
+
if resolved:
|
|
181
|
+
data["resolved"] = resolved
|
|
182
|
+
if root.get("guild_id"):
|
|
183
|
+
data["guild_id"] = root["guild_id"]
|
|
184
|
+
return await self._dispatch_interaction(InteractionType.APPLICATION_COMMAND, channel, data)
|
|
185
|
+
|
|
186
|
+
async def context_menu(
|
|
187
|
+
self, channel: ChannelHandle, name: str, target: MemberActor | MessageLike
|
|
188
|
+
) -> InteractionResult:
|
|
189
|
+
"""Invoke a user or message context-menu command on a target."""
|
|
190
|
+
self._check(channel, "use_application_commands")
|
|
191
|
+
backend = self._env.backend
|
|
192
|
+
if isinstance(target, MemberActor):
|
|
193
|
+
command_type = 2
|
|
194
|
+
guild = backend.get_guild(self.guild.id)
|
|
195
|
+
resolved: dict[str, Any] = {
|
|
196
|
+
"users": {str(target.id): dict(serializers.user_payload(backend.get_user(target.id)))},
|
|
197
|
+
"members": {
|
|
198
|
+
str(target.id): dict(
|
|
199
|
+
serializers.member_payload(backend, guild, guild.members[target.id], with_user=False)
|
|
200
|
+
)
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
else:
|
|
204
|
+
command_type = 3
|
|
205
|
+
stored = backend.get_message(_channel_id_of(target), target.id)
|
|
206
|
+
resolved = {"messages": {str(target.id): dict(serializers.message_payload(backend, stored))}}
|
|
207
|
+
root, _leaf, _nesting = self._resolve_command(name, type=command_type)
|
|
208
|
+
data = {
|
|
209
|
+
"id": root["id"],
|
|
210
|
+
"name": root["name"],
|
|
211
|
+
"type": command_type,
|
|
212
|
+
"target_id": str(target.id),
|
|
213
|
+
"resolved": resolved,
|
|
214
|
+
}
|
|
215
|
+
return await self._dispatch_interaction(InteractionType.APPLICATION_COMMAND, channel, data)
|
|
216
|
+
|
|
217
|
+
async def autocomplete(
|
|
218
|
+
self, channel: ChannelHandle, name: str, option: str, value: str, /, **filled: Any
|
|
219
|
+
) -> list[dict[str, Any]]:
|
|
220
|
+
"""Type into an autocomplete option; returns the choices the bot offered."""
|
|
221
|
+
root, leaf, nesting = self._resolve_command(name)
|
|
222
|
+
leaf_options, _resolved = _interactions.build_options(self, name, leaf, filled, partial=True)
|
|
223
|
+
declared = {o["name"]: o for o in (leaf.get("options") or [])}
|
|
224
|
+
if option not in declared:
|
|
225
|
+
raise SetupError(f"Command '{name}' has no option '{option}'")
|
|
226
|
+
leaf_options.append(
|
|
227
|
+
{"name": option, "type": declared[option]["type"], "value": value, "focused": True}
|
|
228
|
+
)
|
|
229
|
+
data = {
|
|
230
|
+
"id": root["id"],
|
|
231
|
+
"name": root["name"],
|
|
232
|
+
"type": root.get("type", 1),
|
|
233
|
+
"options": _interactions.nest_options(root, nesting, leaf_options),
|
|
234
|
+
}
|
|
235
|
+
result = await self._dispatch_interaction(
|
|
236
|
+
InteractionType.APPLICATION_COMMAND_AUTOCOMPLETE, channel, data
|
|
237
|
+
)
|
|
238
|
+
return result.autocomplete_choices or []
|
|
239
|
+
|
|
240
|
+
# ------------------------------------------------------------ components
|
|
241
|
+
|
|
242
|
+
async def click(
|
|
243
|
+
self,
|
|
244
|
+
message: MessageLike,
|
|
245
|
+
*,
|
|
246
|
+
label: str | None = None,
|
|
247
|
+
custom_id: str | None = None,
|
|
248
|
+
) -> InteractionResult:
|
|
249
|
+
"""Click a button on a message, exactly as a user could."""
|
|
250
|
+
stored = self._visible_message(message)
|
|
251
|
+
button = _find_component(
|
|
252
|
+
stored.components, types=(ComponentType.BUTTON,), custom_id=custom_id, label=label
|
|
253
|
+
)
|
|
254
|
+
return await self._component_interaction(
|
|
255
|
+
stored, {"custom_id": button["custom_id"], "component_type": ComponentType.BUTTON}
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
async def select(
|
|
259
|
+
self,
|
|
260
|
+
message: MessageLike,
|
|
261
|
+
values: Sequence[str],
|
|
262
|
+
*,
|
|
263
|
+
custom_id: str | None = None,
|
|
264
|
+
) -> InteractionResult:
|
|
265
|
+
"""Choose values in a string select menu."""
|
|
266
|
+
stored = self._visible_message(message)
|
|
267
|
+
menu = _find_component(
|
|
268
|
+
stored.components, types=(ComponentType.STRING_SELECT,), custom_id=custom_id, label=None
|
|
269
|
+
)
|
|
270
|
+
valid = {o["value"] for o in menu.get("options") or []}
|
|
271
|
+
for value in values:
|
|
272
|
+
if value not in valid:
|
|
273
|
+
error = SetupError(f"Select option {value!r} does not exist")
|
|
274
|
+
error.add_note(f"Available options: {sorted(valid)}")
|
|
275
|
+
raise error
|
|
276
|
+
return await self._component_interaction(
|
|
277
|
+
stored,
|
|
278
|
+
{
|
|
279
|
+
"custom_id": menu["custom_id"],
|
|
280
|
+
"component_type": ComponentType.STRING_SELECT,
|
|
281
|
+
"values": list(values),
|
|
282
|
+
},
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
async def submit_modal(self, shown: InteractionResult, values: dict[str, str]) -> InteractionResult:
|
|
286
|
+
"""Fill in and submit a modal the bot previously showed this user."""
|
|
287
|
+
spec = shown.modal
|
|
288
|
+
if spec is None:
|
|
289
|
+
raise SetupError("That interaction did not respond with a modal")
|
|
290
|
+
components = []
|
|
291
|
+
for row in spec.get("components") or []:
|
|
292
|
+
for item in row.get("components") or []:
|
|
293
|
+
custom_id = item.get("custom_id")
|
|
294
|
+
if custom_id in values:
|
|
295
|
+
components.append(
|
|
296
|
+
{
|
|
297
|
+
"type": 1,
|
|
298
|
+
"components": [{"type": 4, "custom_id": custom_id, "value": values[custom_id]}],
|
|
299
|
+
}
|
|
300
|
+
)
|
|
301
|
+
channel = ChannelHandle(
|
|
302
|
+
self._env, self.guild, self._env.backend.get_channel(shown._interaction.channel_id)
|
|
303
|
+
)
|
|
304
|
+
return await self._dispatch_interaction(
|
|
305
|
+
InteractionType.MODAL_SUBMIT, channel, {"custom_id": spec["custom_id"], "components": components}
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# -------------------------------------------------------------- plumbing
|
|
309
|
+
|
|
310
|
+
def _visible_message(self, message: MessageLike) -> Any:
|
|
311
|
+
backend = self._env.backend
|
|
312
|
+
stored = backend.get_message(_channel_id_of(message), message.id)
|
|
313
|
+
if not stored.visible_to(self.id):
|
|
314
|
+
raise SetupError(
|
|
315
|
+
"That message is ephemeral and not visible to this user — "
|
|
316
|
+
"a real user could not interact with it"
|
|
317
|
+
)
|
|
318
|
+
return stored
|
|
319
|
+
|
|
320
|
+
async def _component_interaction(self, stored: Any, data: dict[str, Any]) -> InteractionResult:
|
|
321
|
+
backend = self._env.backend
|
|
322
|
+
channel = ChannelHandle(self._env, self.guild, backend.get_channel(stored.channel_id))
|
|
323
|
+
result = await self._dispatch_interaction(
|
|
324
|
+
InteractionType.MESSAGE_COMPONENT,
|
|
325
|
+
channel,
|
|
326
|
+
data,
|
|
327
|
+
extra={"message": dict(serializers.message_payload(backend, stored))},
|
|
328
|
+
source_message_id=stored.id,
|
|
329
|
+
)
|
|
330
|
+
return result
|
|
331
|
+
|
|
332
|
+
async def _dispatch_interaction(
|
|
333
|
+
self,
|
|
334
|
+
type: int,
|
|
335
|
+
channel: ChannelHandle,
|
|
336
|
+
data: dict[str, Any],
|
|
337
|
+
*,
|
|
338
|
+
extra: dict[str, Any] | None = None,
|
|
339
|
+
source_message_id: int | None = None,
|
|
340
|
+
) -> InteractionResult:
|
|
341
|
+
backend = self._env.backend
|
|
342
|
+
record, payload = _interactions.base_payload(
|
|
343
|
+
backend,
|
|
344
|
+
type=type,
|
|
345
|
+
channel_id=channel.id,
|
|
346
|
+
guild_id=self.guild.id,
|
|
347
|
+
user_id=self.id,
|
|
348
|
+
data=data,
|
|
349
|
+
)
|
|
350
|
+
if extra:
|
|
351
|
+
payload.update(extra)
|
|
352
|
+
if source_message_id is not None:
|
|
353
|
+
record.source_message_id = source_message_id
|
|
354
|
+
backend.emit("INTERACTION_CREATE", payload)
|
|
355
|
+
await self._env.settle()
|
|
356
|
+
return InteractionResult(self._env, record)
|
|
357
|
+
|
|
358
|
+
def __repr__(self) -> str:
|
|
359
|
+
return f"<MemberActor id={self.id} name={self.name!r} guild={self.guild.id}>"
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _channel_id_of(message: MessageLike) -> int:
|
|
363
|
+
if isinstance(message, ResponseMessage):
|
|
364
|
+
return message.channel_id
|
|
365
|
+
return message.channel.id
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _find_component(
|
|
369
|
+
rows: list[dict[str, Any]],
|
|
370
|
+
*,
|
|
371
|
+
types: tuple[int, ...],
|
|
372
|
+
custom_id: str | None,
|
|
373
|
+
label: str | None,
|
|
374
|
+
) -> dict[str, Any]:
|
|
375
|
+
found = []
|
|
376
|
+
for row in rows or []:
|
|
377
|
+
for component in row.get("components") or []:
|
|
378
|
+
if component.get("type") not in types:
|
|
379
|
+
continue
|
|
380
|
+
if custom_id is not None and component.get("custom_id") != custom_id:
|
|
381
|
+
continue
|
|
382
|
+
if label is not None and component.get("label") != label:
|
|
383
|
+
continue
|
|
384
|
+
found.append(component)
|
|
385
|
+
if not found:
|
|
386
|
+
raise SetupError(
|
|
387
|
+
f"No matching component (custom_id={custom_id!r}, label={label!r}) — "
|
|
388
|
+
"a real user could not interact with it"
|
|
389
|
+
)
|
|
390
|
+
component = found[0]
|
|
391
|
+
if component.get("disabled"):
|
|
392
|
+
raise SetupError("That component is disabled — a real user could not interact with it")
|
|
393
|
+
return component
|
simcord/backend/cdn.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""In-memory fake CDN for attachments and other binary assets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
CDN_BASE = "https://cdn.dpt.invalid"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CdnStore:
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self._blobs: dict[str, bytes] = {}
|
|
13
|
+
|
|
14
|
+
def store_attachment(
|
|
15
|
+
self, attachment_id: int, channel_id: int, filename: str, data: bytes, description: str | None
|
|
16
|
+
) -> dict[str, Any]:
|
|
17
|
+
url = f"{CDN_BASE}/attachments/{channel_id}/{attachment_id}/{filename}"
|
|
18
|
+
self._blobs[url] = data
|
|
19
|
+
return {
|
|
20
|
+
"id": str(attachment_id),
|
|
21
|
+
"filename": filename,
|
|
22
|
+
"description": description,
|
|
23
|
+
"size": len(data),
|
|
24
|
+
"url": url,
|
|
25
|
+
"proxy_url": url,
|
|
26
|
+
"content_type": None,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
def get(self, url: str) -> bytes | None:
|
|
30
|
+
return self._blobs.get(url)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Error catalog: backend failures that map to real Discord HTTP errors.
|
|
2
|
+
|
|
3
|
+
The fake transports translate :class:`BackendError` into genuine
|
|
4
|
+
``discord.HTTPException`` subclasses carrying authentic Discord JSON error
|
|
5
|
+
codes, so bot code that branches on them behaves exactly as in production.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BackendError(Exception):
|
|
14
|
+
def __init__(self, status: int, code: int, message: str) -> None:
|
|
15
|
+
super().__init__(f"{status} (error code: {code}): {message}")
|
|
16
|
+
self.status = status
|
|
17
|
+
self.code = code
|
|
18
|
+
self.message = message
|
|
19
|
+
|
|
20
|
+
def to_json(self) -> dict[str, Any]:
|
|
21
|
+
return {"code": self.code, "message": self.message}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SetupError(Exception):
|
|
25
|
+
"""The test mis-set-up or mis-drove the virtual world (not a bot bug)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --- the catalog (codes per https://discord.com/developers/docs/topics/opcodes-and-status-codes) ---
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def missing_permissions() -> BackendError:
|
|
32
|
+
return BackendError(403, 50013, "Missing Permissions")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def missing_access() -> BackendError:
|
|
36
|
+
return BackendError(403, 50001, "Missing Access")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def unknown_guild() -> BackendError:
|
|
40
|
+
return BackendError(404, 10004, "Unknown Guild")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def unknown_channel() -> BackendError:
|
|
44
|
+
return BackendError(404, 10003, "Unknown Channel")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def unknown_member() -> BackendError:
|
|
48
|
+
return BackendError(404, 10007, "Unknown Member")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def unknown_message() -> BackendError:
|
|
52
|
+
return BackendError(404, 10008, "Unknown Message")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def unknown_role() -> BackendError:
|
|
56
|
+
return BackendError(404, 10011, "Unknown Role")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def unknown_user() -> BackendError:
|
|
60
|
+
return BackendError(404, 10013, "Unknown User")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def unknown_webhook() -> BackendError:
|
|
64
|
+
return BackendError(404, 10015, "Unknown Webhook")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def unknown_ban() -> BackendError:
|
|
68
|
+
return BackendError(404, 10026, "Unknown Ban")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def already_acknowledged() -> BackendError:
|
|
72
|
+
return BackendError(400, 40060, "Interaction has already been acknowledged")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cannot_edit_other_user() -> BackendError:
|
|
76
|
+
return BackendError(403, 50005, "Cannot edit a message authored by another user")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def invalid_form_body(detail: str) -> BackendError:
|
|
80
|
+
return BackendError(400, 50035, f"Invalid Form Body: {detail}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cannot_dm_bot() -> BackendError:
|
|
84
|
+
return BackendError(403, 50007, "Cannot send messages to this user")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Plain dataclass models for the virtual backend's state.
|
|
2
|
+
|
|
3
|
+
These are deliberately independent of discord.py's model classes: the backend
|
|
4
|
+
plays the role of Discord's servers, and only ever speaks to the bot through
|
|
5
|
+
wire-format payloads produced by :mod:`simcord.backend.serializers`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .channel import Channel, Overwrite, ThreadMetadata
|
|
9
|
+
from .guild import Guild
|
|
10
|
+
from .interaction import Interaction, ResponseKind
|
|
11
|
+
from .member import Member
|
|
12
|
+
from .message import EPHEMERAL_FLAG, Message, Reaction
|
|
13
|
+
from .role import Role
|
|
14
|
+
from .user import User
|
|
15
|
+
from .webhook import Webhook
|
|
16
|
+
|
|
17
|
+
__all__ = (
|
|
18
|
+
"EPHEMERAL_FLAG",
|
|
19
|
+
"Channel",
|
|
20
|
+
"Guild",
|
|
21
|
+
"Interaction",
|
|
22
|
+
"Member",
|
|
23
|
+
"Message",
|
|
24
|
+
"Overwrite",
|
|
25
|
+
"Reaction",
|
|
26
|
+
"ResponseKind",
|
|
27
|
+
"Role",
|
|
28
|
+
"ThreadMetadata",
|
|
29
|
+
"User",
|
|
30
|
+
"Webhook",
|
|
31
|
+
)
|