Skip to content

API reference

The complete public API, generated from the source. Everything here is importable from the top-level simcord package.

New here?

Read Core concepts first — it explains how these objects relate (builders arrange the world, actors act as users, queries assert) before you dive into signatures.

Entry point

simcord.run

async with simcord.run(bot) as env: — attach, fake-login, READY.

On exit, if the bot raised errors the test never inspected (via env.errors or env.raise_errors()), they are re-raised as an ExceptionGroup so bot bugs cannot pass silently. Opt out with simcord.run(bot, check_errors=False).

Source code in src/simcord/env.py
class run:
    """``async with simcord.run(bot) as env:`` — attach, fake-login, READY.

    On exit, if the bot raised errors the test never inspected (via
    ``env.errors`` or ``env.raise_errors()``), they are re-raised as an
    ``ExceptionGroup`` so bot bugs cannot pass silently. Opt out with
    ``simcord.run(bot, check_errors=False)``.
    """

    def __init__(self, bot: discord.Client, **options: Any) -> None:
        self._env = Env(bot, **options)

    async def __aenter__(self) -> Env:
        await self._env.start()
        return self._env

    async def __aexit__(self, exc_type: Any, *exc_info: Any) -> None:
        env = self._env
        await env.shutdown()
        # Don't mask an exception already propagating out of the test body.
        if exc_type is None and env.check_errors and not env._errors_inspected:
            env.raise_errors()

simcord.Env

A running test environment around a single bot.

Use via :func:simcord.run::

async with simcord.run(bot) as env:
    guild = env.create_guild()
    ...

Only one Env may be live per event loop at a time: start() monkeypatches loop.create_task to track the bot's tasks, and nesting two environments on one loop would corrupt each other's task bookkeeping.

Source code in src/simcord/env.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
class Env:
    """A running test environment around a single bot.

    Use via :func:`simcord.run`::

        async with simcord.run(bot) as env:
            guild = env.create_guild()
            ...

    Only one ``Env`` may be live per event loop at a time: ``start()``
    monkeypatches ``loop.create_task`` to track the bot's tasks, and nesting two
    environments on one loop would corrupt each other's task bookkeeping.
    """

    def __init__(
        self,
        bot: discord.Client,
        *,
        strict_sync: bool = True,
        check_errors: bool = True,
        approved_intents: discord.Intents | None = None,
    ) -> None:
        self.bot = bot
        self.strict_sync = strict_sync
        self.check_errors = check_errors
        #: Simulates the developer-portal privileged-intent toggles. ``None``
        #: (the default) means everything is approved; pass an Intents with
        #: e.g. ``members=False`` to make start() fail with
        #: :class:`discord.PrivilegedIntentsRequired`, as a real connect would.
        self.approved_intents = approved_intents
        self.backend = Backend()
        self._errors: list[BaseException] = []
        self._errors_inspected = False
        self._guilds: list[GuildHandle] = []
        self._tasks: list[asyncio.Task[Any]] = []
        self._loop: asyncio.AbstractEventLoop | None = None
        self._orig_create_task: Any = None
        self._orig_monotonic: Any = None
        self._time_offset = 0.0
        self._adapter_token: Any = None
        self._gateway_feed: Any = None
        self._started = False

    # ------------------------------------------------------------- lifecycle

    async def start(self) -> None:
        if self._started:
            raise SetupError("Env already started")
        self._started = True
        self._loop = asyncio.get_running_loop()
        await self._attach_bot(self.bot)

    async def restart_bot(self, bot: discord.Client | None = None) -> None:
        """Simulate a bot restart while the virtual world persists.

        Detaches the current bot, attaches ``bot`` (or the same instance if
        omitted), and replays ``GUILD_CREATE`` for every existing guild so the
        new client's cache repopulates exactly as on a first run — letting tests
        prove that persistent views (``bot.add_view`` in ``setup_hook``)
        re-attach to messages they never saw created.

        Pass a freshly built client for a faithful restart: re-running the same
        instance re-executes ``setup_hook`` (which typically reloads extensions
        and would fail). The virtual clock is preserved — the world does not
        rewind. Errors the old bot raised are preserved too: a restart does not
        launder away un-inspected bot bugs.
        """
        if not self._started:
            raise SetupError("Env not started; use restart_bot() only inside simcord.run()")
        await self.settle()
        await self._detach_bot()
        await self._attach_bot(bot or self.bot)
        for handle in self._guilds:
            guild = self.backend.get_guild(handle.id)
            self.backend.emit("GUILD_CREATE", serializers.guild_create_payload(self.backend, guild))
        await self.settle()

    async def _attach_bot(self, bot: discord.Client) -> None:
        """Install the fakes onto ``bot`` and bring it to READY. Shared by
        :meth:`start` and :meth:`restart_bot`."""
        self.bot = bot
        loop = self._loop
        assert loop is not None

        # Track every task spawned while the env is live so settle() can wait
        # for the bot to finish reacting without guessing with sleeps.
        self._orig_create_task = loop.create_task

        def tracking_create_task(coro: Any, **kwargs: Any) -> asyncio.Task[Any]:
            task = self._orig_create_task(coro, **kwargs)
            self._tasks.append(task)
            return task

        loop.create_task = tracking_create_task  # type: ignore[method-assign]

        # Virtual clock: time.monotonic() = real monotonic + offset, so
        # advance_time() can fast-forward view timeouts, cooldown buckets and
        # asyncio timers without real waiting. One patch covers everything:
        # BaseEventLoop.time() and discord.py's deadline checks both read
        # time.monotonic at call time. Restored on shutdown. The offset survives
        # restarts so the world's clock does not rewind.
        self._orig_monotonic = time.monotonic
        time.monotonic = lambda: self._orig_monotonic() + self._time_offset

        _dpy_internals.install_http(bot, FakeHTTPClient(self.backend, loop))
        _dpy_internals.set_guild_ready_timeout(bot, 0.0)
        self._adapter_token = _dpy_internals.set_webhook_adapter(FakeWebhookAdapter(self.backend))

        gateway = FakeGateway(self.backend, _dpy_internals.get_state(bot))
        self._gateway_feed = gateway.feed
        self.backend.subscribers.append(gateway.feed)
        # The upstream half of the gateway: answers REQUEST_GUILD_MEMBERS so
        # member chunking (startup, Guild.chunk, query_members) works.
        _dpy_internals.install_websocket(bot, FakeWebSocket(self.backend, gateway))

        try:
            self._capture_errors()
            # Simulated developer-portal check: a real IDENTIFY with an
            # unapproved privileged intent is rejected with close code 4014,
            # which discord.py surfaces as PrivilegedIntentsRequired.
            if self.approved_intents is not None and _intents.missing_privileged_intents(
                bot.intents, self.approved_intents
            ):
                raise discord.PrivilegedIntentsRequired(shard_id=None)
            # Runs the real login flow: identity, application info, setup_hook
            # (where bots typically load extensions and sync their command tree).
            await bot.login("simcord.fake.token")
            gateway.feed(
                "READY",
                {
                    "v": 10,
                    "user": dict(serializers.user_payload(self.backend.bot_user)),
                    "guilds": [],
                    "session_id": "simcord-session",
                    "resume_gateway_url": "wss://simcord.invalid",
                    "shard": [0, 1],
                    "application": {"id": str(self.backend.application_id), "flags": 0},
                },
            )
            await self.settle()
        except BaseException:
            # Setup (e.g. setup_hook) blew up: undo the global monkeypatches so
            # we don't leak the patched loop.create_task / webhook adapter into
            # whatever runs next on this loop.
            await self._detach_bot()
            raise

    async def shutdown(self) -> None:
        await self._detach_bot()

    async def _detach_bot(self) -> None:
        """Undo the current bot's patches and stop tracking it. Leaves the
        backend (the virtual world) intact so a restart can re-attach to it."""
        if self._gateway_feed is not None:
            try:
                self.backend.subscribers.remove(self._gateway_feed)
            except ValueError:
                pass
            self._gateway_feed = None
        if self._adapter_token is not None:
            _dpy_internals.reset_webhook_adapter(self._adapter_token)
            self._adapter_token = None
        if self._loop is not None and self._orig_create_task is not None:
            self._loop.create_task = self._orig_create_task  # type: ignore[method-assign]
            self._orig_create_task = None
        if self._orig_monotonic is not None:
            time.monotonic = self._orig_monotonic
            self._orig_monotonic = None
        current = asyncio.current_task()
        to_cancel = [t for t in self._tasks if t is not current and not t.done()]
        for task in to_cancel:
            task.cancel()
        await asyncio.gather(*to_cancel, return_exceptions=True)
        self._tasks = []

    async def settle(self, timeout: float = 5.0, idle: float = 0.05) -> None:  # noqa: ASYNC109
        # `timeout` is deliberate public API: settle() polls for quiescence and
        # decides between "parked on a future" and "still working", so it can't
        # be replaced by wrapping the body in asyncio.timeout().
        """Wait until the bot has finished reacting to injected events.

        Waits for all tracked tasks to complete. A task that completes no work
        within an ``idle`` window is only abandoned if it is genuinely parked on
        a future (e.g. blocked in ``wait_for`` for a later user action) — if the
        loop still has timers scheduled to fire before ``timeout`` (e.g. an
        ``asyncio.sleep`` in a cooldown or backoff), we keep waiting for them.
        If pending tasks neither finish nor park before ``timeout``, a
        ``TimeoutError`` with the pending tasks is raised.
        """
        assert self._loop is not None
        deadline = self._loop.time() + timeout
        # Give freshly-scheduled callbacks a chance to run first.
        for _ in range(3):
            await asyncio.sleep(0)
        while True:
            self._tasks = [t for t in self._tasks if not t.done()]
            pending = [
                t
                for t in self._tasks
                if getattr(t.get_coro(), "__qualname__", "").split(".")[-1]
                not in _dpy_internals.BACKGROUND_CORO_NAMES
            ]
            if not pending:
                return
            done, _ = await asyncio.wait(pending, timeout=idle, return_when=asyncio.FIRST_COMPLETED)
            if done:
                continue  # progress made — re-evaluate what is still pending
            if self._loop.time() > deadline:
                raise TimeoutError(f"bot did not settle; pending tasks: {pending}")
            next_timer = self._next_scheduled_timer()
            if next_timer is None or next_timer > deadline:
                # No imminent timer: the remaining tasks are parked on futures
                # waiting for input we will never deliver. Leave them running.
                return
            # A timer (e.g. asyncio.sleep) is due before the deadline; loop and
            # wait for the work it will wake up.

    def _next_scheduled_timer(self) -> float | None:
        """The earliest live ``call_later`` deadline on the loop, if any.

        Used by :meth:`settle` to tell ``asyncio.sleep``-style pauses (which
        schedule a timer) apart from tasks parked indefinitely on a future
        (which do not). Best-effort: relies on the standard loop's internals
        and degrades to ``None`` if they are unavailable.
        """
        scheduled = getattr(self._loop, "_scheduled", None)
        if not scheduled:
            return None
        times = [h.when() for h in scheduled if not h.cancelled()]
        return min(times) if times else None

    async def advance_time(self, seconds: float) -> None:
        """Fast-forward the virtual clock, firing every timer that becomes due.

        View timeouts, cooldown resets, ``asyncio.sleep`` chains — anything the
        bot scheduled against the loop's clock — fire as if ``seconds`` of real
        time had passed, without waiting. Timers are consumed in order (a chain
        of three 60s sleeps completes within ``advance_time(180)``), and the
        bot's reactions are settled after each step.
        """
        assert self._loop is not None
        await self.settle()
        # Cooldowns and age math derive from message/interaction timestamps, so
        # the backend's virtual wall clock must advance in step with the loop's.
        self.backend.advance_clock(seconds)
        # Polls finalize on a wall-clock deadline rather than a loop timer, so
        # fast-forwarding time must finalize any that just expired. Settle once
        # afterwards so the MESSAGE_UPDATE(s) this emits reach the bot's
        # listeners even when no loop timer fires during this window.
        self.backend.expire_due_polls()
        # Scheduled events transition on wall-clock deadlines, like polls.
        self.backend.activate_due_scheduled_events()
        await self.settle()
        remaining = float(seconds)
        while remaining > 0:
            next_timer = self._next_scheduled_timer()
            now = self._loop.time()
            if next_timer is None or next_timer - now > remaining:
                self._time_offset += remaining
                break
            step = max(next_timer - now, 0.0)
            self._time_offset += step
            remaining -= step
            # The earliest timer is now due: let it fire and the bot react.
            await asyncio.sleep(0)
            await self.settle()

    # -------------------------------------------------------- error capture

    @property
    def errors(self) -> list[BaseException]:
        """Errors the bot raised (command handlers, app commands, listeners).

        Reading this marks the errors as inspected: ``simcord.run`` then trusts the
        test's own assertions instead of failing it at teardown.
        """
        self._errors_inspected = True
        return self._errors

    def _capture_errors(self) -> None:
        from discord.ext import commands

        async def on_command_error(_ctx: Any, error: BaseException) -> None:
            # CommandNotFound just means the message wasn't a command — that is
            # not a bot bug, so don't pollute env.errors with it.
            if not isinstance(error, commands.CommandNotFound):
                self._errors.append(error)

        add_listener = getattr(self.bot, "add_listener", None)
        if add_listener is not None:
            add_listener(on_command_error, "on_command_error")

        # Exceptions raised inside plain event listeners (e.g. on_member_join)
        # go to Client.on_error, which by default only logs them. Capture them
        # too so listener bugs surface in env.errors instead of vanishing.
        original_on_error = self.bot.on_error

        async def on_error(event_method: str, /, *args: Any, **kwargs: Any) -> None:
            import sys

            exc = sys.exc_info()[1]
            if exc is not None:
                self._errors.append(exc)
            await original_on_error(event_method, *args, **kwargs)

        self.bot.on_error = on_error  # type: ignore[method-assign]

        tree = getattr(self.bot, "tree", None)
        if tree is not None:
            original = tree.on_error

            async def on_tree_error(interaction: Any, error: BaseException) -> None:
                self._errors.append(error)
                await original(interaction, error)

            tree.on_error = on_tree_error

    # -------------------------------------------------------------- builders

    def create_user(
        self,
        name: str,
        *,
        bot: bool = False,
        system: bool = False,
        global_name: str | None = None,
        discriminator: str = "0",
        public_flags: discord.PublicUserFlags | None = None,
    ) -> UserHandle:
        """Create a virtual user.

        ``bot=True`` makes messages this user posts arrive with
        ``message.author.bot`` set — the way a bot/application account, or a
        webhook (see :meth:`GuildHandle.create_webhook`), appears to the bot
        under test. ``system=True`` marks an official Discord system account.
        ``global_name`` is the display name (distinct from the unique
        ``name``/username); ``discriminator`` is the legacy four-digit tag
        (``"0"`` for migrated accounts); ``public_flags`` carries badge flags
        such as ``verified_bot``.
        """
        return UserHandle(
            self,
            self.backend.make_user(
                name,
                bot=bot,
                system=system,
                global_name=global_name,
                discriminator=discriminator,
                public_flags=public_flags.value if public_flags is not None else 0,
            ),
        )

    def create_guild(
        self,
        name: str = "Test Guild",
        *,
        id: int | None = None,
        owner: UserHandle | None = None,
        description: str | None = None,
        verification_level: discord.VerificationLevel | None = None,
        notifications: discord.NotificationLevel | None = None,
        content_filter: discord.ContentFilter | None = None,
        preferred_locale: str | None = None,
        afk_timeout: int | None = None,
    ) -> GuildHandle:
        """Create a guild.

        Pass ``id`` to pin a known id — e.g. to match a bot that syncs its
        commands to a hardcoded guild id, so ``strict_sync`` can stay on. Pass
        ``owner`` to make a specific user the guild owner (owners bypass every
        permission check); by default a fresh synthetic owner is created so the
        bot never owns the guild. The remaining keywords seed guild settings the
        bot can read back off ``discord.Guild`` and could later change itself via
        ``Guild.edit`` (the keyword names here are friendlier aliases — e.g.
        ``notifications`` for ``default_notifications``).
        """
        settings: dict[str, Any] = {}
        if description is not None:
            settings["description"] = description
        if verification_level is not None:
            settings["verification_level"] = verification_level.value
        if notifications is not None:
            settings["default_message_notifications"] = notifications.value
        if content_filter is not None:
            settings["explicit_content_filter"] = content_filter.value
        if preferred_locale is not None:
            settings["preferred_locale"] = preferred_locale
        if afk_timeout is not None:
            settings["afk_timeout"] = afk_timeout
        handle = GuildHandle(
            self,
            self.backend.create_guild(
                name, id=id, owner_id=owner.id if owner is not None else None, **settings
            ),
        )
        self._guilds.append(handle)
        return handle

    @property
    def guild(self) -> GuildHandle:
        """The first created guild, for the common single-guild case."""
        if not self._guilds:
            raise SetupError("No guild created yet; call env.create_guild() first")
        return self._guilds[0]

    # ----------------------------------------------------------- diagnostics

    @property
    def http_log(self) -> list[tuple[str, str, dict[str, Any] | None]]:
        """Every REST call the bot made: (method, path, json body)."""
        return self.backend.http_log

    def transcript(self) -> str:
        """Human-readable record of everything that happened, in order.

        One line per gateway event injected and REST call the bot made — the
        "what did the bot actually do" dump, including events DROPPED (missing
        intent) or CENSORED (missing message_content) by intent simulation.
        The pytest plugin attaches this to failing tests automatically.
        """
        lines = []
        for kind, name, payload in self.backend.transcript:
            lines.append(f"{kind:<8} {name:<28} {_summarize(payload)}")
        return "\n".join(lines)

    def raise_errors(self) -> None:
        """Re-raise everything the bot raised during the test, as a group.

        Exceptions from command handlers, app-command callbacks and event
        listeners are captured into :attr:`errors` rather than propagating into
        your test (that is what lets a bot keep running after one handler
        fails). Call this to assert the bot ran cleanly: it raises an
        ``ExceptionGroup`` of everything captured — even a single error — and
        does nothing if there were none.
        """
        self._errors_inspected = True
        captured = list(self._errors)
        if not captured:
            return
        message = f"bot raised {len(captured)} error(s) during the test"
        if all(isinstance(exc, Exception) for exc in captured):
            raise ExceptionGroup(message, captured)  # type: ignore[arg-type]
        raise BaseExceptionGroup(message, captured)

    def inject_error(
        self,
        method: str,
        path: str,
        *,
        status: int = 500,
        code: int = 0,
        message: str = "Internal Server Error (injected by test)",
        times: int | None = 1,
    ) -> None:
        """Make matching REST calls fail, to test the bot's error handling.

        ``path`` is an fnmatch pattern against the API path, e.g.
        ``"/channels/*/messages"``; ``method`` may be ``"*"``. ``times=None``
        keeps the fault active for the rest of the test.
        """
        self.backend.faults.append(
            {
                "method": method,
                "path": path,
                "status": status,
                "code": code,
                "message": message,
                "times": times,
            }
        )

errors property

errors: list[BaseException]

Errors the bot raised (command handlers, app commands, listeners).

Reading this marks the errors as inspected: simcord.run then trusts the test's own assertions instead of failing it at teardown.

guild property

guild: GuildHandle

The first created guild, for the common single-guild case.

http_log property

http_log: list[tuple[str, str, dict[str, Any] | None]]

Every REST call the bot made: (method, path, json body).

restart_bot async

restart_bot(bot: Client | None = None) -> None

Simulate a bot restart while the virtual world persists.

Detaches the current bot, attaches bot (or the same instance if omitted), and replays GUILD_CREATE for every existing guild so the new client's cache repopulates exactly as on a first run — letting tests prove that persistent views (bot.add_view in setup_hook) re-attach to messages they never saw created.

Pass a freshly built client for a faithful restart: re-running the same instance re-executes setup_hook (which typically reloads extensions and would fail). The virtual clock is preserved — the world does not rewind. Errors the old bot raised are preserved too: a restart does not launder away un-inspected bot bugs.

Source code in src/simcord/env.py
async def restart_bot(self, bot: discord.Client | None = None) -> None:
    """Simulate a bot restart while the virtual world persists.

    Detaches the current bot, attaches ``bot`` (or the same instance if
    omitted), and replays ``GUILD_CREATE`` for every existing guild so the
    new client's cache repopulates exactly as on a first run — letting tests
    prove that persistent views (``bot.add_view`` in ``setup_hook``)
    re-attach to messages they never saw created.

    Pass a freshly built client for a faithful restart: re-running the same
    instance re-executes ``setup_hook`` (which typically reloads extensions
    and would fail). The virtual clock is preserved — the world does not
    rewind. Errors the old bot raised are preserved too: a restart does not
    launder away un-inspected bot bugs.
    """
    if not self._started:
        raise SetupError("Env not started; use restart_bot() only inside simcord.run()")
    await self.settle()
    await self._detach_bot()
    await self._attach_bot(bot or self.bot)
    for handle in self._guilds:
        guild = self.backend.get_guild(handle.id)
        self.backend.emit("GUILD_CREATE", serializers.guild_create_payload(self.backend, guild))
    await self.settle()

settle async

settle(timeout: float = 5.0, idle: float = 0.05) -> None

Wait until the bot has finished reacting to injected events.

Waits for all tracked tasks to complete. A task that completes no work within an idle window is only abandoned if it is genuinely parked on a future (e.g. blocked in wait_for for a later user action) — if the loop still has timers scheduled to fire before timeout (e.g. an asyncio.sleep in a cooldown or backoff), we keep waiting for them. If pending tasks neither finish nor park before timeout, a TimeoutError with the pending tasks is raised.

Source code in src/simcord/env.py
async def settle(self, timeout: float = 5.0, idle: float = 0.05) -> None:  # noqa: ASYNC109
    # `timeout` is deliberate public API: settle() polls for quiescence and
    # decides between "parked on a future" and "still working", so it can't
    # be replaced by wrapping the body in asyncio.timeout().
    """Wait until the bot has finished reacting to injected events.

    Waits for all tracked tasks to complete. A task that completes no work
    within an ``idle`` window is only abandoned if it is genuinely parked on
    a future (e.g. blocked in ``wait_for`` for a later user action) — if the
    loop still has timers scheduled to fire before ``timeout`` (e.g. an
    ``asyncio.sleep`` in a cooldown or backoff), we keep waiting for them.
    If pending tasks neither finish nor park before ``timeout``, a
    ``TimeoutError`` with the pending tasks is raised.
    """
    assert self._loop is not None
    deadline = self._loop.time() + timeout
    # Give freshly-scheduled callbacks a chance to run first.
    for _ in range(3):
        await asyncio.sleep(0)
    while True:
        self._tasks = [t for t in self._tasks if not t.done()]
        pending = [
            t
            for t in self._tasks
            if getattr(t.get_coro(), "__qualname__", "").split(".")[-1]
            not in _dpy_internals.BACKGROUND_CORO_NAMES
        ]
        if not pending:
            return
        done, _ = await asyncio.wait(pending, timeout=idle, return_when=asyncio.FIRST_COMPLETED)
        if done:
            continue  # progress made — re-evaluate what is still pending
        if self._loop.time() > deadline:
            raise TimeoutError(f"bot did not settle; pending tasks: {pending}")
        next_timer = self._next_scheduled_timer()
        if next_timer is None or next_timer > deadline:
            # No imminent timer: the remaining tasks are parked on futures
            # waiting for input we will never deliver. Leave them running.
            return

advance_time async

advance_time(seconds: float) -> None

Fast-forward the virtual clock, firing every timer that becomes due.

View timeouts, cooldown resets, asyncio.sleep chains — anything the bot scheduled against the loop's clock — fire as if seconds of real time had passed, without waiting. Timers are consumed in order (a chain of three 60s sleeps completes within advance_time(180)), and the bot's reactions are settled after each step.

Source code in src/simcord/env.py
async def advance_time(self, seconds: float) -> None:
    """Fast-forward the virtual clock, firing every timer that becomes due.

    View timeouts, cooldown resets, ``asyncio.sleep`` chains — anything the
    bot scheduled against the loop's clock — fire as if ``seconds`` of real
    time had passed, without waiting. Timers are consumed in order (a chain
    of three 60s sleeps completes within ``advance_time(180)``), and the
    bot's reactions are settled after each step.
    """
    assert self._loop is not None
    await self.settle()
    # Cooldowns and age math derive from message/interaction timestamps, so
    # the backend's virtual wall clock must advance in step with the loop's.
    self.backend.advance_clock(seconds)
    # Polls finalize on a wall-clock deadline rather than a loop timer, so
    # fast-forwarding time must finalize any that just expired. Settle once
    # afterwards so the MESSAGE_UPDATE(s) this emits reach the bot's
    # listeners even when no loop timer fires during this window.
    self.backend.expire_due_polls()
    # Scheduled events transition on wall-clock deadlines, like polls.
    self.backend.activate_due_scheduled_events()
    await self.settle()
    remaining = float(seconds)
    while remaining > 0:
        next_timer = self._next_scheduled_timer()
        now = self._loop.time()
        if next_timer is None or next_timer - now > remaining:
            self._time_offset += remaining
            break
        step = max(next_timer - now, 0.0)
        self._time_offset += step
        remaining -= step
        # The earliest timer is now due: let it fire and the bot react.
        await asyncio.sleep(0)
        await self.settle()

create_user

create_user(name: str, *, bot: bool = False, system: bool = False, global_name: str | None = None, discriminator: str = '0', public_flags: PublicUserFlags | None = None) -> UserHandle

Create a virtual user.

bot=True makes messages this user posts arrive with message.author.bot set — the way a bot/application account, or a webhook (see :meth:GuildHandle.create_webhook), appears to the bot under test. system=True marks an official Discord system account. global_name is the display name (distinct from the unique name/username); discriminator is the legacy four-digit tag ("0" for migrated accounts); public_flags carries badge flags such as verified_bot.

Source code in src/simcord/env.py
def create_user(
    self,
    name: str,
    *,
    bot: bool = False,
    system: bool = False,
    global_name: str | None = None,
    discriminator: str = "0",
    public_flags: discord.PublicUserFlags | None = None,
) -> UserHandle:
    """Create a virtual user.

    ``bot=True`` makes messages this user posts arrive with
    ``message.author.bot`` set — the way a bot/application account, or a
    webhook (see :meth:`GuildHandle.create_webhook`), appears to the bot
    under test. ``system=True`` marks an official Discord system account.
    ``global_name`` is the display name (distinct from the unique
    ``name``/username); ``discriminator`` is the legacy four-digit tag
    (``"0"`` for migrated accounts); ``public_flags`` carries badge flags
    such as ``verified_bot``.
    """
    return UserHandle(
        self,
        self.backend.make_user(
            name,
            bot=bot,
            system=system,
            global_name=global_name,
            discriminator=discriminator,
            public_flags=public_flags.value if public_flags is not None else 0,
        ),
    )

create_guild

create_guild(name: str = 'Test Guild', *, id: int | None = None, owner: UserHandle | None = None, description: str | None = None, verification_level: VerificationLevel | None = None, notifications: NotificationLevel | None = None, content_filter: ContentFilter | None = None, preferred_locale: str | None = None, afk_timeout: int | None = None) -> GuildHandle

Create a guild.

Pass id to pin a known id — e.g. to match a bot that syncs its commands to a hardcoded guild id, so strict_sync can stay on. Pass owner to make a specific user the guild owner (owners bypass every permission check); by default a fresh synthetic owner is created so the bot never owns the guild. The remaining keywords seed guild settings the bot can read back off discord.Guild and could later change itself via Guild.edit (the keyword names here are friendlier aliases — e.g. notifications for default_notifications).

Source code in src/simcord/env.py
def create_guild(
    self,
    name: str = "Test Guild",
    *,
    id: int | None = None,
    owner: UserHandle | None = None,
    description: str | None = None,
    verification_level: discord.VerificationLevel | None = None,
    notifications: discord.NotificationLevel | None = None,
    content_filter: discord.ContentFilter | None = None,
    preferred_locale: str | None = None,
    afk_timeout: int | None = None,
) -> GuildHandle:
    """Create a guild.

    Pass ``id`` to pin a known id — e.g. to match a bot that syncs its
    commands to a hardcoded guild id, so ``strict_sync`` can stay on. Pass
    ``owner`` to make a specific user the guild owner (owners bypass every
    permission check); by default a fresh synthetic owner is created so the
    bot never owns the guild. The remaining keywords seed guild settings the
    bot can read back off ``discord.Guild`` and could later change itself via
    ``Guild.edit`` (the keyword names here are friendlier aliases — e.g.
    ``notifications`` for ``default_notifications``).
    """
    settings: dict[str, Any] = {}
    if description is not None:
        settings["description"] = description
    if verification_level is not None:
        settings["verification_level"] = verification_level.value
    if notifications is not None:
        settings["default_message_notifications"] = notifications.value
    if content_filter is not None:
        settings["explicit_content_filter"] = content_filter.value
    if preferred_locale is not None:
        settings["preferred_locale"] = preferred_locale
    if afk_timeout is not None:
        settings["afk_timeout"] = afk_timeout
    handle = GuildHandle(
        self,
        self.backend.create_guild(
            name, id=id, owner_id=owner.id if owner is not None else None, **settings
        ),
    )
    self._guilds.append(handle)
    return handle

transcript

transcript() -> str

Human-readable record of everything that happened, in order.

One line per gateway event injected and REST call the bot made — the "what did the bot actually do" dump, including events DROPPED (missing intent) or CENSORED (missing message_content) by intent simulation. The pytest plugin attaches this to failing tests automatically.

Source code in src/simcord/env.py
def transcript(self) -> str:
    """Human-readable record of everything that happened, in order.

    One line per gateway event injected and REST call the bot made — the
    "what did the bot actually do" dump, including events DROPPED (missing
    intent) or CENSORED (missing message_content) by intent simulation.
    The pytest plugin attaches this to failing tests automatically.
    """
    lines = []
    for kind, name, payload in self.backend.transcript:
        lines.append(f"{kind:<8} {name:<28} {_summarize(payload)}")
    return "\n".join(lines)

raise_errors

raise_errors() -> None

Re-raise everything the bot raised during the test, as a group.

Exceptions from command handlers, app-command callbacks and event listeners are captured into :attr:errors rather than propagating into your test (that is what lets a bot keep running after one handler fails). Call this to assert the bot ran cleanly: it raises an ExceptionGroup of everything captured — even a single error — and does nothing if there were none.

Source code in src/simcord/env.py
def raise_errors(self) -> None:
    """Re-raise everything the bot raised during the test, as a group.

    Exceptions from command handlers, app-command callbacks and event
    listeners are captured into :attr:`errors` rather than propagating into
    your test (that is what lets a bot keep running after one handler
    fails). Call this to assert the bot ran cleanly: it raises an
    ``ExceptionGroup`` of everything captured — even a single error — and
    does nothing if there were none.
    """
    self._errors_inspected = True
    captured = list(self._errors)
    if not captured:
        return
    message = f"bot raised {len(captured)} error(s) during the test"
    if all(isinstance(exc, Exception) for exc in captured):
        raise ExceptionGroup(message, captured)  # type: ignore[arg-type]
    raise BaseExceptionGroup(message, captured)

inject_error

inject_error(method: str, path: str, *, status: int = 500, code: int = 0, message: str = 'Internal Server Error (injected by test)', times: int | None = 1) -> None

Make matching REST calls fail, to test the bot's error handling.

path is an fnmatch pattern against the API path, e.g. "/channels/*/messages"; method may be "*". times=None keeps the fault active for the rest of the test.

Source code in src/simcord/env.py
def inject_error(
    self,
    method: str,
    path: str,
    *,
    status: int = 500,
    code: int = 0,
    message: str = "Internal Server Error (injected by test)",
    times: int | None = 1,
) -> None:
    """Make matching REST calls fail, to test the bot's error handling.

    ``path`` is an fnmatch pattern against the API path, e.g.
    ``"/channels/*/messages"``; ``method`` may be ``"*"``. ``times=None``
    keeps the fault active for the rest of the test.
    """
    self.backend.faults.append(
        {
            "method": method,
            "path": path,
            "status": status,
            "code": code,
            "message": message,
            "times": times,
        }
    )

Builders

Synchronous, omnipotent handles for arranging the virtual Discord. Returned by env/guild methods. See Core concepts → Builders.

simcord.GuildHandle

Source code in src/simcord/builders.py
class GuildHandle:
    def __init__(self, env: Env, guild: Guild) -> None:
        self._env = env
        self._guild = guild

    @property
    def id(self) -> int:
        return self._guild.id

    @property
    def name(self) -> str:
        return self._guild.name

    @property
    def default_role(self) -> RoleHandle:
        return RoleHandle(self._env, self, self._guild.everyone_role)

    @property
    def owner(self) -> UserHandle:
        return UserHandle(self._env, self._env.backend.get_user(self._guild.owner_id))

    @property
    def me(self) -> discord.Member | None:
        cached = self._env.bot.get_guild(self.id)
        return cached.me if cached else None

    @property
    def channels(self) -> dict[str, ChannelHandle]:
        backend = self._env.backend
        return {
            backend.channels[cid].name or str(cid): ChannelHandle(self._env, self, backend.channels[cid])
            for cid in self._guild.channel_ids
        }

    @property
    def roles(self) -> dict[str, RoleHandle]:
        return {r.name: RoleHandle(self._env, self, r) for r in self._guild.roles.values()}

    def create_text_channel(
        self,
        name: str,
        *,
        overwrites: dict[RoleHandle | MemberActor, discord.PermissionOverwrite] | None = None,
        topic: str | None = None,
        category: ChannelHandle | None = None,
    ) -> ChannelHandle:
        model_overwrites = []
        for target, overwrite in (overwrites or {}).items():
            allow, deny = overwrite.pair()
            model_overwrites.append(
                Overwrite(
                    target_id=target.id,
                    type=OverwriteType.ROLE if isinstance(target, RoleHandle) else OverwriteType.MEMBER,
                    allow=allow.value,
                    deny=deny.value,
                )
            )
        channel = self._env.backend.create_channel(
            self.id,
            name,
            overwrites=model_overwrites,
            topic=topic,
            parent_id=category.id if category else None,
        )
        return ChannelHandle(self._env, self, channel)

    def create_voice_channel(
        self,
        name: str,
        *,
        category: ChannelHandle | None = None,
        user_limit: int = 0,
        bitrate: int = 64000,
    ) -> ChannelHandle:
        channel = self._env.backend.create_channel(
            self.id,
            name,
            type=ChannelType.VOICE,
            user_limit=user_limit,
            bitrate=bitrate,
            parent_id=category.id if category else None,
        )
        return ChannelHandle(self._env, self, channel)

    def create_stage_channel(self, name: str, *, category: ChannelHandle | None = None) -> ChannelHandle:
        channel = self._env.backend.create_channel(
            self.id, name, type=ChannelType.STAGE_VOICE, parent_id=category.id if category else None
        )
        return ChannelHandle(self._env, self, channel)

    def create_news_channel(self, name: str, *, category: ChannelHandle | None = None) -> ChannelHandle:
        channel = self._env.backend.create_channel(
            self.id, name, type=ChannelType.NEWS, parent_id=category.id if category else None
        )
        return ChannelHandle(self._env, self, channel)

    def create_category(self, name: str) -> ChannelHandle:
        channel = self._env.backend.create_channel(self.id, name, type=ChannelType.CATEGORY)
        return ChannelHandle(self._env, self, channel)

    def create_forum_channel(self, name: str) -> ChannelHandle:
        channel = self._env.backend.create_channel(self.id, name, type=ChannelType.FORUM)
        return ChannelHandle(self._env, self, channel)

    def create_scheduled_event(
        self,
        name: str,
        *,
        start_time: str,
        entity_type: int = 2,
        channel: ChannelHandle | None = None,
        description: str | None = None,
        end_time: str | None = None,
        location: str | None = None,
    ) -> ScheduledEvent:
        """Create a scheduled event directly (omnipotent setup)."""
        return self._env.backend.create_scheduled_event(
            self.id,
            name=name,
            entity_type=entity_type,
            scheduled_start_time=start_time,
            channel_id=channel.id if channel else None,
            description=description,
            scheduled_end_time=end_time,
            entity_metadata={"location": location} if location else None,
        )

    def create_webhook(self, channel: ChannelHandle, name: str = "Webhook") -> WebhookHandle:
        """Create an incoming webhook bound to ``channel`` (omnipotent setup).

        Use the returned handle's :meth:`~WebhookHandle.send` to post messages
        as the webhook would — they arrive with ``message.webhook_id`` set and
        ``message.author.bot`` True, which is how a webhook (a GitHub/CI
        integration, another service posting an embed, etc.) appears to the bot
        under test. This is the test-driven counterpart to the bot creating and
        executing a webhook itself over the API.
        """
        webhook = self._env.backend.create_webhook(channel.id, name, self._env.backend.bot_user.id)
        return WebhookHandle(self._env, webhook)

    def create_emoji(self, name: str, *, animated: bool = False) -> GuildEmoji:
        return self._env.backend.create_emoji(self.id, name, self._env.backend.bot_user.id, animated=animated)

    def create_sticker(self, name: str, *, description: str | None = None, tags: str = "") -> Sticker:
        return self._env.backend.create_sticker(
            self.id, name, self._env.backend.bot_user.id, description=description, tags=tags
        )

    def set_command_permissions(
        self, command: Any, permissions: dict[RoleHandle | ChannelHandle | UserHandle | MemberActor, bool]
    ) -> None:
        """Set an app command's per-guild permission overrides (omnipotent setup).

        ``command`` is a command id or any object with an ``id`` (e.g. a fetched
        ``discord.app_commands.AppCommand``); ``permissions`` maps role/user/
        channel handles to allow (``True``) / deny (``False``). The bot then
        reads these back via ``AppCommand.fetch_permissions``.
        """
        command_id = command if isinstance(command, int) else command.id
        entries = [
            {
                "id": str(target.id),
                "type": 1
                if isinstance(target, RoleHandle)
                else 3
                if isinstance(target, ChannelHandle)
                else 2,
                "permission": bool(allowed),
            }
            for target, allowed in permissions.items()
        ]
        self._env.backend.set_command_permissions(self.id, command_id, entries)

    def audit_log(self) -> list[AuditLogEntry]:
        """The guild's recorded audit-log entries, oldest first."""
        return list(self._guild.audit_log_entries)

    def voice_states(self) -> dict[int, VoiceState]:
        """Current voice states keyed by user id."""
        return dict(self._guild.voice_states)

    def invites(self) -> list[Invite]:
        return [inv for inv in self._env.backend.invites.values() if inv.guild_id == self.id]

    def set_vanity_url(self, code: str) -> None:
        """Give the guild a vanity invite code so ``Guild.vanity_invite()`` resolves.

        Discord backs a vanity URL with a real invite, so this stores one under
        ``code`` pointing at the guild's first channel — the populated path is
        genuine modelled state a test can read back, not a constant fake.
        """
        if not self._guild.channel_ids:
            raise SetupError("set_vanity_url needs a channel; create one first")
        backend = self._env.backend
        self._guild.vanity_url_code = code
        backend.invites[code] = Invite(
            code=code,
            guild_id=self.id,
            channel_id=self._guild.channel_ids[0],
            inviter_id=backend.bot_user.id,
            created_at=backend.now_iso(),
        )

    def create_role(
        self, name: str, *, permissions: discord.Permissions | None = None, **fields: Any
    ) -> RoleHandle:
        role = self._env.backend.create_role(
            self.id, name, permissions=permissions.value if permissions else 0, **fields
        )
        return RoleHandle(self._env, self, role)

    def add_member(
        self,
        user: UserHandle,
        *,
        roles: Sequence[RoleHandle] = (),
        nick: str | None = None,
    ) -> MemberActor:
        from .actors import MemberActor

        self._env.backend.add_member(self.id, user.id, roles=[r.id for r in roles], nick=nick, announce=True)
        return MemberActor(self._env, self, user)

    def remove_member(self, member: MemberActor | UserHandle) -> None:
        """The member leaves the guild (dispatches the leave event)."""
        self._env.backend.remove_member(self.id, member.id)

    def get_ban(self, user: UserHandle | MemberActor) -> dict[str, Any] | None:
        if user.id not in self._guild.bans:
            return None
        return {"user": user, "reason": self._guild.bans[user.id]}

    def member_ids(self) -> list[int]:
        return list(self._guild.members)

    def __repr__(self) -> str:
        return f"<GuildHandle id={self.id} name={self.name!r}>"

create_scheduled_event

create_scheduled_event(name: str, *, start_time: str, entity_type: int = 2, channel: ChannelHandle | None = None, description: str | None = None, end_time: str | None = None, location: str | None = None) -> ScheduledEvent

Create a scheduled event directly (omnipotent setup).

Source code in src/simcord/builders.py
def create_scheduled_event(
    self,
    name: str,
    *,
    start_time: str,
    entity_type: int = 2,
    channel: ChannelHandle | None = None,
    description: str | None = None,
    end_time: str | None = None,
    location: str | None = None,
) -> ScheduledEvent:
    """Create a scheduled event directly (omnipotent setup)."""
    return self._env.backend.create_scheduled_event(
        self.id,
        name=name,
        entity_type=entity_type,
        scheduled_start_time=start_time,
        channel_id=channel.id if channel else None,
        description=description,
        scheduled_end_time=end_time,
        entity_metadata={"location": location} if location else None,
    )

create_webhook

create_webhook(channel: ChannelHandle, name: str = 'Webhook') -> WebhookHandle

Create an incoming webhook bound to channel (omnipotent setup).

Use the returned handle's :meth:~WebhookHandle.send to post messages as the webhook would — they arrive with message.webhook_id set and message.author.bot True, which is how a webhook (a GitHub/CI integration, another service posting an embed, etc.) appears to the bot under test. This is the test-driven counterpart to the bot creating and executing a webhook itself over the API.

Source code in src/simcord/builders.py
def create_webhook(self, channel: ChannelHandle, name: str = "Webhook") -> WebhookHandle:
    """Create an incoming webhook bound to ``channel`` (omnipotent setup).

    Use the returned handle's :meth:`~WebhookHandle.send` to post messages
    as the webhook would — they arrive with ``message.webhook_id`` set and
    ``message.author.bot`` True, which is how a webhook (a GitHub/CI
    integration, another service posting an embed, etc.) appears to the bot
    under test. This is the test-driven counterpart to the bot creating and
    executing a webhook itself over the API.
    """
    webhook = self._env.backend.create_webhook(channel.id, name, self._env.backend.bot_user.id)
    return WebhookHandle(self._env, webhook)

set_command_permissions

set_command_permissions(command: Any, permissions: dict[RoleHandle | ChannelHandle | UserHandle | MemberActor, bool]) -> None

Set an app command's per-guild permission overrides (omnipotent setup).

command is a command id or any object with an id (e.g. a fetched discord.app_commands.AppCommand); permissions maps role/user/ channel handles to allow (True) / deny (False). The bot then reads these back via AppCommand.fetch_permissions.

Source code in src/simcord/builders.py
def set_command_permissions(
    self, command: Any, permissions: dict[RoleHandle | ChannelHandle | UserHandle | MemberActor, bool]
) -> None:
    """Set an app command's per-guild permission overrides (omnipotent setup).

    ``command`` is a command id or any object with an ``id`` (e.g. a fetched
    ``discord.app_commands.AppCommand``); ``permissions`` maps role/user/
    channel handles to allow (``True``) / deny (``False``). The bot then
    reads these back via ``AppCommand.fetch_permissions``.
    """
    command_id = command if isinstance(command, int) else command.id
    entries = [
        {
            "id": str(target.id),
            "type": 1
            if isinstance(target, RoleHandle)
            else 3
            if isinstance(target, ChannelHandle)
            else 2,
            "permission": bool(allowed),
        }
        for target, allowed in permissions.items()
    ]
    self._env.backend.set_command_permissions(self.id, command_id, entries)

audit_log

audit_log() -> list[AuditLogEntry]

The guild's recorded audit-log entries, oldest first.

Source code in src/simcord/builders.py
def audit_log(self) -> list[AuditLogEntry]:
    """The guild's recorded audit-log entries, oldest first."""
    return list(self._guild.audit_log_entries)

voice_states

voice_states() -> dict[int, VoiceState]

Current voice states keyed by user id.

Source code in src/simcord/builders.py
def voice_states(self) -> dict[int, VoiceState]:
    """Current voice states keyed by user id."""
    return dict(self._guild.voice_states)

set_vanity_url

set_vanity_url(code: str) -> None

Give the guild a vanity invite code so Guild.vanity_invite() resolves.

Discord backs a vanity URL with a real invite, so this stores one under code pointing at the guild's first channel — the populated path is genuine modelled state a test can read back, not a constant fake.

Source code in src/simcord/builders.py
def set_vanity_url(self, code: str) -> None:
    """Give the guild a vanity invite code so ``Guild.vanity_invite()`` resolves.

    Discord backs a vanity URL with a real invite, so this stores one under
    ``code`` pointing at the guild's first channel — the populated path is
    genuine modelled state a test can read back, not a constant fake.
    """
    if not self._guild.channel_ids:
        raise SetupError("set_vanity_url needs a channel; create one first")
    backend = self._env.backend
    self._guild.vanity_url_code = code
    backend.invites[code] = Invite(
        code=code,
        guild_id=self.id,
        channel_id=self._guild.channel_ids[0],
        inviter_id=backend.bot_user.id,
        created_at=backend.now_iso(),
    )

remove_member

remove_member(member: MemberActor | UserHandle) -> None

The member leaves the guild (dispatches the leave event).

Source code in src/simcord/builders.py
def remove_member(self, member: MemberActor | UserHandle) -> None:
    """The member leaves the guild (dispatches the leave event)."""
    self._env.backend.remove_member(self.id, member.id)

simcord.ChannelHandle

Source code in src/simcord/builders.py
class ChannelHandle:
    def __init__(self, env: Env, guild: GuildHandle | None, channel: Channel) -> None:
        self._env = env
        self._channel = channel
        self.guild = guild

    @property
    def id(self) -> int:
        return self._channel.id

    @property
    def name(self) -> str | None:
        return self._channel.name

    @property
    def mention(self) -> str:
        return f"<#{self.id}>"

    @property
    def is_thread(self) -> bool:
        return self._channel.is_thread

    @property
    def threads(self) -> list[ChannelHandle]:
        backend = self._env.backend
        return [
            ChannelHandle(self._env, self.guild, c)
            for c in backend.channels.values()
            if c.is_thread and c.parent_id == self.id
        ]

    def history(self, *, viewer: MemberActor | UserHandle | None = None) -> list[discord.Message]:
        """All messages, oldest first, as real ``discord.Message`` objects.

        With ``viewer=``, ephemeral messages not addressed to that user are
        hidden — exactly what that user would see in their client.
        """
        out = []
        viewer_id = viewer.id if viewer is not None else None
        for message in sorted(self._env.backend.messages.get(self.id, {}).values(), key=lambda m: m.id):
            if not message.visible_to(viewer_id):
                continue
            out.append(to_discord_message(self._env, message))
        return out

    @property
    def last_message(self) -> discord.Message | None:
        history = self.history()
        return history[-1] if history else None

    def pinned_messages(self) -> list[discord.Message]:
        return [m for m in self.history() if m.pinned]

    def __repr__(self) -> str:
        return f"<ChannelHandle id={self.id} name={self.name!r}>"

history

history(*, viewer: MemberActor | UserHandle | None = None) -> list[discord.Message]

All messages, oldest first, as real discord.Message objects.

With viewer=, ephemeral messages not addressed to that user are hidden — exactly what that user would see in their client.

Source code in src/simcord/builders.py
def history(self, *, viewer: MemberActor | UserHandle | None = None) -> list[discord.Message]:
    """All messages, oldest first, as real ``discord.Message`` objects.

    With ``viewer=``, ephemeral messages not addressed to that user are
    hidden — exactly what that user would see in their client.
    """
    out = []
    viewer_id = viewer.id if viewer is not None else None
    for message in sorted(self._env.backend.messages.get(self.id, {}).values(), key=lambda m: m.id):
        if not message.visible_to(viewer_id):
            continue
        out.append(to_discord_message(self._env, message))
    return out

simcord.RoleHandle

Source code in src/simcord/builders.py
class RoleHandle:
    def __init__(self, env: Env, guild: GuildHandle, role: Role) -> None:
        self._env = env
        self._role = role
        self.guild = guild

    @property
    def id(self) -> int:
        return self._role.id

    @property
    def name(self) -> str:
        return self._role.name

    @property
    def mention(self) -> str:
        return f"<@&{self.id}>"

    def __repr__(self) -> str:
        return f"<RoleHandle id={self.id} name={self.name!r}>"

simcord.UserHandle

A virtual human user (independent of any guild).

Source code in src/simcord/builders.py
class UserHandle:
    """A virtual human user (independent of any guild)."""

    def __init__(self, env: Env, user: User) -> None:
        self._env = env
        self._user = user

    @property
    def id(self) -> int:
        return self._user.id

    @property
    def name(self) -> str:
        return self._user.name

    @property
    def bot(self) -> bool:
        """Whether this user is a bot/application account."""
        return self._user.bot

    @property
    def system(self) -> bool:
        """Whether this user is an official Discord system account."""
        return self._user.system

    @property
    def global_name(self) -> str | None:
        """The user's display name, if distinct from the username."""
        return self._user.global_name

    @property
    def discriminator(self) -> str:
        """The legacy four-digit tag (``"0"`` for migrated accounts)."""
        return self._user.discriminator

    @property
    def mention(self) -> str:
        return f"<@{self.id}>"

    @property
    def dm_channel(self) -> ChannelHandle:
        """The user's DM channel with the bot."""
        return ChannelHandle(self._env, None, self._env.backend.get_dm_channel(self.id))

    async def send_dm(self, content: str = "", **kwargs: Any) -> discord.Message:
        """DM the bot as this user."""
        channel = self._env.backend.get_dm_channel(self.id)
        message = self._env.backend.create_message(channel.id, self.id, content, **kwargs)
        await self._env.settle()
        return to_discord_message(self._env, message)

    def __repr__(self) -> str:
        return f"<UserHandle id={self.id} name={self.name!r}>"

bot property

bot: bool

Whether this user is a bot/application account.

system property

system: bool

Whether this user is an official Discord system account.

global_name property

global_name: str | None

The user's display name, if distinct from the username.

discriminator property

discriminator: str

The legacy four-digit tag ("0" for migrated accounts).

dm_channel property

dm_channel: ChannelHandle

The user's DM channel with the bot.

send_dm async

send_dm(content: str = '', **kwargs: Any) -> discord.Message

DM the bot as this user.

Source code in src/simcord/builders.py
async def send_dm(self, content: str = "", **kwargs: Any) -> discord.Message:
    """DM the bot as this user."""
    channel = self._env.backend.get_dm_channel(self.id)
    message = self._env.backend.create_message(channel.id, self.id, content, **kwargs)
    await self._env.settle()
    return to_discord_message(self._env, message)

Actors

The simulated human that drives your bot. Created by guild.add_member(...). See Core concepts → Actors.

simcord.MemberActor

A guild member that acts like a real human user.

Source code in src/simcord/actors.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
class MemberActor:
    """A guild member that acts like a real human user."""

    def __init__(self, env: Env, guild: GuildHandle, user: UserHandle) -> None:
        self._env = env
        self.guild = guild
        self.user = user

    @property
    def id(self) -> int:
        return self.user.id

    @property
    def name(self) -> str:
        return self.user.name

    @property
    def mention(self) -> str:
        return self.user.mention

    @property
    def member(self) -> discord.Member | None:
        cached = self._env.bot.get_guild(self.guild.id)
        return cached.get_member(self.id) if cached else None

    def _check(self, channel: ChannelHandle, *permissions: str) -> None:
        self._env.backend.require_permissions(self.guild.id, self.id, channel.id, *permissions)

    # ------------------------------------------------------------------ text

    async def send(
        self,
        channel: ChannelHandle,
        content: str = "",
        *,
        reply_to: MessageLike | None = None,
        attachments: Sequence[tuple[str, bytes]] = (),
    ) -> discord.Message:
        backend = self._env.backend
        perm = "send_messages_in_threads" if channel.is_thread else "send_messages"
        self._check(channel, perm)
        reference = None
        if reply_to is not None:
            reference = {"channel_id": str(channel.id), "message_id": str(reply_to.id)}
        attachment_payloads = backend.store_attachments(channel.id, attachments)
        message = backend.create_message(
            channel.id,
            self.id,
            content,
            reference=reference,
            attachments=attachment_payloads,
        )
        await self._env.settle()
        return to_discord_message(self._env, message)

    async def edit(self, message: MessageLike, content: str) -> None:
        stored = self._env.backend.get_message(_channel_id_of(message), message.id)
        if stored.author_id != self.id:
            raise SetupError("Users can only edit their own messages")
        self._env.backend.edit_message(stored.channel_id, stored.id, {"content": content})
        await self._env.settle()

    async def delete(self, message: MessageLike) -> None:
        stored = self._env.backend.get_message(_channel_id_of(message), message.id)
        if stored.author_id != self.id:
            self._env.backend.require_permissions(
                self.guild.id, self.id, stored.channel_id, "manage_messages"
            )
        self._env.backend.delete_message(stored.channel_id, stored.id)
        await self._env.settle()

    async def typing(self, channel: ChannelHandle) -> None:
        self._check(channel, "send_messages")
        backend = self._env.backend
        guild = backend.get_guild(self.guild.id)
        payload = {
            "channel_id": str(channel.id),
            "user_id": str(self.id),
            "timestamp": 0,
            "guild_id": str(self.guild.id),
            "member": serializers.member_payload(backend, guild, guild.members[self.id]),
        }
        backend.emit("TYPING_START", payload)
        await self._env.settle()

    async def react(self, message: MessageLike, emoji: str) -> None:
        backend = self._env.backend
        stored = backend.get_message(_channel_id_of(message), message.id)
        backend.require_permissions(self.guild.id, self.id, stored.channel_id, "add_reactions")
        backend.add_reaction(stored.channel_id, stored.id, emoji, self.id)
        await self._env.settle()

    async def unreact(self, message: MessageLike, emoji: str) -> None:
        backend = self._env.backend
        stored = backend.get_message(_channel_id_of(message), message.id)
        backend.remove_reaction(stored.channel_id, stored.id, emoji, self.id)
        await self._env.settle()

    async def send_dm(self, content: str = "", **kwargs: Any) -> discord.Message:
        return await self.user.send_dm(content, **kwargs)

    # ---------------------------------------------------------- app commands

    def _resolve_root(self, name: str, type: int) -> dict[str, Any]:
        """Find a registered command by exact name, falling back to the unsynced tree."""
        root = self._env.backend.find_command(name, self.guild.id, type=type)
        if root is None:
            root = self._unsynced_fallback(name, type)
        return root

    def _resolve_command(
        self, name: str, type: int = AppCommandType.CHAT_INPUT
    ) -> tuple[dict[str, Any], dict[str, Any], list[str]]:
        """Resolve "root [group] [sub]" to (root command, leaf spec, nesting path).

        Only slash commands nest, so the space-separated parts are walked as a
        subcommand path; context menus (whose names contain spaces) resolve by
        full name via :meth:`_resolve_root` instead.
        """
        parts = name.split()
        root = self._resolve_root(parts[0], type)
        leaf, nesting = _interactions.walk_to_subcommand(root, parts[1:])
        return root, leaf, nesting

    def _unsynced_fallback(self, name: str, type: int) -> dict[str, Any]:
        tree = getattr(self._env.bot, "tree", None)
        in_tree = None
        if tree is not None:
            for scope in (None, discord.Object(self.guild.id)):
                for cmd in tree.get_commands(guild=scope, type=discord.AppCommandType(type)):
                    if cmd.name == name:
                        in_tree = (cmd, scope)
        if in_tree is None:
            raise SetupError(f"No application command named '{name}' exists")
        if self._env.strict_sync:
            raise SetupError(
                f"Command '{name}' exists in the command tree but was never synced — "
                "did you forget `await bot.tree.sync()`? "
                "(Pass strict_sync=False to simcord.run to auto-register unsynced commands.)"
            )
        cmd, scope = in_tree
        guild_id = None if scope is None else self.guild.id
        registered = self._env.backend.register_commands(
            guild_id,
            [c.to_dict(tree) for c in self._env.bot.tree.get_commands(guild=scope)],  # type: ignore[union-attr]
        )
        return next(
            c for c in registered if c["name"] == name and c.get("type", AppCommandType.CHAT_INPUT) == type
        )

    async def slash(self, channel: ChannelHandle, name: str, /, **options: Any) -> InteractionResult:
        """Invoke a synced slash command (use spaces for subcommands: "config set")."""
        self._check(channel, "use_application_commands")
        root, leaf, nesting = self._resolve_command(name)
        leaf_options, resolved = _interactions.build_options(self, name, leaf, options)
        data: dict[str, Any] = {
            "id": root["id"],
            "name": root["name"],
            "type": root.get("type", AppCommandType.CHAT_INPUT),
            "options": _interactions.nest_options(root, nesting, leaf_options),
        }
        if resolved:
            data["resolved"] = resolved
        if root.get("guild_id"):
            data["guild_id"] = root["guild_id"]
        return await self._dispatch_interaction(InteractionType.APPLICATION_COMMAND, channel, data)

    async def context_menu(
        self, channel: ChannelHandle, name: str, target: MemberActor | MessageLike
    ) -> InteractionResult:
        """Invoke a user or message context-menu command on a target."""
        self._check(channel, "use_application_commands")
        backend = self._env.backend
        if isinstance(target, MemberActor):
            command_type = AppCommandType.USER
            guild = backend.get_guild(self.guild.id)
            resolved: dict[str, Any] = {
                "users": {str(target.id): dict(serializers.user_payload(backend.get_user(target.id)))},
                "members": {
                    str(target.id): dict(
                        serializers.member_payload(backend, guild, guild.members[target.id], with_user=False)
                    )
                },
            }
        else:
            command_type = AppCommandType.MESSAGE
            stored = backend.get_message(_channel_id_of(target), target.id)
            resolved = {"messages": {str(target.id): dict(serializers.message_payload(backend, stored))}}
        # Context-menu names contain spaces and never nest, so resolve the full
        # name directly rather than treating words as a subcommand path.
        root = self._resolve_root(name, command_type)
        data = {
            "id": root["id"],
            "name": root["name"],
            "type": command_type,
            "target_id": str(target.id),
            "resolved": resolved,
        }
        return await self._dispatch_interaction(InteractionType.APPLICATION_COMMAND, channel, data)

    async def autocomplete(
        self, channel: ChannelHandle, name: str, option: str, value: str, /, **filled: Any
    ) -> list[dict[str, Any]]:
        """Type into an autocomplete option; returns the choices the bot offered."""
        root, leaf, nesting = self._resolve_command(name)
        leaf_options, _resolved = _interactions.build_options(self, name, leaf, filled, partial=True)
        declared = {o["name"]: o for o in (leaf.get("options") or [])}
        if option not in declared:
            raise SetupError(f"Command '{name}' has no option '{option}'")
        leaf_options.append(
            {"name": option, "type": declared[option]["type"], "value": value, "focused": True}
        )
        data = {
            "id": root["id"],
            "name": root["name"],
            "type": root.get("type", AppCommandType.CHAT_INPUT),
            "options": _interactions.nest_options(root, nesting, leaf_options),
        }
        result = await self._dispatch_interaction(
            InteractionType.APPLICATION_COMMAND_AUTOCOMPLETE, channel, data
        )
        return result.autocomplete_choices or []

    # ------------------------------------------------------------ components

    async def click(
        self,
        message: MessageLike,
        *,
        label: str | None = None,
        custom_id: str | None = None,
    ) -> InteractionResult:
        """Click a button on a message, exactly as a user could."""
        stored = self._visible_message(message)
        button = _find_component(
            stored.components, types=(ComponentType.BUTTON,), custom_id=custom_id, label=label
        )
        return await self._component_interaction(
            stored, {"custom_id": button["custom_id"], "component_type": ComponentType.BUTTON}
        )

    async def select(
        self,
        message: MessageLike,
        values: Sequence[Any],
        *,
        custom_id: str | None = None,
    ) -> InteractionResult:
        """Choose values in a select menu.

        For a string select, ``values`` are the option strings. For user/role/
        channel/mentionable selects, ``values`` are the matching handles
        (:class:`UserHandle`/:class:`MemberActor`, :class:`RoleHandle`,
        :class:`ChannelHandle`) — exactly the entities a real user could pick.
        """
        stored = self._visible_message(message)
        menu = _find_component(stored.components, types=SELECT_TYPES, custom_id=custom_id, label=None)
        menu_type = menu["type"]

        lo = menu.get("min_values", 1)
        hi = menu.get("max_values", 1)
        if not lo <= len(values) <= hi:
            raise SetupError(f"Select expects between {lo} and {hi} value(s), got {len(values)}")

        data: dict[str, Any] = {"custom_id": menu["custom_id"], "component_type": menu_type}
        if menu_type == ComponentType.STRING_SELECT:
            valid = {o["value"] for o in menu.get("options") or []}
            for value in values:
                if value not in valid:
                    error = SetupError(f"Select option {value!r} does not exist")
                    error.add_note(f"Available options: {sorted(valid)}")
                    raise error
            data["values"] = list(values)
        else:
            self._check_select_handles(menu_type, values)
            resolved: dict[str, dict[str, Any]] = {}
            for value in values:
                _interactions.resolve_handle(self._env.backend, value, resolved, user_id=self.id)
            data["values"] = [str(value.id) for value in values]
            data["resolved"] = resolved
        return await self._component_interaction(stored, data)

    @staticmethod
    def _check_select_handles(menu_type: int, values: Sequence[Any]) -> None:
        """Reject handles whose kind cannot appear in this entity-select type."""
        allowed: tuple[type, ...] = _ENTITY_SELECT_HANDLES[ComponentType(menu_type)]
        for value in values:
            if not isinstance(value, allowed):
                names = " or ".join(t.__name__ for t in allowed)
                raise SetupError(
                    f"{ComponentType(menu_type).name} expects {names}, got {type(value).__name__}"
                )

    async def submit_modal(self, shown: InteractionResult, values: dict[str, str]) -> InteractionResult:
        """Fill in and submit a modal the bot previously showed this user."""
        spec = shown.modal
        if spec is None:
            raise SetupError("That interaction did not respond with a modal")
        components = []
        for row in spec.get("components") or []:
            for item in row.get("components") or []:
                custom_id = item.get("custom_id")
                if custom_id in values:
                    components.append(
                        {
                            "type": ComponentType.ACTION_ROW,
                            "components": [
                                {
                                    "type": ComponentType.TEXT_INPUT,
                                    "custom_id": custom_id,
                                    "value": values[custom_id],
                                }
                            ],
                        }
                    )
        channel = ChannelHandle(
            self._env, self.guild, self._env.backend.get_channel(shown._interaction.channel_id)
        )
        return await self._dispatch_interaction(
            InteractionType.MODAL_SUBMIT, channel, {"custom_id": spec["custom_id"], "components": components}
        )

    # ------------------------------------------------------------------ polls

    async def vote(self, message: MessageLike, *, answer: int) -> None:
        """Cast (or move) this user's vote to ``answer`` (a 1-based answer id)."""
        backend = self._env.backend
        stored = backend.get_message(_channel_id_of(message), message.id)
        # require_permissions enforces view_channel whenever a channel id is passed.
        backend.require_permissions(self.guild.id, self.id, stored.channel_id)
        backend.add_poll_vote(stored.channel_id, stored.id, answer, self.id)
        await self._env.settle()

    async def remove_vote(self, message: MessageLike, *, answer: int) -> None:
        """Retract this user's vote for ``answer``."""
        backend = self._env.backend
        stored = backend.get_message(_channel_id_of(message), message.id)
        backend.require_permissions(self.guild.id, self.id, stored.channel_id)
        backend.remove_poll_vote(stored.channel_id, stored.id, answer, self.id)
        await self._env.settle()

    # ------------------------------------------------------------------ voice

    async def join_voice(
        self, channel: ChannelHandle, *, self_mute: bool = False, self_deaf: bool = False
    ) -> None:
        """Connect to a voice/stage channel (state only — no audio)."""
        self._check(channel, "connect")
        self._env.backend.set_voice_state(
            self.guild.id, self.id, channel.id, self_mute=self_mute, self_deaf=self_deaf
        )
        await self._env.settle()

    async def leave_voice(self) -> None:
        """Disconnect from voice."""
        self._env.backend.set_voice_state(self.guild.id, self.id, None)
        await self._env.settle()

    async def set_voice(self, *, self_mute: bool | None = None, self_deaf: bool | None = None) -> None:
        """Update self-mute/self-deaf while connected."""
        state = self._env.backend.get_guild(self.guild.id).voice_states.get(self.id)
        if state is None:
            raise SetupError("This user is not connected to a voice channel")
        flags: dict[str, Any] = {}
        if self_mute is not None:
            flags["self_mute"] = self_mute
        if self_deaf is not None:
            flags["self_deaf"] = self_deaf
        self._env.backend.set_voice_state(self.guild.id, self.id, state.channel_id, **flags)
        await self._env.settle()

    # -------------------------------------------------------- scheduled events

    async def subscribe_event(self, event: Any) -> None:
        """Mark interest in a scheduled event (accepts an id or a handle with ``.id``)."""
        event_id = event if isinstance(event, int) else event.id
        self._env.backend.set_scheduled_event_subscription(self.guild.id, event_id, self.id, True)
        await self._env.settle()

    async def unsubscribe_event(self, event: Any) -> None:
        event_id = event if isinstance(event, int) else event.id
        self._env.backend.set_scheduled_event_subscription(self.guild.id, event_id, self.id, False)
        await self._env.settle()

    # -------------------------------------------------------------- plumbing

    def _visible_message(self, message: MessageLike) -> Any:
        backend = self._env.backend
        stored = backend.get_message(_channel_id_of(message), message.id)
        if not stored.visible_to(self.id):
            raise SetupError(
                "That message is ephemeral and not visible to this user — "
                "a real user could not interact with it"
            )
        return stored

    async def _component_interaction(self, stored: Any, data: dict[str, Any]) -> InteractionResult:
        backend = self._env.backend
        channel = ChannelHandle(self._env, self.guild, backend.get_channel(stored.channel_id))
        result = await self._dispatch_interaction(
            InteractionType.MESSAGE_COMPONENT,
            channel,
            data,
            extra={"message": dict(serializers.message_payload(backend, stored))},
            source_message_id=stored.id,
        )
        return result

    async def _dispatch_interaction(
        self,
        type: int,
        channel: ChannelHandle,
        data: dict[str, Any],
        *,
        extra: dict[str, Any] | None = None,
        source_message_id: int | None = None,
    ) -> InteractionResult:
        backend = self._env.backend
        record, payload = _interactions.base_payload(
            backend,
            type=type,
            channel_id=channel.id,
            guild_id=self.guild.id,
            user_id=self.id,
            data=data,
        )
        if extra:
            payload.update(extra)
        if source_message_id is not None:
            record.source_message_id = source_message_id
        backend.emit("INTERACTION_CREATE", payload)
        await self._env.settle()
        return InteractionResult(self._env, record)

    def __repr__(self) -> str:
        return f"<MemberActor id={self.id} name={self.name!r} guild={self.guild.id}>"

slash async

slash(channel: ChannelHandle, name: str, /, **options: Any) -> InteractionResult

Invoke a synced slash command (use spaces for subcommands: "config set").

Source code in src/simcord/actors.py
async def slash(self, channel: ChannelHandle, name: str, /, **options: Any) -> InteractionResult:
    """Invoke a synced slash command (use spaces for subcommands: "config set")."""
    self._check(channel, "use_application_commands")
    root, leaf, nesting = self._resolve_command(name)
    leaf_options, resolved = _interactions.build_options(self, name, leaf, options)
    data: dict[str, Any] = {
        "id": root["id"],
        "name": root["name"],
        "type": root.get("type", AppCommandType.CHAT_INPUT),
        "options": _interactions.nest_options(root, nesting, leaf_options),
    }
    if resolved:
        data["resolved"] = resolved
    if root.get("guild_id"):
        data["guild_id"] = root["guild_id"]
    return await self._dispatch_interaction(InteractionType.APPLICATION_COMMAND, channel, data)

context_menu async

context_menu(channel: ChannelHandle, name: str, target: MemberActor | MessageLike) -> InteractionResult

Invoke a user or message context-menu command on a target.

Source code in src/simcord/actors.py
async def context_menu(
    self, channel: ChannelHandle, name: str, target: MemberActor | MessageLike
) -> InteractionResult:
    """Invoke a user or message context-menu command on a target."""
    self._check(channel, "use_application_commands")
    backend = self._env.backend
    if isinstance(target, MemberActor):
        command_type = AppCommandType.USER
        guild = backend.get_guild(self.guild.id)
        resolved: dict[str, Any] = {
            "users": {str(target.id): dict(serializers.user_payload(backend.get_user(target.id)))},
            "members": {
                str(target.id): dict(
                    serializers.member_payload(backend, guild, guild.members[target.id], with_user=False)
                )
            },
        }
    else:
        command_type = AppCommandType.MESSAGE
        stored = backend.get_message(_channel_id_of(target), target.id)
        resolved = {"messages": {str(target.id): dict(serializers.message_payload(backend, stored))}}
    # Context-menu names contain spaces and never nest, so resolve the full
    # name directly rather than treating words as a subcommand path.
    root = self._resolve_root(name, command_type)
    data = {
        "id": root["id"],
        "name": root["name"],
        "type": command_type,
        "target_id": str(target.id),
        "resolved": resolved,
    }
    return await self._dispatch_interaction(InteractionType.APPLICATION_COMMAND, channel, data)

autocomplete async

autocomplete(channel: ChannelHandle, name: str, option: str, value: str, /, **filled: Any) -> list[dict[str, Any]]

Type into an autocomplete option; returns the choices the bot offered.

Source code in src/simcord/actors.py
async def autocomplete(
    self, channel: ChannelHandle, name: str, option: str, value: str, /, **filled: Any
) -> list[dict[str, Any]]:
    """Type into an autocomplete option; returns the choices the bot offered."""
    root, leaf, nesting = self._resolve_command(name)
    leaf_options, _resolved = _interactions.build_options(self, name, leaf, filled, partial=True)
    declared = {o["name"]: o for o in (leaf.get("options") or [])}
    if option not in declared:
        raise SetupError(f"Command '{name}' has no option '{option}'")
    leaf_options.append(
        {"name": option, "type": declared[option]["type"], "value": value, "focused": True}
    )
    data = {
        "id": root["id"],
        "name": root["name"],
        "type": root.get("type", AppCommandType.CHAT_INPUT),
        "options": _interactions.nest_options(root, nesting, leaf_options),
    }
    result = await self._dispatch_interaction(
        InteractionType.APPLICATION_COMMAND_AUTOCOMPLETE, channel, data
    )
    return result.autocomplete_choices or []

click async

click(message: MessageLike, *, label: str | None = None, custom_id: str | None = None) -> InteractionResult

Click a button on a message, exactly as a user could.

Source code in src/simcord/actors.py
async def click(
    self,
    message: MessageLike,
    *,
    label: str | None = None,
    custom_id: str | None = None,
) -> InteractionResult:
    """Click a button on a message, exactly as a user could."""
    stored = self._visible_message(message)
    button = _find_component(
        stored.components, types=(ComponentType.BUTTON,), custom_id=custom_id, label=label
    )
    return await self._component_interaction(
        stored, {"custom_id": button["custom_id"], "component_type": ComponentType.BUTTON}
    )

select async

select(message: MessageLike, values: Sequence[Any], *, custom_id: str | None = None) -> InteractionResult

Choose values in a select menu.

For a string select, values are the option strings. For user/role/ channel/mentionable selects, values are the matching handles (:class:UserHandle/:class:MemberActor, :class:RoleHandle, :class:ChannelHandle) — exactly the entities a real user could pick.

Source code in src/simcord/actors.py
async def select(
    self,
    message: MessageLike,
    values: Sequence[Any],
    *,
    custom_id: str | None = None,
) -> InteractionResult:
    """Choose values in a select menu.

    For a string select, ``values`` are the option strings. For user/role/
    channel/mentionable selects, ``values`` are the matching handles
    (:class:`UserHandle`/:class:`MemberActor`, :class:`RoleHandle`,
    :class:`ChannelHandle`) — exactly the entities a real user could pick.
    """
    stored = self._visible_message(message)
    menu = _find_component(stored.components, types=SELECT_TYPES, custom_id=custom_id, label=None)
    menu_type = menu["type"]

    lo = menu.get("min_values", 1)
    hi = menu.get("max_values", 1)
    if not lo <= len(values) <= hi:
        raise SetupError(f"Select expects between {lo} and {hi} value(s), got {len(values)}")

    data: dict[str, Any] = {"custom_id": menu["custom_id"], "component_type": menu_type}
    if menu_type == ComponentType.STRING_SELECT:
        valid = {o["value"] for o in menu.get("options") or []}
        for value in values:
            if value not in valid:
                error = SetupError(f"Select option {value!r} does not exist")
                error.add_note(f"Available options: {sorted(valid)}")
                raise error
        data["values"] = list(values)
    else:
        self._check_select_handles(menu_type, values)
        resolved: dict[str, dict[str, Any]] = {}
        for value in values:
            _interactions.resolve_handle(self._env.backend, value, resolved, user_id=self.id)
        data["values"] = [str(value.id) for value in values]
        data["resolved"] = resolved
    return await self._component_interaction(stored, data)

submit_modal async

submit_modal(shown: InteractionResult, values: dict[str, str]) -> InteractionResult

Fill in and submit a modal the bot previously showed this user.

Source code in src/simcord/actors.py
async def submit_modal(self, shown: InteractionResult, values: dict[str, str]) -> InteractionResult:
    """Fill in and submit a modal the bot previously showed this user."""
    spec = shown.modal
    if spec is None:
        raise SetupError("That interaction did not respond with a modal")
    components = []
    for row in spec.get("components") or []:
        for item in row.get("components") or []:
            custom_id = item.get("custom_id")
            if custom_id in values:
                components.append(
                    {
                        "type": ComponentType.ACTION_ROW,
                        "components": [
                            {
                                "type": ComponentType.TEXT_INPUT,
                                "custom_id": custom_id,
                                "value": values[custom_id],
                            }
                        ],
                    }
                )
    channel = ChannelHandle(
        self._env, self.guild, self._env.backend.get_channel(shown._interaction.channel_id)
    )
    return await self._dispatch_interaction(
        InteractionType.MODAL_SUBMIT, channel, {"custom_id": spec["custom_id"], "components": components}
    )

vote async

vote(message: MessageLike, *, answer: int) -> None

Cast (or move) this user's vote to answer (a 1-based answer id).

Source code in src/simcord/actors.py
async def vote(self, message: MessageLike, *, answer: int) -> None:
    """Cast (or move) this user's vote to ``answer`` (a 1-based answer id)."""
    backend = self._env.backend
    stored = backend.get_message(_channel_id_of(message), message.id)
    # require_permissions enforces view_channel whenever a channel id is passed.
    backend.require_permissions(self.guild.id, self.id, stored.channel_id)
    backend.add_poll_vote(stored.channel_id, stored.id, answer, self.id)
    await self._env.settle()

remove_vote async

remove_vote(message: MessageLike, *, answer: int) -> None

Retract this user's vote for answer.

Source code in src/simcord/actors.py
async def remove_vote(self, message: MessageLike, *, answer: int) -> None:
    """Retract this user's vote for ``answer``."""
    backend = self._env.backend
    stored = backend.get_message(_channel_id_of(message), message.id)
    backend.require_permissions(self.guild.id, self.id, stored.channel_id)
    backend.remove_poll_vote(stored.channel_id, stored.id, answer, self.id)
    await self._env.settle()

join_voice async

join_voice(channel: ChannelHandle, *, self_mute: bool = False, self_deaf: bool = False) -> None

Connect to a voice/stage channel (state only — no audio).

Source code in src/simcord/actors.py
async def join_voice(
    self, channel: ChannelHandle, *, self_mute: bool = False, self_deaf: bool = False
) -> None:
    """Connect to a voice/stage channel (state only — no audio)."""
    self._check(channel, "connect")
    self._env.backend.set_voice_state(
        self.guild.id, self.id, channel.id, self_mute=self_mute, self_deaf=self_deaf
    )
    await self._env.settle()

leave_voice async

leave_voice() -> None

Disconnect from voice.

Source code in src/simcord/actors.py
async def leave_voice(self) -> None:
    """Disconnect from voice."""
    self._env.backend.set_voice_state(self.guild.id, self.id, None)
    await self._env.settle()

set_voice async

set_voice(*, self_mute: bool | None = None, self_deaf: bool | None = None) -> None

Update self-mute/self-deaf while connected.

Source code in src/simcord/actors.py
async def set_voice(self, *, self_mute: bool | None = None, self_deaf: bool | None = None) -> None:
    """Update self-mute/self-deaf while connected."""
    state = self._env.backend.get_guild(self.guild.id).voice_states.get(self.id)
    if state is None:
        raise SetupError("This user is not connected to a voice channel")
    flags: dict[str, Any] = {}
    if self_mute is not None:
        flags["self_mute"] = self_mute
    if self_deaf is not None:
        flags["self_deaf"] = self_deaf
    self._env.backend.set_voice_state(self.guild.id, self.id, state.channel_id, **flags)
    await self._env.settle()

subscribe_event async

subscribe_event(event: Any) -> None

Mark interest in a scheduled event (accepts an id or a handle with .id).

Source code in src/simcord/actors.py
async def subscribe_event(self, event: Any) -> None:
    """Mark interest in a scheduled event (accepts an id or a handle with ``.id``)."""
    event_id = event if isinstance(event, int) else event.id
    self._env.backend.set_scheduled_event_subscription(self.guild.id, event_id, self.id, True)
    await self._env.settle()

Results

Returned by the interaction verbs (slash, context_menu, click, select, submit_modal). See Slash commands → Inspecting the result.

simcord.InteractionResult

Everything that happened in response to a simulated interaction.

Source code in src/simcord/results.py
class InteractionResult:
    """Everything that happened in response to a simulated interaction."""

    def __init__(self, env: Env, interaction: Interaction) -> None:
        self._env = env
        self._interaction = interaction

    @property
    def acknowledged(self) -> bool:
        return self._interaction.responded

    @property
    def deferred(self) -> bool:
        return self._interaction.deferred

    @property
    def ephemeral(self) -> bool:
        return self._interaction.ephemeral

    @property
    def modal(self) -> dict[str, Any] | None:
        """The raw modal payload, if the bot responded with a modal."""
        return self._interaction.modal

    @property
    def autocomplete_choices(self) -> list[dict[str, Any]] | None:
        return self._interaction.autocomplete_choices

    @property
    def response(self) -> ResponseMessage | None:
        interaction = self._interaction
        if interaction.message_id is None:
            return None
        message = self._env.backend.get_message(interaction.channel_id, interaction.message_id)
        return ResponseMessage(self._env, message)

    @property
    def followups(self) -> list[ResponseMessage]:
        out = []
        for message_id in self._interaction.followup_ids:
            try:
                message = self._env.backend.get_message(self._interaction.channel_id, message_id)
            except BackendError:
                continue  # deleted followup (Unknown Message)
            out.append(ResponseMessage(self._env, message))
        return out

    def __repr__(self) -> str:
        return (
            f"<InteractionResult acknowledged={self.acknowledged} "
            f"kind={self._interaction.response_kind!r} "
            f"response={self.response!r} followups={len(self.followups)}>"
        )

modal property

modal: dict[str, Any] | None

The raw modal payload, if the bot responded with a modal.

simcord.ResponseMessage

A message the bot sent in response to an interaction.

Source code in src/simcord/results.py
class ResponseMessage:
    """A message the bot sent in response to an interaction."""

    def __init__(self, env: Env, message: Message) -> None:
        self._env = env
        self._message = message

    @property
    def id(self) -> int:
        return self._message.id

    @property
    def channel_id(self) -> int:
        return self._message.channel_id

    @property
    def content(self) -> str:
        return self._message.content

    @property
    def embeds(self) -> list[discord.Embed]:
        return [discord.Embed.from_dict(e) for e in self._message.embeds]

    @property
    def components(self) -> list[dict[str, Any]]:
        return list(self._message.components)

    @property
    def ephemeral(self) -> bool:
        return bool(self._message.flags & EPHEMERAL_FLAG)

    @property
    def message(self) -> discord.Message:
        return to_discord_message(self._env, self._message)

    def __repr__(self) -> str:
        return f"<ResponseMessage id={self.id} content={self.content!r} ephemeral={self.ephemeral}>"

Assertions

Runner-agnostic helpers whose failure messages print what the bot actually did. See Errors & diagnostics → Assertions.

simcord.assert_sent

assert_sent(channel: ChannelHandle, *, content: str | None = None, contains: str | None = None, embed_title: str | None = None, viewer: MemberActor | UserHandle | None = None) -> None

Assert the channel's most recent (visible) message matches.

viewer= filters to what that user can see, hiding ephemeral messages addressed to others — the same rule as :meth:ChannelHandle.history.

Source code in src/simcord/asserts.py
def assert_sent(
    channel: ChannelHandle,
    *,
    content: str | None = None,
    contains: str | None = None,
    embed_title: str | None = None,
    viewer: MemberActor | UserHandle | None = None,
) -> None:
    """Assert the channel's most recent (visible) message matches.

    ``viewer=`` filters to what that user can see, hiding ephemeral messages
    addressed to others — the same rule as :meth:`ChannelHandle.history`.
    """
    history = channel.history(viewer=viewer)
    if not history:
        raise AssertionError(f"expected a message in {channel!r}, but none was sent")
    last = history[-1]
    problems = _check_fields(
        last, content=content, contains=contains, embed_title=embed_title, ephemeral=None
    )
    if problems:
        recent = "\n".join(f"  - {m.content!r}" for m in history[-5:])
        raise AssertionError(
            "last message did not match:\n" + "\n".join(problems) + f"\nrecent messages:\n{recent}"
        )

simcord.assert_responded

assert_responded(result: InteractionResult, *, content: str | None = None, contains: str | None = None, embed_title: str | None = None, ephemeral: bool | None = None) -> None

Assert the interaction produced a response message matching the given fields.

On failure the message includes the interaction's repr, which shows whether it acknowledged, deferred, or opened a modal instead of sending a response.

Source code in src/simcord/asserts.py
def assert_responded(
    result: InteractionResult,
    *,
    content: str | None = None,
    contains: str | None = None,
    embed_title: str | None = None,
    ephemeral: bool | None = None,
) -> None:
    """Assert the interaction produced a response message matching the given fields.

    On failure the message includes the interaction's repr, which shows whether it
    acknowledged, deferred, or opened a modal instead of sending a response."""
    response = result.response
    if response is None:
        raise AssertionError(f"interaction produced no response message\nactual: {result!r}")
    problems = _check_fields(
        response, content=content, contains=contains, embed_title=embed_title, ephemeral=ephemeral
    )
    if problems:
        raise AssertionError(
            "interaction response did not match:\n" + "\n".join(problems) + f"\nactual: {result!r}"
        )

simcord.assert_message

assert_message(message: MessageLike, *, content: str | None = None, contains: str | None = None, embed_title: str | None = None, ephemeral: bool | None = None) -> None

Assert a single message matches the given fields. Each field is checked only when provided. Accepts a ResponseMessage or a real discord.Message.

Source code in src/simcord/asserts.py
def assert_message(
    message: MessageLike,
    *,
    content: str | None = None,
    contains: str | None = None,
    embed_title: str | None = None,
    ephemeral: bool | None = None,
) -> None:
    """Assert a single message matches the given fields. Each field is checked
    only when provided. Accepts a ``ResponseMessage`` or a real ``discord.Message``."""
    problems = _check_fields(
        message, content=content, contains=contains, embed_title=embed_title, ephemeral=ephemeral
    )
    if problems:
        raise AssertionError("message did not match:\n" + "\n".join(problems) + f"\nactual: {message!r}")

simcord.assert_error

assert_error(env: Env, exc_type: type[BaseException] = BaseException, *, code: int | None = None, contains: str | None = None) -> BaseException

Assert the bot captured an error matching exc_type/code/contains.

discord.py wraps callback failures (e.g. CommandInvokeError.original), so the type and code are matched against the error and its .original. Returns the matched error. Reading env.errors marks errors inspected, so this also satisfies the teardown check_errors guard.

Source code in src/simcord/asserts.py
def assert_error(
    env: Env,
    exc_type: type[BaseException] = BaseException,
    *,
    code: int | None = None,
    contains: str | None = None,
) -> BaseException:
    """Assert the bot captured an error matching ``exc_type``/``code``/``contains``.

    discord.py wraps callback failures (e.g. ``CommandInvokeError.original``), so
    the type and code are matched against the error *and* its ``.original``.
    Returns the matched error. Reading ``env.errors`` marks errors inspected, so
    this also satisfies the teardown ``check_errors`` guard.
    """
    captured = env.errors

    def matches(error: BaseException) -> bool:
        # Match against the error and its unwrapped .original. Each criterion is
        # checked across both candidates independently — in the common case the
        # wrapped original satisfies all of them together (discord.py puts the
        # type, code and message on the original, not the CommandInvokeError).
        candidates = [error]
        original = getattr(error, "original", None)
        if original is not None:
            candidates.append(original)
        if not any(isinstance(c, exc_type) for c in candidates):
            return False
        if code is not None and not any(getattr(c, "code", None) == code for c in candidates):
            return False
        if contains is not None and not any(contains in str(c) for c in candidates):
            return False
        return True

    for error in captured:
        if matches(error):
            return error

    wanted = [exc_type.__name__]
    if code is not None:
        wanted.append(f"code={code}")
    if contains is not None:
        wanted.append(f"containing {contains!r}")
    if not captured:
        raise AssertionError(f"expected an error ({', '.join(wanted)}), but the bot captured none")
    listed = "\n".join(f"  - {e!r}" for e in captured)
    raise AssertionError(f"no captured error matched ({', '.join(wanted)}); captured:\n{listed}")

simcord.assert_no_errors

assert_no_errors(env: Env) -> None

Assert the bot ran cleanly: raises an ExceptionGroup of anything it captured, or does nothing. A clearer-named pairing for :func:assert_error over :meth:Env.raise_errors.

Source code in src/simcord/asserts.py
def assert_no_errors(env: Env) -> None:
    """Assert the bot ran cleanly: raises an ``ExceptionGroup`` of anything it
    captured, or does nothing. A clearer-named pairing for :func:`assert_error`
    over :meth:`Env.raise_errors`."""
    env.raise_errors()

Errors

simcord.BackendError

Bases: Exception

Source code in src/simcord/backend/errors.py
class BackendError(Exception):
    def __init__(self, status: int, code: int, message: str) -> None:
        super().__init__(f"{status} (error code: {code}): {message}")
        self.status = status
        self.code = code
        self.message = message

    def to_json(self) -> dict[str, Any]:
        return {"code": self.code, "message": self.message}

simcord.SetupError

Bases: Exception

The test mis-set-up or mis-drove the virtual world (not a bot bug).

Source code in src/simcord/backend/errors.py
class SetupError(Exception):
    """The test mis-set-up or mis-drove the virtual world (not a bot bug)."""

simcord.RouteNotImplemented

Bases: BackendError

An unimplemented route — a parity gap that must surface loudly.

Although it subclasses :class:BackendError, the transports catch it (and :class:UnsupportedField) before the generic except BackendError that maps backend failures onto Discord errors, so it is never disguised as an HTTPException a broad except could swallow. For the same reason a handler must never wrap a route lookup or :meth:RequestContext.fields in a broad except BackendError — doing so would re-hide the parity gap.

Source code in src/simcord/http/router.py
class RouteNotImplemented(BackendError):
    """An unimplemented route — a parity gap that must surface loudly.

    Although it subclasses :class:`BackendError`, the transports catch it (and
    :class:`UnsupportedField`) *before* the generic ``except BackendError`` that
    maps backend failures onto Discord errors, so it is never disguised as an
    ``HTTPException`` a broad ``except`` could swallow. For the same reason a
    handler must never wrap a route lookup or :meth:`RequestContext.fields` in a
    broad ``except BackendError`` — doing so would re-hide the parity gap.
    """

    def __init__(self, method: str, path: str) -> None:
        super().__init__(501, 0, f"simcord does not implement '{method} {path}' yet.")
        self.method = method
        self.path = path
        self.add_note(
            "See the parity matrix in the docs; please open an issue if your bot needs this route: "
            "https://github.com/SilentHacks/simcord/issues"
        )

simcord.UnsupportedField

Bases: BackendError

A handler was sent a request field it does not honour.

The field-level analogue of :class:RouteNotImplemented: silently dropping an edit field would let a bot test pass while the real edit diverges. Like RouteNotImplemented it is not disguised as an HTTPException (the transports re-raise it), so a broad except discord.HTTPException cannot swallow a parity gap. It does subclass :class:BackendError, so — as with RouteNotImplemented — a handler must never wrap :meth:RequestContext.fields in a broad except BackendError, which would re-hide the gap.

Source code in src/simcord/http/router.py
class UnsupportedField(BackendError):
    """A handler was sent a request field it does not honour.

    The field-level analogue of :class:`RouteNotImplemented`: silently dropping
    an edit field would let a bot test pass while the real edit diverges. Like
    ``RouteNotImplemented`` it is *not* disguised as an ``HTTPException`` (the
    transports re-raise it), so a broad ``except discord.HTTPException`` cannot
    swallow a parity gap. It does subclass :class:`BackendError`, so — as with
    ``RouteNotImplemented`` — a handler must never wrap :meth:`RequestContext.fields`
    in a broad ``except BackendError``, which would re-hide the gap.
    """

    def __init__(self, method: str, path: str, fields: list[str], *, note: str | None = None) -> None:
        joined = ", ".join(fields)
        super().__init__(
            501, 0, f"simcord does not implement the field(s) [{joined}] on '{method} {path}' yet."
        )
        self.method = method
        self.path = path
        self.fields = list(fields)
        # ``note`` lets a handler explain a *deliberate* refusal (a real discord.py
        # field simcord will not model), so the failure reads as a considered gap
        # rather than an unfinished route; otherwise point at the parity matrix.
        self.add_note(
            note
            or "See the parity matrix in the docs; please open an issue if your bot needs this field: "
            "https://github.com/SilentHacks/simcord/issues"
        )