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.
Files changed (44) hide show
  1. simcord/__init__.py +50 -0
  2. simcord/_dpy_internals.py +86 -0
  3. simcord/actors.py +393 -0
  4. simcord/backend/__init__.py +11 -0
  5. simcord/backend/cdn.py +30 -0
  6. simcord/backend/errors.py +84 -0
  7. simcord/backend/models/__init__.py +31 -0
  8. simcord/backend/models/channel.py +53 -0
  9. simcord/backend/models/guild.py +28 -0
  10. simcord/backend/models/interaction.py +97 -0
  11. simcord/backend/models/member.py +15 -0
  12. simcord/backend/models/message.py +61 -0
  13. simcord/backend/models/role.py +15 -0
  14. simcord/backend/models/user.py +10 -0
  15. simcord/backend/models/webhook.py +14 -0
  16. simcord/backend/permissions.py +70 -0
  17. simcord/backend/serializers.py +291 -0
  18. simcord/backend/state.py +666 -0
  19. simcord/builders.py +230 -0
  20. simcord/enums.py +58 -0
  21. simcord/env.py +381 -0
  22. simcord/gateway.py +31 -0
  23. simcord/http/__init__.py +11 -0
  24. simcord/http/_helpers.py +76 -0
  25. simcord/http/client.py +158 -0
  26. simcord/http/router.py +141 -0
  27. simcord/http/routes/__init__.py +13 -0
  28. simcord/http/routes/application.py +48 -0
  29. simcord/http/routes/channels.py +130 -0
  30. simcord/http/routes/commands.py +27 -0
  31. simcord/http/routes/guilds.py +202 -0
  32. simcord/http/routes/interactions.py +140 -0
  33. simcord/http/routes/messages.py +112 -0
  34. simcord/http/routes/reactions.py +45 -0
  35. simcord/interactions.py +177 -0
  36. simcord/parity.py +63 -0
  37. simcord/py.typed +0 -0
  38. simcord/pytest_plugin.py +59 -0
  39. simcord/results.py +118 -0
  40. simcord-0.1.0.dist-info/METADATA +210 -0
  41. simcord-0.1.0.dist-info/RECORD +44 -0
  42. simcord-0.1.0.dist-info/WHEEL +4 -0
  43. simcord-0.1.0.dist-info/entry_points.txt +2 -0
  44. 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
@@ -0,0 +1,11 @@
1
+ from . import errors, models, permissions, serializers
2
+ from .state import DEFAULT_EVERYONE_PERMISSIONS, Backend
3
+
4
+ __all__ = (
5
+ "DEFAULT_EVERYONE_PERMISSIONS",
6
+ "Backend",
7
+ "errors",
8
+ "models",
9
+ "permissions",
10
+ "serializers",
11
+ )
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
+ )