diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..cc06c46 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"d0a8b446-9af2-42d3-81d4-b722b4678616","pid":18056,"procStart":"21098558","acquiredAt":1783128435683} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 79bfb80..18c0e7e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,3 @@ erl_crash.dump # Ignore package tarball (built via "mix hex.build"). dexcord-*.tar - -.claude diff --git a/README.md b/README.md index e0fabad..78523ed 100644 --- a/README.md +++ b/README.md @@ -1,502 +1,21 @@ # Dexcord -A reliability-first, single-shard Discord bot library for Elixir. - -## Why this exists - -Dexcord exists because [Nostrum](https://github.com/Kraigie/nostrum)'s gateway -handling is unstable in practice: it can lose the websocket and either never -reconnect, or reconnect into a session that silently delivers no more events - -forcing a full bot restart to notice and recover. That failure mode is the -whole reason this library exists, so it is treated as the product, not an -edge case: - -* **Resume-over-reidentify.** A crashed gateway process comes back and sends - an op 6 RESUME using session state that outlives it, instead of burning an - IDENTIFY (budgeted ~1000/day, 1/5s) and losing in-flight state. -* **Zombie-connection detection.** If a heartbeat goes unacknowledged before - the next beat is due, the connection is assumed dead and force-closed into a - resume - the exact Nostrum failure mode this was built to fix. -* **Crash-surviving sessions.** `session_id`, `last_seq`, and the resume URL - live in ETS owned by a small, rarely-crashing process, not in the gateway - process itself. A gateway crash costs a RESUME, not a lost session. -* **No silent wedging.** Every connection state in the gateway `gen_statem` - carries a timeout. There is no state the process can sit in forever without - either making progress or timing out into backoff. - -Scope is deliberately narrow: single shard, single machine, small-to-medium -guild counts. No voice. No sharding. If you need those, this isn't (yet) your -library. If you need a bot that survives a laptop going to sleep, a flaky -network, or its own gateway process crashing, that's exactly what this is -for. - -## Requirements - -* Elixir `~> 1.18` (for the built-in `JSON` module - Dexcord ships with no - Jason dependency). +**TODO: Add description** ## Installation -Dexcord isn't published on Hex yet. Depend on it via `path:` (local checkout) -or `git:`: +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `dexcord` to your list of dependencies in `mix.exs`: ```elixir def deps do [ - {:dexcord, path: "../dexcord"} - # or: - # {:dexcord, git: "https://github.com/luna/dexcord.git"} + {:dexcord, "~> 0.1.0"} ] end ``` -## Quickstart +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at . -Dexcord is a library, not an application that starts itself - you add one -child to your own supervision tree: - -```elixir -defmodule MyBot.Application do - use Application - - def start(_type, _args) do - children = [ - {Dexcord, - token: System.fetch_env!("DISCORD_TOKEN"), - handler: MyBot.Handler, - intents: :all, - cache_presences: true, - request_guild_members: false, - slash: MyBot.Slash, - slash_guild_ids: [System.get_env("DEV_GUILD_ID")]} - ] - - Supervisor.start_link(children, strategy: :one_for_one, name: MyBot.Supervisor) - end -end -``` - -Options accepted by `{Dexcord, opts}` (validated eagerly by `Dexcord.child_spec/1`, -raising `ArgumentError` on anything missing or malformed): - -* `:token` (required) - the bot token. -* `:handler` (required) - a module using `Dexcord.Handler`. -* `:intents` - `:all`, `:default`, a list of intent atoms, or an integer - bitmask. Default `:default` (`guilds`, `guild_messages`, `direct_messages`, - `message_content`). `:all` requests every documented intent, including the - three privileged ones - see [Privileged intents](#privileged-intents) below. -* `:cache_presences` - cache `PRESENCE_UPDATE` events (default `false`). This - is the chattiest event under `:all`, so caching it costs real memory on a - busy server; leave it off unless you actually read presences. -* `:request_guild_members` - on each `GUILD_CREATE`, request the guild's full - member list via op 8 (default `false`). `GUILD_CREATE` already includes full - member lists for guilds under Discord's `large_threshold` (~250 members), so - this only matters for larger guilds. Requires the `:guild_members` intent. -* `:slash` - a module using `Dexcord.Slash`. When set, `Dexcord.Slash.Registrar` - registers its `commands/0` at startup, and `INTERACTION_CREATE` events are - auto-routed to it (the raw event still reaches `:handler` too). -* `:slash_guild_ids` - a list of guild ids (strings or integers). When - present, slash commands are registered per-guild (propagates instantly - - good for development) instead of globally (propagates in ~1h - for - production). See [Slash command registration](#slash-command-registration). -* `:gateway_url` - override the gateway URL (e.g. `"ws://127.0.0.1:4000"`); - when set, the `GET /gateway/bot` lookup is skipped. Meant for pointing at a - fake gateway in tests, not production use. - -## The three interaction styles - -Dexcord gives you three ways to react to what's happening on Discord, and -they compose - the raw handler always sees every event, regardless of -whether prefix or slash routing also fires. - -### 1. Raw handler events - -Every gateway dispatch event reaches your `Dexcord.Handler` as a -`{event_name, data}` tuple, where `data` is the raw string-keyed payload map -Discord sent (no atom keys, no structs): - -```elixir -defmodule MyBot.Handler do - use Dexcord.Handler - - def handle_event({:MESSAGE_CREATE, msg}) do - IO.puts("#{msg["author"]["username"]}: #{msg["content"]}") - end - - def handle_event({:PRESENCE_UPDATE, presence}) do - # only fires if cache_presences: true and presences are being cached - end -end -``` - -`use Dexcord.Handler` injects a catch-all `handle_event/1` clause, so you -only write the clauses you care about; unmatched events are silently -ignored. See [the relay-everything-handler note](#event-handling-semantics) -if you want a handler that logs *every* event through one clause. - -### 2. Prefix commands - -`Dexcord.Prefix.Router` is a small command router for plain-text `!command` -style bots. Define a router, then call `Dexcord.Prefix.dispatch/2` from your -handler's `MESSAGE_CREATE` clause: - -```elixir -defmodule MyBot.Commands do - use Dexcord.Prefix.Router - - def handle_command("ping", _args, msg) do - Dexcord.Api.create_message(msg["channel_id"], "pong") - end - - def handle_command("echo", args, msg) do - Dexcord.Api.create_message(msg["channel_id"], Enum.join(args, " ")) - end -end - -defmodule MyBot.Handler do - use Dexcord.Handler - - def handle_event({:MESSAGE_CREATE, msg}) do - Dexcord.Prefix.dispatch(msg, prefix: "!", to: MyBot.Commands) - end -end -``` - -`dispatch/2` skips messages authored by a bot (including the bot's own -messages, checked both via the payload's `author.bot` flag and against the -cached bot user id) so a command that replies in-channel can't recursively -trigger itself. `Dexcord.Prefix.parse/2` is exposed separately as a pure -function if you want the prefix/command/args split without the bot-author -check or the router dispatch. - -### 3. Slash commands - -A `Dexcord.Slash` module declares its command definitions and handles -routed interactions: - -```elixir -defmodule MyBot.Slash do - use Dexcord.Slash - - def commands do - [%{name: "ping", description: "Replies with pong."}] - end - - def handle_interaction("ping", interaction) do - Dexcord.Slash.respond(interaction, "pong") - end -end -``` - -Wire it up with `slash: MyBot.Slash` (and, in development, `slash_guild_ids:` -for instant registration). Response helpers, all going through -`Dexcord.Api`: - -* `Dexcord.Slash.respond/2` - an immediate response. Takes a binary (used as - `content`) or a map: `%{content: "...", embeds: [...], components: [...], - ephemeral: true}`. `ephemeral: true` sets the message's ephemeral flag so - only the invoking user sees it: - - ```elixir - def handle_interaction("ping", interaction) do - Dexcord.Slash.respond(interaction, %{content: "pong", ephemeral: true}) - end - ``` - -* `Dexcord.Slash.respond_later/1` - a deferred response (shows "thinking..." - while you do slower work). -* `Dexcord.Slash.followup/2` - sends a followup message after a deferred or - initial response. -* `Dexcord.Slash.edit_response/2` - edits the original response. - -A caller-supplied integer `flags` is preserved and OR-ed with the ephemeral -bit, so `%{content: "...", flags: 4}` passes `4` through and -`%{content: "...", flags: 4, ephemeral: true}` sends `68`. - -### Components and modals - -Interactions are routed on their **top-level type**: application commands -(type 2) go to `handle_interaction/2` by command name, message components -(type 3, e.g. buttons and selects) go to `handle_component/2`, and modal -submits (type 5) go to `handle_modal/2` - both keyed on the interaction's -`custom_id`: - -```elixir -defmodule MyBot.Slash do - use Dexcord.Slash - - def commands, do: [%{name: "menu", description: "Open the menu."}] - - def handle_interaction("menu", itx) do - Dexcord.Slash.respond(itx, %{ - content: "Pick one:", - components: [ - %{type: 1, - components: [%{type: 2, style: 1, label: "Refresh", custom_id: "refresh"}]} - ] - }) - end - - # button press -> keyed on custom_id - def handle_component("refresh", itx), do: Dexcord.Slash.respond(itx, "refreshed") - - # modal submit -> keyed on custom_id - def handle_modal("feedback_form", itx), do: Dexcord.Slash.respond(itx, "thanks!") -end -``` - -`use Dexcord.Slash` injects catch-all clauses for all three callbacks. The -`handle_interaction/2` fallback logs a **warning** for an unrecognized command -name (a command you declared but did not handle is unexpected). The -`handle_component/2` and `handle_modal/2` callbacks are **optional** and their -fallbacks only log at **debug** - components and modals are routinely handled -elsewhere in a bot, so an unmatched `custom_id` is not an error. A module that -was compiled defining only `handle_interaction/2` keeps working unchanged. - -## Privileged intents - -Three intents are *privileged* and must be explicitly enabled in the Discord -Developer Portal in addition to being requested in `:intents` - Discord will -otherwise close the gateway connection with code **4014 (disallowed -intents)** on every connection attempt. With `intents: :all` (which requests -all three) this is the most likely first-run failure. - -Enable the toggles you need under **Bot -> Privileged Gateway Intents**: - -| Intent atom | Portal toggle | -|---|---| -| `:guild_members` | SERVER MEMBERS INTENT | -| `:guild_presences` | PRESENCE INTENT | -| `:message_content` | MESSAGE CONTENT INTENT | - -`Dexcord.Intents.disallowed_message/1` builds a bespoke error message naming -only the privileged intents you actually requested and the exact toggle each -needs - this is what gets logged when a 4014 fires, so you don't have to -cross-reference Discord's docs to figure out which checkbox you missed. - -## Cache - -Under `intents: :all`, Discord's gateway is a firehose describing the -current state of every guild the bot is in. `Dexcord.Cache` folds that -stream into ETS tables so any process can read guild/channel/member/etc. -state without a REST round-trip: - -| Table | Key | -|---|---| -| `:dexcord_me` | `:me` | -| `:dexcord_guilds` | `guild_id` | -| `:dexcord_channels` | `channel_id` | -| `:dexcord_users` | `user_id` | -| `:dexcord_members` | `{guild_id, user_id}` | -| `:dexcord_roles` | `{guild_id, role_id}` | -| `:dexcord_presences` | `{guild_id, user_id}` (only populated when `cache_presences: true`) | -| `:dexcord_voice_states` | `{guild_id, user_id}` | - -Read API examples: - -```elixir -{:ok, guild} = Dexcord.Cache.guild(guild_id) -members = Dexcord.Cache.members(guild_id) -{:ok, member} = Dexcord.Cache.member(guild_id, user_id) -{:ok, user} = Dexcord.Cache.user(user_id) -roles = Dexcord.Cache.roles(guild_id) -me = Dexcord.Cache.me!() -``` - -Every read function has a `!` bang variant that raises instead of returning -`:error`. Reads go straight to public ETS tables from any process - there is -no GenServer round-trip on the read path. - -**Staleness is best-effort, by design.** The cache is written by the -Dispatcher, in exact gateway order, but the Dispatcher's own process can -crash (tables are lost and recreated empty), and a resume gap can rarely -redeliver or skip an event. Treat the cache as a fast, usually-correct local -view and REST as the source of truth when it matters. - -* `cache_presences: true` is required for the presences table to be - populated at all - it's the chattiest event stream under `:all`, so the - write is skipped entirely (not built-then-discarded) when this is off. -* `request_guild_members: true` requests full member lists via op 8 for - guilds over Discord's `large_threshold`, so `Dexcord.Cache.members/1` - covers more than the ~250 members `GUILD_CREATE` already includes for free. - -## REST - -`Dexcord.Api` wraps Discord's REST API over Finch, with automatic rate -limiting - `Dexcord.Api.Ratelimit` learns each route's bucket from response -headers and makes the calling process sleep as needed; you never manage -rate limits yourself. - -Typed endpoints (thin wrappers, string-keyed request/response maps): - -``` -get_gateway_bot/0 get_current_user/0 get_current_application/0 -get_user/1 get_channel/1 get_guild/1 -create_dm/1 create_message/2 edit_message/3 -delete_message/2 create_reaction/3 -create_interaction_response/3 -edit_original_interaction_response/3 -create_followup_message/3 -bulk_overwrite_global_commands/2 -bulk_overwrite_guild_commands/3 -``` - -For anything not covered, `Dexcord.Api.request/4` is the escape hatch every -typed endpoint is built on: - -```elixir -Dexcord.Api.request(:patch, "/guilds/#{guild_id}", %{"name" => "New Name"}) -# => {:ok, map} | {:ok, nil} | {:ok, {:raw, binary}} | {:error, %Dexcord.Api.Error{}} -``` - -Every call has a total wall-clock deadline covering all internal rate-limit -waits and 429 retries (default 30s), configurable: - -```elixir -config :dexcord, :api_deadline_ms, 60_000 -``` - -Exceeding the deadline returns `{:error, %Dexcord.Api.Error{status: nil, -message: "rate limit deadline exceeded"}}` rather than blocking forever. - -## Slash command registration - -`Dexcord.Slash.Registrar` runs as a startup Task (added to the supervision -tree only when `slash:` is configured) that bulk-overwrites your commands via -REST, independently of the gateway connection: - -* **Guild-scoped** (`slash_guild_ids: [...]`) - propagates instantly, ideal - for development. **Global commands are never touched in this mode** - - overwriting an empty global command list would wipe a bot's real - production commands, so guild mode logs a hint instead of doing that. -* **Global** (no `slash_guild_ids`) - overwrites the application's global - commands. Propagation takes up to ~1h. As a symmetric guard, an **empty** - `commands/0` in global mode is **skipped** (it would wipe every production - command on every boot); the Registrar logs how to do it on purpose - - `Dexcord.Api.bulk_overwrite_global_commands(app_id, [])` - instead. - -Registration is idempotent (a bulk overwrite is safe to repeat). The Registrar -owns its own retry policy rather than leaning on supervisor restarts: it is -`restart: :temporary` (so the supervisor never restarts it), and a failed -registration can therefore **never cycle the gateway** or share the tree's -`max_restarts` budget with it. On failure it retries in-process with increasing -back-off (default `2s` then `10s`, configurable via -`config :dexcord, :registrar_retry_delays`); if every attempt fails it logs a -loud error and exits normally, leaving the rest of the tree - gateway included - -running untouched. - -## Event handling semantics - -* **Task-per-event.** Each dispatch event spawns one `Task` running your - handler's `handle_event/1`. This means a crashing handler kills only its - own Task - never the Dispatcher, never the gateway - but it also means - **there is no cross-event ordering guarantee for handlers**: two events - dispatched back-to-back may have their handler Tasks scheduled in either - order. The **cache is always in exact gateway order** (written inline by - the Dispatcher before any handler Task is spawned), so if you need ordering - guarantees, read from `Dexcord.Cache` rather than relying on handler - execution order. -* **Handler crash isolation.** A raised exception or exit inside - `handle_event/1` only takes down that one Task; the bot keeps running and - keeps dispatching subsequent events. -* **The relay-everything-handler note.** `use Dexcord.Handler` injects a - catch-all `handle_event(_event), do: :ok` clause via `@before_compile`. If - your handler module defines *only* a single catch-all clause (e.g. one that - matches any `event` and relays it somewhere), that clause already matches - everything, so the injected catch-all becomes an unreachable clause - - which Elixir warns about, and which fails the build under - `--warnings-as-errors`. For that specific shape of handler, declare - `@behaviour Dexcord.Handler` directly instead of `use Dexcord.Handler`: - - ```elixir - defmodule MyBot.RelayHandler do - @behaviour Dexcord.Handler - - @impl true - def handle_event(event), do: MyBot.EventLog.record(event) - end - ``` - -## Porting from Nostrum - -| Nostrum | Dexcord | -|---|---| -| `Nostrum.Consumer` `handle_event/1` callback | `Dexcord.Handler` `handle_event/1` callback (`use Dexcord.Handler`) | -| `Nostrum.Api.*` | `Dexcord.Api.*` (typed endpoints + `request/4` escape hatch) | -| `Nostrum.Cache.*` (several cache modules) | `Dexcord.Cache` (one module, one ETS table per entity type) | -| `%Nostrum.Struct.Message{}` etc. (atom-keyed structs) | string-keyed maps everywhere - `msg["content"]`, not `msg.content` | -| Auto-starting `:nostrum` application | you add `{Dexcord, opts}` to your own supervision tree | - -The biggest day-to-day adjustment is the lack of structs: every payload - -messages, guilds, members, interactions - is the raw string-keyed map -Discord sent over the wire. This costs `msg["content"]` instead of -`msg.content`, but means Dexcord never has to guess a struct shape ahead of a -Discord API change, and never mints atoms from wire data. - -## Reliability design - -The gateway is a `gen_statem` (`callback_mode: [:handle_event_function, -:state_enter]`) with six explicit states, each carrying a timeout so nothing -can wedge silently: - -``` -:disconnected -> :connecting -> :hello_wait -> :identifying -> :connected - \-> :resuming -/ -``` - -* **`:disconnected`** waits out an exponential backoff (`min(1s * 2^attempt, - 60s)`, ±20% jitter) before attempting to reconnect. -* **`:connecting`** opens the websocket - to the session's stored resume URL - if one exists, otherwise a fresh `GET /gateway/bot` lookup (falling back to - `wss://gateway.discord.gg`). -* **`:hello_wait`** receives HELLO (op 10), arms the heartbeat, and either - RESUMEs (if a session exists) or IDENTIFYs. -* **`:identifying`** waits for READY, which establishes a fresh session - (`session_id` + resume URL, stored in `Dexcord.Session`'s ETS) and resets - backoff. -* **`:resuming`** forwards replayed dispatches in gateway order as they - arrive, re-arming its own timeout on every frame so a large replay backlog - can't trip it while true silence still does. On RESUMED, backoff resets and - the connection is live. -* **`:connected`** is steady state: dispatches flow to the cache and handler, - heartbeats are exchanged, and the heartbeat timer doubles as the liveness - watchdog. - -**Resume protocol.** `Dexcord.Session` stores everything a restarted gateway -needs to RESUME instead of re-IDENTIFYing: `session_id`, `last_seq`, -`resume_gateway_url`. This state lives in a small ETS-owning GenServer that -sits *outside* the gateway process (a `:one_for_one` supervision strategy -means a gateway crash doesn't take Session down with it), so a killed and -restarted gateway reads this back and resumes with no lost session and no -extra IDENTIFY. A resume-loop cap abandons resuming for a fresh IDENTIFY -after too many consecutive failed resume attempts, so a persistently-broken -resume path can't spin forever. - -**Zombie-connection detection.** Heartbeat ACKs are tracked; if a heartbeat -is sent and the next beat comes due without an ACK in between, the -connection is assumed dead (a "zombie" - the socket looks alive but Discord -has stopped responding), closed with code 4000, and resumed. This is the -specific Nostrum failure mode Dexcord was built to fix: a connection that -looks open but silently stops delivering events. - -**Close-code handling.** Every gateway close code maps to one of three -recovery actions (`Dexcord.Gateway.Payload.close_action/1`): `:fatal` (4004 -auth failure, 4013 invalid intents, 4014 disallowed intents - unrecoverable -without a config change, so the gateway marks the session fatal and refuses -to reconnect), `:reidentify` (4007 invalid seq, 4009 session timed out - the -session is dead, clear it and IDENTIFY fresh), or `:resume` (everything else, -including 1000/1001 from the server and any TCP/TLS transport failure - try -to pick the session back up). - -**Identify-storm defenses**, layered so no single one is a single point of -failure: - -* A fatal-flag fast-exit: on a fatal close, the session is marked fatal and - the gateway exits; on restart it sees the flag and exits immediately with - *no network call at all*, so the supervisor's `max_restarts` trips in - milliseconds and the failure propagates to the host application instead of - hammering Discord with repeated bad IDENTIFYs. -* A minimum gap between consecutive IDENTIFYs (enforced even across process - restarts, via a timestamp in `Dexcord.Session`). -* Resume-first: every reconnect prefers RESUME over IDENTIFY whenever a - session exists, since IDENTIFY is the scarce, budgeted operation. diff --git a/examples/echo_bot.exs b/examples/echo_bot.exs deleted file mode 100644 index e3ac3b4..0000000 --- a/examples/echo_bot.exs +++ /dev/null @@ -1,89 +0,0 @@ -# Dexcord example bot: raw events, a prefix command, and a slash command. -# -# Run it from the repo root with: -# -# DISCORD_TOKEN=your-bot-token elixir examples/echo_bot.exs -# -# Optionally set DEV_GUILD_ID to a guild id to register the `/ping` slash -# command to that guild only (instant propagation, good for development). -# Without it, the command registers globally (propagation can take ~1h). -# -# What it demonstrates: -# -# * `intents: :all` - every documented gateway intent, including the -# privileged ones. If you see a 4014 (disallowed intents) error in the -# logs, enable SERVER MEMBERS / PRESENCE / MESSAGE CONTENT under -# Bot -> Privileged Gateway Intents in the Discord Developer Portal (see -# the README's "Privileged intents" section). -# * A raw `Dexcord.Handler` that logs every `MESSAGE_CREATE` and sets the -# bot's presence on `READY`. -# * A `!ping` prefix command via `Dexcord.Prefix.Router`. -# * A `/ping` slash command that responds ephemerally. -# -# This script does not connect to Discord on its own - it only starts if you -# provide a real DISCORD_TOKEN and run it, at which point it connects for -# real and stays running until you stop it (Ctrl-C twice). - -Mix.install([ - {:dexcord, path: Path.expand("..", __DIR__)} -]) - -defmodule EchoBot.Commands do - @moduledoc false - use Dexcord.Prefix.Router - - def handle_command("ping", _args, msg) do - Dexcord.Api.create_message(msg["channel_id"], "pong") - end -end - -defmodule EchoBot.Slash do - @moduledoc false - use Dexcord.Slash - - def commands do - [%{name: "ping", description: "Replies with pong (only you can see it)."}] - end - - def handle_interaction("ping", interaction) do - Dexcord.Slash.respond(interaction, %{content: "pong", ephemeral: true}) - end -end - -defmodule EchoBot.Handler do - @moduledoc false - use Dexcord.Handler - - def handle_event({:READY, data}) do - IO.puts("Ready as #{get_in(data, ["user", "username"])}") - - Dexcord.update_presence(%{ - "since" => nil, - "activities" => [%{"name" => "!ping / /ping", "type" => 0}], - "status" => "online", - "afk" => false - }) - end - - def handle_event({:MESSAGE_CREATE, msg}) do - IO.puts("#{get_in(msg, ["author", "username"])}: #{msg["content"]}") - Dexcord.Prefix.dispatch(msg, prefix: "!", to: EchoBot.Commands) - end -end - -token = System.fetch_env!("DISCORD_TOKEN") -dev_guild_id = System.get_env("DEV_GUILD_ID") - -children = [ - {Dexcord, - token: token, - handler: EchoBot.Handler, - intents: :all, - slash: EchoBot.Slash, - slash_guild_ids: if(dev_guild_id, do: [dev_guild_id])} -] - -{:ok, _supervisor} = - Supervisor.start_link(children, strategy: :one_for_one, name: EchoBot.Supervisor) - -Process.sleep(:infinity) diff --git a/lib/dexcord.ex b/lib/dexcord.ex index bb97fbc..bf07a62 100644 --- a/lib/dexcord.ex +++ b/lib/dexcord.ex @@ -20,11 +20,6 @@ defmodule Dexcord do * `:cache_presences` - cache PRESENCE_UPDATE (default `false`) * `:request_guild_members` - request full member lists on GUILD_CREATE (default `false`) - * `:slash` - a module using `Dexcord.Slash`. When set, `Dexcord.Slash.Registrar` - registers its `commands/0` at startup and `INTERACTION_CREATE`s are - auto-routed to it (the raw event still reaches the handler). - * `:slash_guild_ids` - a list of guild ids (strings or integers). When present, - slash commands are registered per guild (instant) instead of globally (~1h). * `:gateway_url` - override the gateway URL (e.g. `"ws://127.0.0.1:4000"`); when set, `GET /gateway/bot` is skipped. Primarily for testing. @@ -32,10 +27,6 @@ defmodule Dexcord do `Dexcord.Config` (`:persistent_term`) before the supervision tree starts. """ - @doc "Sends a presence update (op 3) through the gateway's send budget." - @spec update_presence(map()) :: :ok - defdelegate update_presence(presence), to: Dexcord.Gateway - @doc """ Builds the supervisor child spec, validating options first. @@ -67,102 +58,19 @@ defmodule Dexcord do end intents = Keyword.get(opts, :intents, :default) - intents_bits = resolve_intents(intents) - - cache_presences = boolean_opt(opts, :cache_presences, false) - request_guild_members = boolean_opt(opts, :request_guild_members, false) - - gateway_url = Keyword.get(opts, :gateway_url) - validate_gateway_url(gateway_url) - - slash = Keyword.get(opts, :slash) - validate_slash(slash) - - slash_guild_ids = Keyword.get(opts, :slash_guild_ids) - validate_slash_guild_ids(slash_guild_ids) + intents_bits = Dexcord.Intents.resolve(intents) %{ token: token, handler: handler, intents: intents_bits, intents_spec: intents, - cache_presences: cache_presences, - request_guild_members: request_guild_members, - slash: slash, - slash_guild_ids: slash_guild_ids, - gateway_url: gateway_url + cache_presences: Keyword.get(opts, :cache_presences, false), + request_guild_members: Keyword.get(opts, :request_guild_members, false), + gateway_url: Keyword.get(opts, :gateway_url) } end - # `Intents.resolve/1` raises a `FunctionClauseError` for a wrong-typed spec - # (a tuple, a negative integer, ...). Convert that into a friendly message; a - # list carrying an unknown intent already raises a helpful `ArgumentError`, - # which we let through. - defp resolve_intents(intents) do - Dexcord.Intents.resolve(intents) - rescue - FunctionClauseError -> - reraise ArgumentError, - [ - message: - "Dexcord :intents must be :all, :default, a list of intent atoms, or a " <> - "non-negative integer, got: #{inspect(intents)}" - ], - __STACKTRACE__ - end - - defp validate_gateway_url(nil), do: :ok - - defp validate_gateway_url(url) when is_binary(url) do - unless String.starts_with?(url, ["ws://", "wss://", "http://", "https://"]) do - raise ArgumentError, - "Dexcord :gateway_url must start with ws://, wss://, http:// or https://, " <> - "got: #{inspect(url)}" - end - - :ok - end - - defp validate_gateway_url(other) do - raise ArgumentError, - "Dexcord :gateway_url must be a URL string or nil, got: #{inspect(other)}" - end - - defp validate_slash(nil), do: :ok - defp validate_slash(mod) when is_atom(mod), do: :ok - - defp validate_slash(other) do - raise ArgumentError, "Dexcord :slash must be a module, got: #{inspect(other)}" - end - - defp validate_slash_guild_ids(nil), do: :ok - - defp validate_slash_guild_ids(ids) when is_list(ids) do - unless Enum.all?(ids, &(is_binary(&1) or is_integer(&1))) do - raise ArgumentError, - "Dexcord :slash_guild_ids must be a list of guild ids (strings or integers), " <> - "got: #{inspect(ids)}" - end - - :ok - end - - defp validate_slash_guild_ids(other) do - raise ArgumentError, - "Dexcord :slash_guild_ids must be a list of guild ids (strings or integers), " <> - "got: #{inspect(other)}" - end - - defp boolean_opt(opts, key, default) do - value = Keyword.get(opts, key, default) - - unless is_boolean(value) do - raise ArgumentError, "Dexcord :#{key} must be a boolean, got: #{inspect(value)}" - end - - value - end - defp require_opt(opts, key) do case Keyword.fetch(opts, key) do {:ok, value} -> value diff --git a/lib/dexcord/api.ex b/lib/dexcord/api.ex index 3234358..aa6c463 100644 --- a/lib/dexcord/api.ex +++ b/lib/dexcord/api.ex @@ -10,9 +10,8 @@ defmodule Dexcord.Api do up to #{3} times. All bodies are string-keyed maps; JSON encode/decode uses the built-in `JSON` - module. Returns `{:ok, map}`, `{:ok, nil}` (204 / empty 2xx body), - `{:ok, {:raw, body}}` (a non-JSON 2xx body), or - `{:error, %Dexcord.Api.Error{}}`. See `request/4` for the full contract. + module. Returns `{:ok, map}`, `{:ok, nil}` (204 / empty 2xx body), or + `{:error, %Dexcord.Api.Error{}}`. The base URL defaults to `https://discord.com/api/v10` and is overridable with `config :dexcord, :api_base_url` (used by the integration tests to point at a @@ -29,11 +28,6 @@ defmodule Dexcord.Api do # Small cushion added to every computed sleep so we wake just after a window # or retry-after boundary rather than a hair before it. @wait_padding_ms 20 - # Total wall-clock budget for a single `request/4` (all internal rate-limit - # waits + 429 retries), overridable with `config :dexcord, :api_deadline_ms`. - @default_deadline_ms 30_000 - # Cap on the raw-body snippet kept in an error message for non-JSON responses. - @max_body_snippet 200 @doc """ Performs a REST request. @@ -42,106 +36,67 @@ defmodule Dexcord.Api do * `path` - path relative to the API base, e.g. `"/channels/123/messages"` * `body` - a map or list to JSON-encode, or `nil` for no body * `opts` - `:audit_log_reason` supported (sent as `x-audit-log-reason`) - - ## Returns - - * `{:ok, map}` - a decoded JSON body - * `{:ok, nil}` - a 204 or empty 2xx body - * `{:ok, {:raw, binary}}` - a 2xx body that was **not** decodable JSON - (preserved verbatim rather than silently dropped) - * `{:error, %Dexcord.Api.Error{}}` - a 4xx/5xx, a transport failure, or a - rate-limit deadline (`status: nil`, message `"rate limit deadline exceeded"`) - when the internal waits would exceed `:api_deadline_ms`. For a non-JSON - error body a bounded snippet of the raw body is kept in `:message`. """ @spec request(atom(), String.t(), map() | list() | nil, keyword()) :: - {:ok, map()} | {:ok, nil} | {:ok, {:raw, binary()}} | {:error, Error.t()} + {:ok, map()} | {:ok, nil} | {:error, Error.t()} def request(method, path, body \\ nil, opts \\ []) do route = Ratelimit.route_key(method, path) - deadline = mono_now() + deadline_ms() - do_request(method, path, body, opts, route, 0, deadline) + do_request(method, path, body, opts, route, 0) end - defp do_request(method, path, body, opts, route, attempt, deadline) do - case Ratelimit.acquire(route, max(deadline - mono_now(), 0)) do + defp do_request(method, path, body, opts, route, attempt) do + case Ratelimit.acquire(route) do {:wait, ms} -> - if mono_now() + ms >= deadline do - deadline_error() - else - Process.sleep(ms + @wait_padding_ms) - do_request(method, path, body, opts, route, attempt, deadline) - end - - {:error, :timeout} -> - deadline_error() + Process.sleep(ms + @wait_padding_ms) + do_request(method, path, body, opts, route, attempt) :ok -> - issue(method, path, body, opts, route, attempt, deadline) + issue(method, path, body, opts, route, attempt) end end - defp issue(method, path, body, opts, route, attempt, deadline) do - # Guarantee the probe is released even if encoding raises or this process is - # killed mid-request: `probe_settled` must run before we ever recurse. - result = - try do - do_issue(method, path, body, opts, route) - after - Ratelimit.probe_settled(route) - end - - case result do - {:retry_429, headers, resp_body} -> - handle_429(method, path, body, opts, route, attempt, headers, resp_body, deadline) - - {:done, done} -> - done - end - end - - defp do_issue(method, path, body, opts, route) do + defp issue(method, path, body, opts, route, attempt) do {headers, encoded} = build(body, opts) request = Finch.build(method, base_url() <> path, headers, encoded) case Finch.request(request, @finch) do {:ok, %Finch.Response{status: 429, headers: resp_headers, body: resp_body}} -> Ratelimit.update(route, resp_headers) - {:retry_429, resp_headers, resp_body} + Ratelimit.probe_settled(route) + handle_429(method, path, body, opts, route, attempt, resp_headers, resp_body) {:ok, %Finch.Response{status: status, headers: resp_headers} = resp} when status in 200..299 -> Ratelimit.update(route, resp_headers) - {:done, success_result(status, resp.body)} + Ratelimit.probe_settled(route) + if status == 204, do: {:ok, nil}, else: {:ok, decode_body(resp.body)} {:ok, %Finch.Response{headers: resp_headers} = resp} -> Ratelimit.update(route, resp_headers) - {:done, {:error, error(resp)}} + Ratelimit.probe_settled(route) + {:error, error(resp)} {:error, reason} -> - {:done, {:error, %Error{status: nil, code: nil, message: inspect(reason), errors: nil}}} + Ratelimit.probe_settled(route) + {:error, %Error{status: nil, code: nil, message: inspect(reason), errors: nil}} end end - defp handle_429(method, path, body, opts, route, attempt, headers, resp_body, deadline) + defp handle_429(method, path, body, opts, route, attempt, headers, resp_body) when attempt < @max_retries do retry_ms = retry_after_ms(resp_body, headers) - cond do - global_429?(headers, resp_body) -> - # The global lock now gates re-acquire; don't double-sleep here. - Ratelimit.set_global_lock(retry_ms) - do_request(method, path, body, opts, route, attempt + 1, deadline) - - mono_now() + retry_ms >= deadline -> - deadline_error() - - true -> - Process.sleep(retry_ms + @wait_padding_ms) - do_request(method, path, body, opts, route, attempt + 1, deadline) + if global_429?(headers, resp_body) do + # The global lock now gates re-acquire; don't double-sleep here. + Ratelimit.set_global_lock(retry_ms) + else + Process.sleep(retry_ms + @wait_padding_ms) end + + do_request(method, path, body, opts, route, attempt + 1) end - defp handle_429(_method, _path, _body, _opts, _route, _attempt, _headers, resp_body, _deadline) do + defp handle_429(_method, _path, _body, _opts, _route, _attempt, _headers, resp_body) do decoded = decoded_map(resp_body) {:error, @@ -176,27 +131,6 @@ defmodule Dexcord.Api do defp base_url, do: Application.get_env(:dexcord, :api_base_url, @default_base_url) - defp deadline_ms, do: Application.get_env(:dexcord, :api_deadline_ms, @default_deadline_ms) - - defp mono_now, do: System.monotonic_time(:millisecond) - - defp deadline_error do - {:error, %Error{status: nil, code: nil, message: "rate limit deadline exceeded", errors: nil}} - end - - # A 2xx body: nil for empty/204, the decoded JSON, or `{:raw, body}` when it is - # present but not decodable JSON (preserved rather than dropped to `nil`). - defp success_result(204, _body), do: {:ok, nil} - - defp success_result(_status, body) when body in ["", nil], do: {:ok, nil} - - defp success_result(_status, body) do - case JSON.decode(body) do - {:ok, decoded} -> {:ok, decoded} - {:error, _} -> {:ok, {:raw, body}} - end - end - defp decode_body(""), do: nil defp decode_body(nil), do: nil @@ -215,28 +149,16 @@ defmodule Dexcord.Api do end defp error(%Finch.Response{status: status, body: body}) do - case decode_body(body) do - map when is_map(map) -> - %Error{ - status: status, - code: Map.get(map, "code"), - message: Map.get(map, "message"), - errors: Map.get(map, "errors") - } + decoded = decoded_map(body) - _ -> - # Non-JSON error body (e.g. a proxy's HTML 5xx): keep a bounded snippet - # rather than discarding it, so the failure is diagnosable. - %Error{status: status, code: nil, message: body_snippet(body), errors: nil} - end + %Error{ + status: status, + code: Map.get(decoded, "code"), + message: Map.get(decoded, "message"), + errors: Map.get(decoded, "errors") + } end - defp body_snippet(body) when is_binary(body) and body != "" do - binary_part(body, 0, min(byte_size(body), @max_body_snippet)) - end - - defp body_snippet(_body), do: nil - defp retry_after_ms(body, headers) do from_body = case decoded_map(body) do diff --git a/lib/dexcord/api/ratelimit.ex b/lib/dexcord/api/ratelimit.ex index a5a0877..9e2330f 100644 --- a/lib/dexcord/api/ratelimit.ex +++ b/lib/dexcord/api/ratelimit.ex @@ -9,20 +9,15 @@ defmodule Dexcord.Api.Ratelimit do response for that route (via the `X-RateLimit-Bucket` header). Until then a route's bucket is unknown. So the model is: - * **`acquire/2`** runs in the *caller's* process. For a known route it reads + * **`acquire/1`** runs in the *caller's* process. For a known route it reads the shared, public ETS table `:dexcord_ratelimit` and takes a token with an atomic `:ets.update_counter/3` - no GenServer round-trip, no I/O in this process ever waits on the server. It returns `:ok` or `{:wait, ms}`; the caller sleeps and retries. - * For an **unknown route**, `acquire/2` serializes through the GenServer so + * For an **unknown route**, `acquire/1` serializes through the GenServer so that N concurrent first-hits don't all fire before the bucket is learned - exactly one becomes the "probe" (`:ok`), the rest park until the probe - settles and then re-`acquire/2` against the freshly learned bucket. The - elected probe is monitored: if it dies before `probe_settled/1`, the parked - waiters are released so the route can't wedge permanently. A probe that - *stays alive* but abandons on a deadline timeout (its `:ok` election raced a - dead ref) explicitly `abandon_probe/1`s the route; a server-side - force-settle timer is the final backstop against a lost abandon. + settles and then re-`acquire/1` against the freshly learned bucket. * **`update/2`** feeds a response's headers back in (through the GenServer): it learns the `X-RateLimit-Bucket` route→bucket mapping and stores the window's `remaining` plus a **monotonic** reset deadline derived from @@ -38,13 +33,6 @@ defmodule Dexcord.Api.Ratelimit do zero-arity function. Tests inject a controllable clock so the bucket math is deterministic with no real sleeping. - ## Idle-TTL eviction - - A periodic sweep drops window state and route→bucket mappings that have been - idle past their reset deadline for longer than `:ratelimit_idle_ttl_ms` - (default 10 min), on `:ratelimit_sweep_interval_ms` (default 60 s). This keeps - token-scoped routes (webhook followups) from accumulating rows forever. - ## ETS layout (`:dexcord_ratelimit`, public) * `{:global_lock_until, mono_ms}` - global lock deadline @@ -59,9 +47,6 @@ defmodule Dexcord.Api.Ratelimit do @table :dexcord_ratelimit @server __MODULE__ - # Short provisional window used at reset time, before the real headers land. - @provisional_reset_ms 1_000 - # --- Public API --------------------------------------------------------- @doc false @@ -77,18 +62,10 @@ defmodule Dexcord.Api.Ratelimit do Pure function (exported for tests). The key is `"METHOD /template"` where the template keeps the **major** parameters literal - `channel_id` and `guild_id` - (the segment after `channels`/`guilds`) and `webhook_id` (the id segment after - `webhooks`) - collapses every other snowflake and the reaction emoji to `:id`, - and gives `DELETE …/messages/:id` its own key family (Discord rate-limits - message deletes separately). - - Secret tokens are **never** stored literally in a key (they would leak into the - public ETS table): the `webhook_token` segment is replaced with a short, - non-reversible SHA-256 digest so bucketing stays token-scoped without keeping - the secret. The `interaction_token` in `/interactions/:id//callback` gets - the same treatment - a per-interaction digest, never the raw token - so one - interaction's `Remaining: 0`/429 can't stall every other interaction's ack - toward Discord's 3 s callback deadline. + (the segment after `channels`/`guilds`) and `webhook_id` + `webhook_token` + (the two segments after `webhooks`) - collapses every other snowflake and the + reaction emoji to `:id`, and gives `DELETE …/messages/:id` its own key family + (Discord rate-limits message deletes separately). iex> Dexcord.Api.Ratelimit.route_key(:post, "/channels/123/messages") "POST /channels/123/messages" @@ -115,21 +92,13 @@ defmodule Dexcord.Api.Ratelimit do end end - # webhook_id is major (literal); the webhook_token is a secret - keep bucketing - # token-scoped via a short non-reversible digest instead of storing it raw. + # webhook_id + webhook_token are both major - keep both literal. defp templatize(["webhooks", id, token | rest]), - do: ["webhooks", id, token_digest(token) | templatize(rest)] + do: ["webhooks", id, token | templatize(rest)] defp templatize(["webhooks", id | rest]), do: ["webhooks", id | templatize(rest)] - # interaction_token is a secret, but each interaction needs its OWN bucket: a - # 429 on one callback must not stall every other interaction's ack. Keep - # per-interaction separation via the same non-reversible digest used for - # webhooks (never the raw token); the minor interaction id still collapses. - defp templatize(["interactions", id, token | rest]), - do: ["interactions", templatize_one(id), token_digest(token) | templatize(rest)] - # channel_id / guild_id are major - keep the id literal. defp templatize([major, id | rest]) when major in ["channels", "guilds"], do: [major, id | templatize(rest)] @@ -147,27 +116,14 @@ defmodule Dexcord.Api.Ratelimit do defp snowflake?(seg), do: seg != "" and String.match?(seg, ~r/^\d+$/) - # Short, non-reversible token digest: 12 url-safe base64 chars of SHA-256. - defp token_digest(token) do - :crypto.hash(:sha256, token) - |> Base.url_encode64(padding: false) - |> binary_part(0, 12) - end - @doc """ Attempts to take a token for `route_key`, in the calling process. Returns `:ok` to proceed, or `{:wait, ms}` telling the caller to sleep that long and try again. A global lock takes precedence over any per-bucket state. - - `probe_timeout` bounds the GenServer round-trip taken only for an *unknown* - route (the probe-park path); on timeout it returns `{:error, :timeout}` rather - than crashing the caller with an EXIT, so a total-deadline can be enforced. - Defaults to `:infinity` (known routes never make the call). """ - @spec acquire(String.t(), timeout()) :: - :ok | {:wait, non_neg_integer()} | {:error, :timeout} - def acquire(route_key, probe_timeout \\ :infinity) do + @spec acquire(String.t()) :: :ok | {:wait, non_neg_integer()} + def acquire(route_key) do now = now() case global_lock_wait(now) do @@ -176,7 +132,7 @@ defmodule Dexcord.Api.Ratelimit do :ok -> case lookup_bucket(route_key) do - nil -> acquire_unknown(route_key, probe_timeout) + nil -> acquire_unknown(route_key) :none -> :ok bucket -> take_token(bucket, now) end @@ -204,31 +160,13 @@ defmodule Dexcord.Api.Ratelimit do @doc """ Signals that a route's probe request has completed (success or failure), - releasing any callers parked behind it so they re-`acquire/2`. + releasing any callers parked behind it so they re-`acquire/1`. """ @spec probe_settled(String.t()) :: :ok def probe_settled(route_key) do GenServer.cast(@server, {:probe_settled, route_key}) end - @doc false - # Explicitly relinquishes an *unsettled* probe that the caller was elected for - # but then abandoned (its `acquire/2` GenServer.call timed out on a nearly-spent - # deadline while the caller stays alive - so no `:DOWN` fires and - # `probe_settled/1` never runs). The server only settles the entry if its - # monitored pid still matches `self()`, so a later, unrelated probe on the same - # route can never be clobbered by a stale abandon. - @spec abandon_probe(String.t()) :: :ok - def abandon_probe(route_key) do - GenServer.cast(@server, {:abandon_probe, route_key, self()}) - end - - @doc false - # Runs the idle-TTL eviction sweep synchronously (exported for tests). The - # periodic timer runs the same pass on `:ratelimit_sweep_interval_ms`. - @spec sweep() :: :ok - def sweep, do: GenServer.call(@server, :sweep) - @doc "The monotonic clock in ms, honoring an injected `:ratelimit_now_fn`." @spec now() :: integer() def now do @@ -240,20 +178,11 @@ defmodule Dexcord.Api.Ratelimit do # --- caller-process internals ------------------------------------------ - defp acquire_unknown(route_key, probe_timeout) do - case GenServer.call(@server, {:acquire_probe, route_key, probe_timeout}, probe_timeout) do + defp acquire_unknown(route_key) do + case GenServer.call(@server, {:acquire_probe, route_key}) do :ok -> :ok - :retry -> acquire(route_key, probe_timeout) + :retry -> acquire(route_key) end - catch - # Parked (or elected) longer than the caller's deadline allows: surface an - # error tuple instead of the raw GenServer.call timeout EXIT. We may have been - # elected the probe server-side just as we gave up (the `:ok` reply raced our - # timeout to a now-dead ref) - since we stay alive, no `:DOWN` will ever fire, - # so explicitly abandon the probe to avoid wedging the route permanently. - :exit, {:timeout, _} -> - abandon_probe(route_key) - {:error, :timeout} end defp lookup_bucket(route_key) do @@ -268,41 +197,20 @@ defmodule Dexcord.Api.Ratelimit do [] -> :ok - [{_, _remaining, reset_at, limit}] -> + [{_, _remaining, reset_at, _limit}] -> cond do is_integer(reset_at) and now >= reset_at -> - # Window elapsed: reset the counter to `limit - 1` under a fresh - # provisional deadline so only ~limit concurrent callers pass before - # the next response's headers land - instead of admitting everyone - # (the window-reset thundering herd). - reset_window(bucket, reset_at, limit, now) + # Window elapsed: allow optimistically; the next response's headers + # refresh `remaining`/`reset_at` for the new window. + :ok true -> - # The shared counter is a distributed, deliberately over-permissive - # gate: concurrent callers can momentarily race past `remaining`. - # Clamp the floor at -1 so a burst of parked callers can't drive it - # arbitrarily negative (any value < 0 already means "wait"). - new = :ets.update_counter(@table, {:bucket, bucket}, {2, -1, -1, -1}) + new = :ets.update_counter(@table, {:bucket, bucket}, {2, -1}) if new >= 0, do: :ok, else: {:wait, wait_until(reset_at, now)} end end end - # CAS the expired row to a fresh window; retry if another caller beat us to it. - defp reset_window(bucket, old_reset_at, limit, now) when is_integer(limit) and limit > 0 do - key = {:bucket, bucket} - match = {key, :"$1", old_reset_at, limit} - replacement = {key, limit - 1, now + @provisional_reset_ms, limit} - - case :ets.select_replace(@table, [{match, [], [{:const, replacement}]}]) do - 1 -> :ok - 0 -> take_token(bucket, now) - end - end - - # No usable limit header yet: fall back to admitting optimistically. - defp reset_window(_bucket, _old_reset_at, _limit, _now), do: :ok - defp wait_until(reset_at, now) when is_integer(reset_at), do: max(reset_at - now, 0) + 1 defp wait_until(_reset_at, _now), do: 1 @@ -325,18 +233,11 @@ defmodule Dexcord.Api.Ratelimit do write_concurrency: true ]) - schedule_sweep() - # `probing`: route_key => {probe_monitor_ref, [parked_waiters]} - # `mons`: probe_monitor_ref => route_key (reverse lookup for :DOWN) - {:ok, %{probing: %{}, mons: %{}}} + {:ok, %{probing: %{}}} end @impl true - def handle_call( - {:acquire_probe, route_key, probe_timeout}, - from, - %{probing: probing, mons: mons} = state - ) do + def handle_call({:acquire_probe, route_key}, from, %{probing: probing} = state) do cond do :ets.member(@table, {:route_bucket, route_key}) -> # Learned since the caller's ETS read - just retry the fast path. @@ -344,43 +245,14 @@ defmodule Dexcord.Api.Ratelimit do Map.has_key?(probing, route_key) -> # A probe is already in flight; park until it settles. - {ref, pid, timer, waiters} = Map.fetch!(probing, route_key) - - {:noreply, - %{state | probing: Map.put(probing, route_key, {ref, pid, timer, [from | waiters]})}} + waiters = Map.fetch!(probing, route_key) + {:noreply, %{state | probing: Map.put(probing, route_key, [from | waiters])}} true -> - # Elect this caller the probe and monitor it: if it dies before it can - # call `probe_settled/1` (killed Task, a raise before the request, ...), - # the :DOWN handler releases parked waiters instead of wedging the route - # forever. Belt-and-braces for the caller that stays ALIVE but never - # settles (deadline-timeout abandon that also loses its abandon cast): a - # generous force-settle timer relinquishes a probe still holding this same - # ref, so the route can never wedge permanently. - {pid, _tag} = from - ref = Process.monitor(pid) - - timer = - Process.send_after( - self(), - {:force_settle, route_key, ref}, - force_settle_ms(probe_timeout) - ) - - {:reply, :ok, - %{ - state - | probing: Map.put(probing, route_key, {ref, pid, timer, []}), - mons: Map.put(mons, ref, route_key) - }} + {:reply, :ok, %{state | probing: Map.put(probing, route_key, [])}} end end - def handle_call(:sweep, _from, state) do - do_sweep(idle_ttl()) - {:reply, :ok, state} - end - def handle_call({:update, route_key, headers}, _from, state) do h = downcase_headers(headers) @@ -411,105 +283,17 @@ defmodule Dexcord.Api.Ratelimit do end @impl true - def handle_cast({:probe_settled, route_key}, state) do - {:noreply, release_probe(state, route_key)} - end - - # A caller that was elected the probe but abandoned it on a deadline timeout. - # Only settle if the entry's monitored pid still matches the abandoning pid - - # otherwise this is a stale abandon and a *different*, later probe now holds the - # route, which must not be clobbered. - def handle_cast({:abandon_probe, route_key, pid}, %{probing: probing} = state) do - case Map.get(probing, route_key) do - {_ref, ^pid, _timer, _waiters} -> {:noreply, release_probe(state, route_key)} - _ -> {:noreply, state} - end - end - - @impl true - def handle_info({:DOWN, ref, :process, _pid, _reason}, %{mons: mons} = state) do - case Map.get(mons, ref) do - nil -> {:noreply, state} - route_key -> {:noreply, release_probe(state, route_key)} - end - end - - # Belt-and-braces force-settle: only fires if the route is still being probed by - # the exact same election (same ref). A settled/re-elected probe has a different - # ref (or no entry), so this can never clobber a healthy in-flight probe. - def handle_info({:force_settle, route_key, ref}, %{probing: probing} = state) do - case Map.get(probing, route_key) do - {^ref, _pid, _timer, _waiters} -> {:noreply, release_probe(state, route_key)} - _ -> {:noreply, state} - end - end - - def handle_info(:sweep, state) do - do_sweep(idle_ttl()) - schedule_sweep() - {:noreply, state} - end - - # Drops the probe for `route_key` and releases its parked waiters to re-acquire - # (they either take the freshly learned bucket or re-elect a new probe). - defp release_probe(%{probing: probing, mons: mons} = state, route_key) do + def handle_cast({:probe_settled, route_key}, %{probing: probing} = state) do case Map.pop(probing, route_key) do {nil, _} -> - state + {:noreply, state} - {{ref, _pid, timer, waiters}, rest} -> - if is_reference(ref), do: Process.demonitor(ref, [:flush]) - if is_reference(timer), do: Process.cancel_timer(timer) + {waiters, rest} -> Enum.each(waiters, &GenServer.reply(&1, :retry)) - %{state | probing: rest, mons: Map.delete(mons, ref)} + {:noreply, %{state | probing: rest}} end end - # Generous force-settle deadline for an elected probe: twice the caller's own - # probe timeout, floored at 30 s (a probe that outlives this is almost certainly - # wedged). Overridable with `:ratelimit_probe_force_settle_ms` for deterministic - # tests; `:infinity` probe timeouts fall back to the 30 s floor. - defp force_settle_ms(probe_timeout) do - case Application.get_env(:dexcord, :ratelimit_probe_force_settle_ms) do - ms when is_integer(ms) and ms > 0 -> ms - _ -> default_force_settle_ms(probe_timeout) - end - end - - defp default_force_settle_ms(ms) when is_integer(ms), do: max(ms * 2, 30_000) - defp default_force_settle_ms(_), do: 30_000 - - # --- idle-TTL eviction -------------------------------------------------- - - # Evicts window state that has been idle past its reset deadline for longer - # than the configured TTL, plus route→bucket mappings whose bucket row is gone. - # Without this every distinct token-scoped route (e.g. webhook followups) would - # accumulate a never-reclaimed row on a long-running bot. - defp do_sweep(ttl) do - cutoff = now() - ttl - - # Expired bucket rows: {{:bucket, hash}, remaining, reset_at, limit}. - :ets.select_delete(@table, [ - {{{:bucket, :"$1"}, :_, :"$2", :_}, [{:is_integer, :"$2"}, {:<, :"$2", cutoff}], [true]} - ]) - - # Route mappings whose target bucket row no longer exists (`:none` mappings - # are a small static set - leave them). - @table - |> :ets.match_object({{:route_bucket, :_}, :_}) - |> Enum.each(fn - {_key, :none} -> :ok - {key, hash} -> unless :ets.member(@table, {:bucket, hash}), do: :ets.delete(@table, key) - end) - - :ok - end - - defp schedule_sweep, do: Process.send_after(self(), :sweep, sweep_interval()) - - defp idle_ttl, do: Application.get_env(:dexcord, :ratelimit_idle_ttl_ms, 600_000) - defp sweep_interval, do: Application.get_env(:dexcord, :ratelimit_sweep_interval_ms, 60_000) - # --- header parsing ----------------------------------------------------- defp downcase_headers(headers) do diff --git a/lib/dexcord/cache.ex b/lib/dexcord/cache.ex deleted file mode 100644 index f534101..0000000 --- a/lib/dexcord/cache.ex +++ /dev/null @@ -1,442 +0,0 @@ -defmodule Dexcord.Cache do - @moduledoc """ - A per-entity ETS cache of the gateway event stream. - - Under `intents: :all` the gateway delivers a firehose describing the current - state of every guild the bot is in. `Dexcord.Cache` folds that stream into a set - of ETS tables so a handler (or any other process) can look up guilds, channels, - members, roles, users, presences and voice states without a REST round-trip. - - ## Single writer, lock-free reads - - This GenServer only *creates* the tables (in `init/1`); it never writes to them - on the hot path. All writes happen through `handle_dispatch/3`, which the - `Dexcord.Dispatcher` calls **inline, in its own process, in gateway order** - - making the Dispatcher the sole writer. Because there is exactly one writer, no - locks are needed. Every table is `:public`, so reads (`guild/1`, `member/2`, ...) - go straight to ETS from any process with no GenServer round-trip. - - If this process ever crashes the tables are lost and recreated empty on restart; - the documented cost is a cold cache until the next relevant events arrive. REST - is always the source of truth; the cache is best-effort, and can briefly lag or - hold a duplicate after a resume gap. - - ## Tables - - | Table | Type | Key | - |------------------------|---------------|---------------------| - | `:dexcord_me` | `set` | `:me` | - | `:dexcord_guilds` | `set` | `guild_id` | - | `:dexcord_channels` | `set` | `channel_id` | - | `:dexcord_users` | `set` | `user_id` | - | `:dexcord_members` | `ordered_set` | `{guild_id, user_id}` | - | `:dexcord_roles` | `ordered_set` | `{guild_id, role_id}` | - | `:dexcord_presences` | `ordered_set` | `{guild_id, user_id}` | - | `:dexcord_voice_states`| `ordered_set` | `{guild_id, user_id}` | - - ### Key and value conventions - - Snowflake ids are stored in table keys as the **raw strings Discord sends** - no - integer parsing. This keeps lookups cheap and avoids precision pitfalls, at the - cost that a caller must pass string ids (which is what every payload already - carries). Stored values are the original string-keyed payload maps (no structs, - no atom keys), with the large child arrays of a guild peeled off into their own - tables. The `ordered_set` tables use `{guild_id, x}` keys so a whole guild can be - listed with a key-prefix select and purged with a single `match_delete/2`. - """ - - use GenServer - - @me :dexcord_me - @guilds :dexcord_guilds - @channels :dexcord_channels - @users :dexcord_users - @members :dexcord_members - @roles :dexcord_roles - @presences :dexcord_presences - @voice_states :dexcord_voice_states - - # Guild child collections lifted out of the guild row into their own tables. - # `emojis` (and stickers) stay inline on the guild and are replaced wholesale. - @guild_child_arrays ~w(channels threads roles members presences voice_states) - - # --- lifecycle ---------------------------------------------------------- - - @doc false - def start_link(opts) do - GenServer.start_link(__MODULE__, opts, name: __MODULE__) - end - - @impl true - def init(_opts) do - set = [:set, :public, :named_table, read_concurrency: true] - oset = [:ordered_set, :public, :named_table, read_concurrency: true] - - :ets.new(@me, set) - :ets.new(@guilds, set) - :ets.new(@channels, set) - :ets.new(@users, set) - :ets.new(@members, oset) - :ets.new(@roles, oset) - :ets.new(@presences, oset) - :ets.new(@voice_states, oset) - - {:ok, %{}} - end - - # --- write path (called inline by the Dispatcher, single writer) --------- - - @doc """ - Folds one dispatch event into the cache. Called inline by `Dexcord.Dispatcher` - *before* the user handler runs, so the handler always observes a cache that - already reflects the event. - - `config` is the resolved `Dexcord.Config` map; only `:cache_presences` is - consulted (presence writes are skipped entirely when it is falsey). Unknown or - uninteresting events - and any event whose data is not a map - are a no-op. - Returns `:ok`. - """ - @spec handle_dispatch(atom() | {:unknown_event, String.t()}, term(), map()) :: :ok - def handle_dispatch(name, data, config) - - def handle_dispatch(_name, data, _config) when not is_map(data), do: :ok - - def handle_dispatch(:READY, data, _config) do - if user = data["user"], do: put_me(user) - - for g <- data["guilds"] || [], - is_map(g) and is_binary(g["id"]), - do: :ets.insert(@guilds, {g["id"], g}) - - :ok - end - - def handle_dispatch(:GUILD_CREATE, guild, config) do - gid = guild["id"] - :ets.insert(@guilds, {gid, Map.drop(guild, @guild_child_arrays)}) - - for ch <- guild["channels"] || [], do: put_channel(Map.put(ch, "guild_id", gid)) - for th <- guild["threads"] || [], do: put_channel(Map.put(th, "guild_id", gid)) - - for role <- guild["roles"] || [], - is_binary(role["id"]), - do: :ets.insert(@roles, {{gid, role["id"]}, role}) - - for vs <- guild["voice_states"] || [], do: put_voice_state(gid, vs) - for m <- guild["members"] || [], do: put_member(gid, m) - - if cache_presences?(config) do - for p <- guild["presences"] || [], do: put_presence(gid, p) - end - - :ok - end - - def handle_dispatch(:GUILD_UPDATE, guild, _config) do - gid = guild["id"] - merged = Map.merge(existing(@guilds, gid), Map.drop(guild, @guild_child_arrays)) - :ets.insert(@guilds, {gid, merged}) - :ok - end - - def handle_dispatch(:GUILD_DELETE, data, _config) do - gid = data["id"] - - if data["unavailable"] == true do - # Guild went unavailable (outage), not removed: keep a stub placeholder. - :ets.insert(@guilds, {gid, data}) - else - :ets.delete(@guilds, gid) - :ets.match_delete(@members, {{gid, :_}, :_}) - :ets.match_delete(@roles, {{gid, :_}, :_}) - :ets.match_delete(@presences, {{gid, :_}, :_}) - :ets.match_delete(@voice_states, {{gid, :_}, :_}) - :ets.select_delete(@channels, [{{:_, %{"guild_id" => gid}}, [], [true]}]) - end - - :ok - end - - def handle_dispatch(name, ch, _config) - when name in [:CHANNEL_CREATE, :CHANNEL_UPDATE, :THREAD_CREATE, :THREAD_UPDATE] do - put_channel(ch) - :ok - end - - def handle_dispatch(name, ch, _config) when name in [:CHANNEL_DELETE, :THREAD_DELETE] do - if id = ch["id"], do: :ets.delete(@channels, id) - :ok - end - - def handle_dispatch(name, member, _config) - when name in [:GUILD_MEMBER_ADD, :GUILD_MEMBER_UPDATE] do - put_member(member["guild_id"], member) - :ok - end - - def handle_dispatch(:GUILD_MEMBER_REMOVE, data, _config) do - with gid when is_binary(gid) <- data["guild_id"], - %{"id" => uid} <- data["user"] do - :ets.delete(@members, {gid, uid}) - end - - :ok - end - - def handle_dispatch(:GUILD_MEMBERS_CHUNK, data, config) do - gid = data["guild_id"] - for m <- data["members"] || [], do: put_member(gid, m) - - presences = data["presences"] - - if is_list(presences) and cache_presences?(config) do - for p <- presences, do: put_presence(gid, p) - end - - :ok - end - - def handle_dispatch(name, data, _config) - when name in [:GUILD_ROLE_CREATE, :GUILD_ROLE_UPDATE] do - gid = data["guild_id"] - - case data["role"] do - %{"id" => rid} = role -> :ets.insert(@roles, {{gid, rid}, role}) - _ -> :ok - end - - :ok - end - - def handle_dispatch(:GUILD_ROLE_DELETE, data, _config) do - if is_binary(data["guild_id"]) and is_binary(data["role_id"]), - do: :ets.delete(@roles, {data["guild_id"], data["role_id"]}) - - :ok - end - - def handle_dispatch(:GUILD_EMOJIS_UPDATE, data, _config) do - gid = data["guild_id"] - - case :ets.lookup(@guilds, gid) do - [{_, g}] -> :ets.insert(@guilds, {gid, Map.put(g, "emojis", data["emojis"] || [])}) - [] -> :ok - end - - :ok - end - - def handle_dispatch(:PRESENCE_UPDATE, data, config) do - # The chattiest event under `:all`: when presences aren't cached we skip the - # write entirely rather than build-then-discard a term. - if cache_presences?(config), do: put_presence(data["guild_id"], data) - :ok - end - - def handle_dispatch(:VOICE_STATE_UPDATE, data, _config) do - gid = data["guild_id"] - uid = data["user_id"] - - cond do - is_nil(gid) or is_nil(uid) -> :ok - is_nil(data["channel_id"]) -> :ets.delete(@voice_states, {gid, uid}) - true -> :ets.insert(@voice_states, {{gid, uid}, data}) - end - - :ok - end - - def handle_dispatch(:USER_UPDATE, data, _config) do - put_me(data) - :ok - end - - def handle_dispatch(:MESSAGE_CREATE, data, _config) do - author = data["author"] - - # Opportunistic freshness: cache the (real, non-webhook) author and, when the - # message carries a guild member fragment, fold it into the members table. - if is_map(author) and is_binary(author["id"]) and is_nil(author["webhook_id"]) and - is_nil(data["webhook_id"]) do - upsert_user(author) - - with gid when is_binary(gid) <- data["guild_id"], - member when is_map(member) <- data["member"] do - uid = author["id"] - merged = existing(@members, {gid, uid}) |> Map.merge(member) |> Map.put("user_id", uid) - :ets.insert(@members, {{gid, uid}, merged}) - end - end - - :ok - end - - # Everything else (unknown events, events with no cache mapping) is a no-op. - def handle_dispatch(_name, _data, _config), do: :ok - - # --- write helpers ------------------------------------------------------ - - defp put_me(user) when is_map(user), - do: :ets.insert(@me, {:me, Map.merge(existing(@me, :me), user)}) - - defp put_me(_), do: :ok - - defp put_channel(%{"id" => id} = ch) when is_binary(id), do: :ets.insert(@channels, {id, ch}) - defp put_channel(_), do: :ok - - # A member's `user` object is moved into the users table; the member row keeps - # only a `"user_id"` back-reference. Merges over any existing row so partial - # updates (GUILD_MEMBER_UPDATE, MESSAGE_CREATE fragments) don't drop fields. - defp put_member(gid, %{"user" => %{"id" => uid} = user} = member) when is_binary(gid) do - upsert_user(user) - - merged = - existing(@members, {gid, uid}) - |> Map.merge(Map.delete(member, "user")) - |> Map.put("user_id", uid) - - :ets.insert(@members, {{gid, uid}, merged}) - end - - defp put_member(_gid, _member), do: :ok - - defp put_voice_state(gid, %{"user_id" => uid} = vs) when is_binary(uid), - do: :ets.insert(@voice_states, {{gid, uid}, vs}) - - defp put_voice_state(_gid, _vs), do: :ok - - defp put_presence(gid, %{"user" => %{"id" => uid}} = presence) when is_binary(gid), - do: :ets.insert(@presences, {{gid, uid}, presence}) - - defp put_presence(_gid, _presence), do: :ok - - defp upsert_user(%{"id" => uid} = user), - do: :ets.insert(@users, {uid, Map.merge(existing(@users, uid), user)}) - - defp upsert_user(_), do: :ok - - defp existing(table, key) do - case :ets.lookup(table, key) do - [{_, v}] -> v - [] -> %{} - end - end - - # --- read API ----------------------------------------------------------- - - @typedoc "A cached entity, as its original string-keyed payload map." - @type entity :: map() - - @doc "The bot's own user object (from READY / USER_UPDATE)." - @spec me() :: {:ok, entity()} | :error - def me, do: fetch(@me, :me) - - @doc "Bang variant of `me/0`; raises if unset." - @spec me!() :: entity() - def me!, do: unwrap(me(), :me) - - @doc "A guild by id." - @spec guild(String.t()) :: {:ok, entity()} | :error - def guild(id), do: fetch(@guilds, id) - - @doc "Bang variant of `guild/1`." - @spec guild!(String.t()) :: entity() - def guild!(id), do: unwrap(guild(id), id) - - @doc "All cached guilds (including unavailable stubs)." - @spec guilds() :: [entity()] - def guilds, do: all_values(@guilds) - - @doc "A channel (or thread, or DM) by id." - @spec channel(String.t()) :: {:ok, entity()} | :error - def channel(id), do: fetch(@channels, id) - - @doc "Bang variant of `channel/1`." - @spec channel!(String.t()) :: entity() - def channel!(id), do: unwrap(channel(id), id) - - @doc "All cached channels/threads belonging to a guild." - @spec channels(String.t()) :: [entity()] - def channels(guild_id) do - @channels - |> :ets.select([{{:_, %{"guild_id" => guild_id}}, [], [:"$_"]}]) - |> Enum.map(&elem(&1, 1)) - end - - @doc "A guild member by guild id + user id." - @spec member(String.t(), String.t()) :: {:ok, entity()} | :error - def member(guild_id, user_id), do: fetch(@members, {guild_id, user_id}) - - @doc "Bang variant of `member/2`." - @spec member!(String.t(), String.t()) :: entity() - def member!(guild_id, user_id), do: unwrap(member(guild_id, user_id), {guild_id, user_id}) - - @doc "All cached members of a guild." - @spec members(String.t()) :: [entity()] - def members(guild_id), do: prefix_values(@members, guild_id) - - @doc "A user by id." - @spec user(String.t()) :: {:ok, entity()} | :error - def user(id), do: fetch(@users, id) - - @doc "Bang variant of `user/1`." - @spec user!(String.t()) :: entity() - def user!(id), do: unwrap(user(id), id) - - @doc "A role by guild id + role id." - @spec role(String.t(), String.t()) :: {:ok, entity()} | :error - def role(guild_id, role_id), do: fetch(@roles, {guild_id, role_id}) - - @doc "Bang variant of `role/2`." - @spec role!(String.t(), String.t()) :: entity() - def role!(guild_id, role_id), do: unwrap(role(guild_id, role_id), {guild_id, role_id}) - - @doc "All cached roles of a guild." - @spec roles(String.t()) :: [entity()] - def roles(guild_id), do: prefix_values(@roles, guild_id) - - @doc "A presence by guild id + user id (only populated when `cache_presences: true`)." - @spec presence(String.t(), String.t()) :: {:ok, entity()} | :error - def presence(guild_id, user_id), do: fetch(@presences, {guild_id, user_id}) - - @doc "Bang variant of `presence/2`." - @spec presence!(String.t(), String.t()) :: entity() - def presence!(guild_id, user_id), do: unwrap(presence(guild_id, user_id), {guild_id, user_id}) - - @doc "All cached presences of a guild." - @spec presences(String.t()) :: [entity()] - def presences(guild_id), do: prefix_values(@presences, guild_id) - - @doc "A voice state by guild id + user id." - @spec voice_state(String.t(), String.t()) :: {:ok, entity()} | :error - def voice_state(guild_id, user_id), do: fetch(@voice_states, {guild_id, user_id}) - - @doc "Bang variant of `voice_state/2`." - @spec voice_state!(String.t(), String.t()) :: entity() - def voice_state!(guild_id, user_id), - do: unwrap(voice_state(guild_id, user_id), {guild_id, user_id}) - - @doc "All cached voice states of a guild." - @spec voice_states(String.t()) :: [entity()] - def voice_states(guild_id), do: prefix_values(@voice_states, guild_id) - - # --- read helpers ------------------------------------------------------- - - defp cache_presences?(config), do: Map.get(config, :cache_presences, false) - - defp fetch(table, key) do - case :ets.lookup(table, key) do - [{_, v}] -> {:ok, v} - [] -> :error - end - end - - defp all_values(table), do: :ets.select(table, [{{:_, :"$1"}, [], [:"$1"]}]) - - # Per-guild listing of an ordered_set keyed by `{guild_id, x}`. - defp prefix_values(table, guild_id), - do: :ets.select(table, [{{{guild_id, :_}, :"$1"}, [], [:"$1"]}]) - - defp unwrap({:ok, value}, _key), do: value - defp unwrap(:error, key), do: raise("Dexcord.Cache: no cached entry for #{inspect(key)}") -end diff --git a/lib/dexcord/config.ex b/lib/dexcord/config.ex index ca6095f..cff1639 100644 --- a/lib/dexcord/config.ex +++ b/lib/dexcord/config.ex @@ -10,7 +10,6 @@ defmodule Dexcord.Config do """ @pt_key __MODULE__ - @app_id_key {__MODULE__, :application_id} @doc "Stores the validated config map." @spec put(map()) :: :ok @@ -37,17 +36,4 @@ defmodule Dexcord.Config do @doc "Returns the configured event handler module." @spec handler :: module() def handler, do: get(:handler) - - @doc """ - Caches the bot's application id (learned once by `Dexcord.Slash.Registrar`). - - Stored under a dedicated `:persistent_term` key, separate from the config map, - so it can be written after boot without republishing the whole config. - """ - @spec put_application_id(String.t()) :: :ok - def put_application_id(id) when is_binary(id), do: :persistent_term.put(@app_id_key, id) - - @doc "Returns the cached application id, or `nil` if not yet resolved." - @spec application_id() :: String.t() | nil - def application_id, do: :persistent_term.get(@app_id_key, nil) end diff --git a/lib/dexcord/dispatcher.ex b/lib/dexcord/dispatcher.ex index 2f6cbad..8708357 100644 --- a/lib/dexcord/dispatcher.ex +++ b/lib/dexcord/dispatcher.ex @@ -33,60 +33,16 @@ defmodule Dexcord.Dispatcher do @impl true def handle_cast({:dispatch, name, data}, state) do - config = Dexcord.Config.get() + # TODO: CACHE - once Dexcord.Cache exists, call Cache.handle_dispatch(name, data) + # INLINE HERE (before spawning the handler Task) so the cache reflects the + # event before the user's handler observes it. Ordered, single-writer. - # Cache write happens INLINE, in this (single-writer) process, BEFORE the - # handler Task is spawned - so the handler always observes a cache that - # already reflects this event, and cache writes stay in exact gateway order. - Dexcord.Cache.handle_dispatch(name, data, config) - - maybe_request_members(name, data, config) - maybe_route_slash(name, data, config) + handler = Dexcord.Config.handler() Task.Supervisor.start_child(@task_supervisor, fn -> - config.handler.handle_event({name, data}) + handler.handle_event({name, data}) end) {:noreply, state} end - - # When `request_guild_members: true` and the :guild_members intent is set, ask - # the gateway (op 8) for the full member list of each real guild as it arrives. - # The GUILD_MEMBERS_CHUNK replies flow back through normal dispatch into the - # cache. Unavailable-stub GUILD_CREATEs are skipped (they carry no members). - defp maybe_request_members(:GUILD_CREATE, data, config) do - if config.request_guild_members and data["unavailable"] != true and - is_binary(data["id"]) and - Dexcord.Intents.enabled?(config.intents, :guild_members) do - Dexcord.Gateway.request_guild_members(data["id"], - query: "", - limit: 0, - presences: config.cache_presences - ) - end - - :ok - end - - defp maybe_request_members(_name, _data, _config), do: :ok - - # When a `slash:` module is configured, auto-route INTERACTION_CREATEs to it in - # a separate Task, IN ADDITION to the raw handler (which always receives the - # event). Only the interaction top-level `"type"`s handled by the slash layer are - # routed: 2 (application command), 3 (message component), 5 (modal submit). - # Type 1 (PING) never arrives over the gateway and type 4 (autocomplete) is left - # to the raw handler. - defp maybe_route_slash(:INTERACTION_CREATE, interaction, config) when is_map(interaction) do - slash_mod = Map.get(config, :slash) - - if slash_mod && interaction["type"] in [2, 3, 5] do - Task.Supervisor.start_child(@task_supervisor, fn -> - Dexcord.Slash.dispatch(interaction, slash_mod) - end) - end - - :ok - end - - defp maybe_route_slash(_name, _data, _config), do: :ok end diff --git a/lib/dexcord/gateway.ex b/lib/dexcord/gateway.ex index c7d2c4c..d01298c 100644 --- a/lib/dexcord/gateway.ex +++ b/lib/dexcord/gateway.ex @@ -14,27 +14,21 @@ defmodule Dexcord.Gateway do RESUME* (`session_id`, `last_seq`, `resume_gateway_url`, backoff, fatal flag) lives in `Dexcord.Session`'s ETS, not here. - ## Reliability behaviour + ## Phase 0-1 scope - The full connect -> HELLO -> IDENTIFY/RESUME -> heartbeat -> dispatch path is - implemented and hardened: replayed dispatches during `:resuming` are forwarded - in order and re-arm the resume watchdog on every frame (so a large replay - backlog can't trip it, while true silence still does), op 9 is handled for - both `d: true` (resumable, RESUME again) and `d: false` (clear session, - jittered 1-5s wait, fresh IDENTIFY), a resume-loop cap abandons resuming for a - fresh IDENTIFY after too many consecutive failures, heartbeat ACK tracking - detects a zombie connection (HEARTBEAT sent, no ACK before the next beat) and - closes with 4000 to force a resume, and a `conn_gen` guard drops stale - messages from a socket that has already been superseded by a reconnect. See - `close_action/1` in `Dexcord.Gateway.Payload` for the fatal vs. reidentify vs. - resume close-code table. + This implements the happy path fully (connect -> HELLO -> IDENTIFY -> READY -> + heartbeat with ACK/zombie tracking -> dispatch) plus basic + disconnect -> backoff -> reconnect, and the real Mint/heartbeat/Session + plumbing. The resume protocol structure is present; its edge cases (replay + ordering guarantees, op 9 timing, resume-loop cap, stale-socket guard) are + hardened in Phase 2. ## `gateway_url:` override If config carries `:gateway_url` (e.g. `"ws://127.0.0.1:4000"`), that URL is used directly and the `GET /gateway/bot` call is skipped. Plain `ws://` maps to an - `:http` Mint scheme (TCP, no TLS); `wss://` to `:https`. This is what the - `Dexcord.FakeGateway` test suite connects against. + `:http` Mint scheme (TCP, no TLS); `wss://` to `:https`. This is what the Phase 2 + FakeGateway test suite connects against. """ @behaviour :gen_statem @@ -113,49 +107,16 @@ defmodule Dexcord.Gateway do ref: nil, status: nil, resp_headers: [], - # Bytes that ride in coalesced with the upgrade's 101 response (before the - # Mint websocket exists). Buffered here and decoded the instant the - # websocket is built - see process_responses/3. Reset per connection. - pending_data: <<>>, hb_interval: nil, hb_acked?: true, # Set on op 9 `d:false` in :resuming; while true the :resuming op 0 handler # must not re-arm the resume watchdog (it would clobber the pending # :reidentify/:identify timer). Cleared on a fresh connection and once we # actually IDENTIFY. See handle_payload/3 op 9 + op 0 in :resuming. - # - # INVARIANT (keep this tight or the statem can wedge in :resuming with a - # cleared session forever): - # * Every handler that arms a `:state_timeout` while in :resuming MUST - # consult this flag before doing so - a replayed dispatch trickling in - # during the reidentify wait must NOT re-arm the @resume_timeout - # watchdog, or it cancels the pending :reidentify/:identify timer. See - # the `data.reidentify_pending?` branch of the op-0 `:resuming` cond. - # * Every do_identify/1 deferral path taken in :resuming (the residual - # identify-gap wait) runs with this flag still set; it is only cleared - # once send_identify/1 actually transitions us out of :resuming. - # * Also DO NOT bump `last_seq` from frames handled under this flag: that - # session is abandoned, so its sequence numbers are meaningless now. reidentify_pending?: false, # One-shot floor (ms) for the next reconnect delay, e.g. the mandated # 1-5s wait after an op 9 invalid-session. Consumed on :disconnected enter. - reconnect_after_ms: 0, - # Gateway send budget (token bucket). Discord allows 120 sends / 60s - # INCLUDING heartbeats; heartbeats bypass this bucket entirely (they use - # the reserved remainder), while op-3 presence updates and op-8 member - # requests each consume one token. When empty they're queued (bounded) - # and drained on `{:timeout, :send_refill}` ticks as tokens refill. - # - # The queue persists across reconnects (it lives in statem data, which a - # RESUME keeps): a frame that could not be sent is requeued at the front - # and drained once :connected is re-entered. One consequence is op-8 - # staleness - a queued GUILD_MEMBERS_CHUNK request may, after a reconnect, - # target a guild the bot has since left. That is harmless: Discord simply - # ignores an op-8 for a guild it no longer shares, and the request is - # idempotent, so no cleanup of the queue across reconnects is needed. - send_tokens: send_budget_max() * 1.0, - send_last_refill: System.monotonic_time(:millisecond), - send_queue: :queue.new() + reconnect_after_ms: 0 } {:ok, :disconnected, data} @@ -192,7 +153,6 @@ defmodule Dexcord.Gateway do data | status: nil, resp_headers: [], - pending_data: <<>>, websocket: nil, reidentify_pending?: false } @@ -281,20 +241,6 @@ defmodule Dexcord.Gateway do end end - # --- send-budget drain (named generic timeout, survives transitions) ---- - - # Tokens have refilled: drain as many queued op-3/op-8 frames as the budget now - # allows. Only fires usefully while :connected (the socket is live there); a tick - # arriving in any other state is dropped - the queue persists and :connected's - # state-enter re-arms a drain once the socket is back. - def handle_event({:timeout, :send_refill}, :drain, :connected, data) do - drain_queue(data) - end - - def handle_event({:timeout, :send_refill}, :drain, _state, _data) do - :keep_state_and_data - end - # --- shared: inbound socket traffic (mode: :active -> :info) ------------ def handle_event(:info, msg, _state, data) do @@ -342,31 +288,21 @@ defmodule Dexcord.Gateway do # --- casts -------------------------------------------------------------- - def handle_event(:cast, {:update_presence, presence}, state, data) do - cast_budgeted(state, data, Payload.presence_update(presence)) + def handle_event(:cast, {:update_presence, presence}, :connected, data) do + send_and_keep(data, Payload.presence_update(presence)) end - def handle_event(:cast, {:request_guild_members, guild_id, opts}, state, data) do - cast_budgeted(state, data, Payload.request_guild_members(guild_id, opts)) + def handle_event(:cast, {:request_guild_members, guild_id, opts}, :connected, data) do + send_and_keep(data, Payload.request_guild_members(guild_id, opts)) end def handle_event(:cast, _msg, _state, _data) do + # Presence/member requests while not connected are dropped this phase. :keep_state_and_data end # --- catch-all: ignore stray events, never crash the statem ------------- - # On (re)entering :connected, kick a drain if frames were queued while the - # budget was exhausted or the socket was down. A zero-delay tick lets - # drain_queue/1 send what the refilled budget now allows and re-arm precisely. - def handle_event(:enter, _old, :connected, data) do - if :queue.is_empty(data.send_queue) do - :keep_state_and_data - else - {:keep_state_and_data, [{{:timeout, :send_refill}, 0, :drain}]} - end - end - def handle_event(:enter, _old, _state, _data), do: :keep_state_and_data def handle_event(type, content, state, _data) do @@ -497,11 +433,8 @@ defmodule Dexcord.Gateway do # identify gap) before IDENTIFYing on this socket. A dispatch trickling in # now must NOT re-arm the resume watchdog: doing so would cancel the pending # :reidentify/:identify state_timeout and wedge us in :resuming with a cleared - # session forever. Dispatch it and leave the timers untouched. We also do NOT - # bump last_seq here: this session was abandoned on op 9 d:false (its state was - # cleared, last_seq reset to nil), so a trickled frame's sequence must not - # repopulate it and cross into the fresh session about to be IDENTIFYed. - dispatch_only(payload) + # session forever. Dispatch it and leave the timers untouched. + bump_and_dispatch(payload) {:keep_state, data} true -> @@ -527,30 +460,8 @@ defmodule Dexcord.Gateway do _ -> :ok end - dispatch_only(payload) - end - - # Dispatch an op-0 frame without touching last_seq (used for frames from an - # abandoned session during the reidentify wait). Tolerant of a non-binary `t`: - # Discord's `t` is a string on real dispatches, but a malformed/garbage op-0 frame - # (e.g. `"t": null`) must not crash the statem via EventNames.to_atom/1's - # is_binary-guarded clause. We treat a non-binary `t` as unrecognised protocol - # noise: log at debug and drop it rather than mint a bogus event or dispatch - # `nil` data to user handlers. Handled here (at the gateway altitude) on purpose, - # not by loosening EventNames, so the garbage is logged, not silently absorbed. - defp dispatch_only(payload) do - case payload["t"] do - t when is_binary(t) -> - Dexcord.Dispatcher.dispatch(Dexcord.EventNames.to_atom(t), payload["d"]) - - other -> - Logger.debug( - "Dexcord.Gateway op-0 frame with non-binary t=#{inspect(other)}; " <> - "treating as protocol noise and not dispatching" - ) - - :ok - end + name = Dexcord.EventNames.to_atom(payload["t"]) + Dexcord.Dispatcher.dispatch(name, payload["d"]) end # --- close handling ----------------------------------------------------- @@ -699,28 +610,11 @@ defmodule Dexcord.Gateway do {:headers, ref, headers} when ref == data.ref -> process_responses(%{data | resp_headers: data.resp_headers ++ headers}, rest, actions) - # A 101 upgrade is delivered by Mint as `:single`-body: the trailing bytes in - # the same TCP segment arrive as a `{:data, ref, bin}` response ORDERED BEFORE - # the `{:done, ref}` that lets us build the websocket. The server's first frame - # (Discord's HELLO) is routinely packed into that segment, so we must buffer - # these pre-upgrade bytes rather than drop them; they're decoded in the - # `{:done, ...}` clause below the instant the websocket exists. - {:data, ref, bin} when ref == data.ref and data.websocket == nil -> - process_responses(%{data | pending_data: data.pending_data <> bin}, rest, actions) - {:done, ref} when ref == data.ref and data.websocket == nil -> case Mint.WebSocket.new(data.conn, ref, data.status, data.resp_headers) do {:ok, conn, websocket} -> data = %{data | conn: conn, websocket: websocket} - upgrade_action = {:next_event, :internal, :upgraded} - - case decode_pending(data) do - {:ok, data, frame_actions} -> - process_responses(data, rest, frame_actions ++ [upgrade_action | actions]) - - {:error, data, reason} -> - {:error, data, reason} - end + process_responses(data, rest, [{:next_event, :internal, :upgraded} | actions]) {:error, conn, reason} -> {:error, %{data | conn: conn}, reason} @@ -748,26 +642,6 @@ defmodule Dexcord.Gateway do end end - # Decode any bytes buffered during the upgrade (a HELLO coalesced with the 101) - # now that the websocket exists, emitting them as internal `{:frame, _}` events in - # order. Returns the actions reversed to match process_responses/3's accumulator. - defp decode_pending(%{pending_data: <<>>} = data), do: {:ok, data, []} - - defp decode_pending(%{pending_data: buffered} = data) do - case Mint.WebSocket.decode(data.websocket, buffered) do - {:ok, websocket, frames} -> - actions = - frames - |> Enum.map(&{:next_event, :internal, {:frame, &1}}) - |> Enum.reverse() - - {:ok, %{data | websocket: websocket, pending_data: <<>>}, actions} - - {:error, websocket, reason} -> - {:error, %{data | websocket: websocket, pending_data: <<>>}, reason} - end - end - # Sends a websocket frame, threading the Mint structs. Never crashes. defp send_frame(%{websocket: nil} = data, _frame), do: {:error, data, :no_websocket} @@ -792,139 +666,13 @@ defmodule Dexcord.Gateway do end end - # --- gateway send budget (token bucket) --------------------------------- - - # Send a budgeted (op-3/op-8) frame if a token is available, else queue it. The - # bucket refills continuously (lazy, monotonic-clock based) so no timer runs - # while the budget is healthy; a refill tick is only armed when we actually have - # to wait for the next token. - # Route an op-3/op-8 cast according to the connection state. - # - # * :connected - spend a token and send now (queue-first, FIFO). - # * any other lifecycle enqueue (bounded) instead of dropping. The frame - # state (:connecting/ survives the handshake or resume and drains on the - # :hello_wait/ next `:connected` enter. This is what stops a - # :identifying/:resuming) request_guild_members issued while GUILD_CREATEs are - # still replaying in :resuming from being lost. - # * :disconnected - enqueue only if a session still exists (a transient - # reconnect that will RESUME and drain); otherwise drop. - # With no session there is no socket and no imminent - # :connected to drain into, so queueing would just - # accumulate frames for a connection that may never come. - defp cast_budgeted(:connected, data, frame_map), do: queue_or_send(data, frame_map) - - defp cast_budgeted(:disconnected, data, frame_map) do - if Session.session_id() do - {:keep_state, enqueue(data, frame_map)} - else - :keep_state_and_data - end - end - - defp cast_budgeted(_state, data, frame_map), do: {:keep_state, enqueue(data, frame_map)} - - defp queue_or_send(data, frame_map) do - data = refill(data) - - # Queue-first discipline: only send immediately when NOTHING is already waiting. - # If the queue is non-empty, this frame must fall in behind the older ones (FIFO) - # - jumping ahead would let a newer op-3 presence overwrite an older queued one - # out of order (Discord is last-write-wins), or reorder op-8 requests. So we - # enqueue at the back and drain from the front for exactly as many tokens as we - # have; the empty-queue fast path preserves the no-timer, send-now behaviour when - # the budget is healthy. - if :queue.is_empty(data.send_queue) and data.send_tokens >= 1.0 do - case send_one_budgeted(data, frame_map) do - {:ok, data} -> {:keep_state, data} - {:error, data} -> fail_reconnect(data) - end - else - do_drain(enqueue(data, frame_map)) - end - end - - # Drain queued frames greedily up to the current token balance, then re-arm a - # refill tick if the queue is still backed up. - defp drain_queue(data), do: do_drain(refill(data)) - - defp do_drain(data) do - cond do - :queue.is_empty(data.send_queue) -> - {:keep_state, data} - - data.send_tokens >= 1.0 -> - {{:value, frame_map}, rest} = :queue.out(data.send_queue) - - case send_one_budgeted(%{data | send_queue: rest}, frame_map) do - {:ok, data} -> do_drain(data) - {:error, data} -> fail_reconnect(data) - end - - true -> - {:keep_state, data, [refill_action(data)]} - end - end - - # Spend one token and send a single budgeted (op-3/op-8) frame. The one place both - # the send-now and drain paths encode+send a budgeted frame, so the spend/refund - # and requeue-on-failure accounting lives here once. - # - # * success -> `{:ok, data}` with a token spent. - # * failure -> `{:error, data}`: the token is REFUNDED (the frame never left, so - # charging it would leak budget over time) and the frame is requeued at the - # FRONT so it isn't lost; the caller then fail_reconnects and the drain re-sends - # it once :connected returns. - defp send_one_budgeted(data, frame_map) do - data = %{data | send_tokens: data.send_tokens - 1.0} - + defp send_and_keep(data, frame_map) do case send_frame(data, {:text, JSON.encode!(frame_map)}) do - {:ok, data} -> - {:ok, data} - - {:error, data, reason} -> - Logger.warning("Dexcord.Gateway budgeted send failed: #{inspect(reason)}") - - data = %{ - data - | send_tokens: min(data.send_tokens + 1.0, send_budget_max() * 1.0), - send_queue: :queue.in_r(frame_map, data.send_queue) - } - - {:error, data} + {:ok, data} -> {:keep_state, data} + {:error, data, _reason} -> fail_reconnect(data) end end - defp enqueue(data, frame_map) do - if :queue.len(data.send_queue) >= send_queue_max() do - Logger.warning( - "Dexcord.Gateway send-budget queue full (#{send_queue_max()}); " <> - "dropping op #{frame_map["op"]} frame" - ) - - data - else - %{data | send_queue: :queue.in(frame_map, data.send_queue)} - end - end - - # Continuous token refill based on wall time since the last touch, capped at the - # bucket size. Reads config each call so tests can shrink the budget live. - defp refill(data) do - {max, window} = send_budget() - now = System.monotonic_time(:millisecond) - elapsed = now - data.send_last_refill - tokens = min(max * 1.0, data.send_tokens + elapsed * (max / window)) - %{data | send_tokens: tokens, send_last_refill: now} - end - - # Named generic timeout for when the next whole token becomes available (only - # armed when tokens < 1, so the delay is always positive). - defp refill_action(data) do - {max, window} = send_budget() - ms = max(1, ceil((1.0 - data.send_tokens) / (max / window))) - {{:timeout, :send_refill}, ms, :drain} - end - # --- identify / resume / heartbeat sends -------------------------------- # Enforce the identify gap on EVERY identify path. If a gap is still pending @@ -1028,16 +776,6 @@ defmodule Dexcord.Gateway do defp max_resume_attempts, do: Application.get_env(:dexcord, :max_resume_attempts, 5) - # Send budget `{tokens, window_ms}`. Default `{115, 60_000}`: Discord's real - # limit is 120 sends / 60s including heartbeats, so ~5 tokens are reserved for - # heartbeats (which bypass this bucket) and the remaining 115 fund op-3/op-8. - defp send_budget, do: Application.get_env(:dexcord, :send_budget, {115, 60_000}) - defp send_budget_max, do: elem(send_budget(), 0) - - # Max frames held while the budget is exhausted; further arrivals are dropped - # (with a warning) until the queue drains. - defp send_queue_max, do: Application.get_env(:dexcord, :send_queue_max, 128) - @doc false # Reconnect backoff: min(base * 2^attempt, cap) with +-20% jitter; 0 on the # first attempt. `:backoff` config `{base_ms, cap_ms}` defaults to `{1000, 60000}`. diff --git a/lib/dexcord/prefix.ex b/lib/dexcord/prefix.ex deleted file mode 100644 index 2136053..0000000 --- a/lib/dexcord/prefix.ex +++ /dev/null @@ -1,148 +0,0 @@ -defmodule Dexcord.Prefix do - @moduledoc """ - A textual (prefix) command router. - - `parse/2` is a pure function that recognises a prefixed command and splits it - into a command word plus its argument string / argument list. `dispatch/2` - wires a raw `MESSAGE_CREATE` payload through `parse/2` into a - `Dexcord.Prefix.Router` module, skipping messages authored by bots (including - the bot itself). A router `use`s `Dexcord.Prefix.Router` and defines - `handle_command/3` clauses for the commands it cares about; an `:ignore` - catch-all is injected for everything else. - - defmodule MyBot.Commands do - use Dexcord.Prefix.Router - - def handle_command("ping", _args, msg), - do: Dexcord.Api.create_message(msg["channel_id"], "pong") - end - - # in the handler: - def handle_event({:MESSAGE_CREATE, msg}), - do: Dexcord.Prefix.dispatch(msg, prefix: "!", to: MyBot.Commands) - """ - - @doc """ - Parses `content` as a `prefix`-prefixed command. - - Returns `{:ok, command, arg_string, args}` where `command` is the first word - after the prefix, `arg_string` is the trimmed remainder, and `args` is that - remainder split on whitespace. Returns `:nomatch` when `content` does not start - with `prefix`, when there is no command word after the prefix, or when either - argument is not a binary. - - Pure: no cache reads, no bot-author check (that lives in `dispatch/2`). - - ## Examples - - iex> Dexcord.Prefix.parse("!echo hello world", "!") - {:ok, "echo", "hello world", ["hello", "world"]} - - iex> Dexcord.Prefix.parse("!ping", "!") - {:ok, "ping", "", []} - - iex> Dexcord.Prefix.parse("hello", "!") - :nomatch - """ - @spec parse(binary(), binary()) :: - {:ok, String.t(), String.t(), [String.t()]} | :nomatch - def parse(content, prefix) - when is_binary(content) and is_binary(prefix) and prefix != "" do - if String.starts_with?(content, prefix) do - content - |> binary_part(byte_size(prefix), byte_size(content) - byte_size(prefix)) - |> String.trim_leading() - |> split_command() - else - :nomatch - end - end - - def parse(_content, _prefix), do: :nomatch - - # The prefix has already been stripped and any leading whitespace trimmed. - defp split_command(rest) do - case String.split(rest, ~r/\s+/, parts: 2) do - # Prefix with nothing (or only whitespace) after it: not a command. - [""] -> - :nomatch - - [command] -> - {:ok, command, "", []} - - [command, arg_string] -> - arg_string = String.trim_trailing(arg_string) - {:ok, command, arg_string, String.split(arg_string, ~r/\s+/, trim: true)} - end - end - - @doc """ - Routes a raw `MESSAGE_CREATE` payload to a `Dexcord.Prefix.Router`. - - Options: - - * `:prefix` (required) - the command prefix, e.g. `"!"` - * `:to` (required) - the router module - - Messages authored by a bot are skipped and return `:ignore`. "Authored by a - bot" means either the message's `author.bot` flag is `true`, or the author id - matches the cached bot user (`Dexcord.Cache.me/0`) - this self-check catches the - bot's own messages even on the rare payloads that omit the `bot` flag, and - prevents a command that replies in-channel from recursively triggering itself. - - On a match the router's `handle_command(command, args, msg)` is called and its - result returned; a non-match (or a skipped bot message) returns `:ignore`. - """ - @spec dispatch(map(), keyword()) :: any() - def dispatch(msg, opts) when is_map(msg) do - prefix = Keyword.fetch!(opts, :prefix) - router = Keyword.fetch!(opts, :to) - - if bot_author?(msg) do - :ignore - else - case parse(msg["content"] || "", prefix) do - {:ok, command, _arg_string, args} -> router.handle_command(command, args, msg) - :nomatch -> :ignore - end - end - end - - defp bot_author?(msg) do - author = msg["author"] || %{} - author["bot"] == true or own_message?(author) - end - - defp own_message?(author) do - case Dexcord.Cache.me() do - {:ok, %{"id" => id}} -> is_binary(id) and author["id"] == id - _ -> false - end - end -end - -defmodule Dexcord.Prefix.Router do - @moduledoc """ - Behaviour for a prefix command router. - - `use Dexcord.Prefix.Router` declares the behaviour and injects an `:ignore` - catch-all `handle_command/3`, so a router only writes the command clauses it - wants. `handle_command/3` receives the command word, the whitespace-split - argument list, and the raw `MESSAGE_CREATE` map. - """ - - @callback handle_command(command :: String.t(), args :: [String.t()], msg :: map()) :: any() - - defmacro __using__(_opts) do - quote do - @behaviour Dexcord.Prefix.Router - @before_compile Dexcord.Prefix.Router - end - end - - defmacro __before_compile__(_env) do - quote do - def handle_command(_command, _args, _msg), do: :ignore - end - end -end diff --git a/lib/dexcord/session.ex b/lib/dexcord/session.ex index 945d0ed..8a286fb 100644 --- a/lib/dexcord/session.ex +++ b/lib/dexcord/session.ex @@ -94,20 +94,11 @@ defmodule Dexcord.Session do @doc """ Records a freshly-established session from a READY payload. - - Resets `last_seq` to `nil` so a new session never inherits the previous - session's sequence number. Sequence numbers are per-session; carrying a stale - `last_seq` across a session boundary would let a later RESUME (or heartbeat) - reference a sequence that belongs to an abandoned session. READY's own sequence - number bumps `last_seq` right after this call (`bump_and_dispatch/1` runs - immediately after `establish/2` in the gateway's READY handler), so the fresh - session is seeded from its own first frame. """ @spec establish(String.t(), String.t()) :: :ok def establish(session_id, resume_gateway_url) do put(:session_id, session_id) put(:resume_gateway_url, resume_gateway_url) - put(:last_seq, nil) :ok end diff --git a/lib/dexcord/slash.ex b/lib/dexcord/slash.ex deleted file mode 100644 index c10c435..0000000 --- a/lib/dexcord/slash.ex +++ /dev/null @@ -1,256 +0,0 @@ -defmodule Dexcord.Slash do - @moduledoc """ - Slash (application) command behaviour, routing, and response helpers. - - A bot's slash module `use`s `Dexcord.Slash` and defines: - - * `commands/0` - the list of command-definition maps registered at startup by - `Dexcord.Slash.Registrar` (see below) - * `handle_interaction/2` clauses per command name (application commands) - * optionally `handle_component/2` and `handle_modal/2` clauses, keyed on the - interaction's `custom_id`, for message-component and modal-submit interactions - - `use Dexcord.Slash` injects catch-all clauses for all three callbacks: the - `handle_interaction/2` fallback logs a **warning** (an application command the - module declared but does not handle is unexpected), while the - `handle_component/2` and `handle_modal/2` fallbacks only log at **debug** - (components and modals are commonly handled elsewhere, so an unmatched - `custom_id` is routine, not an error). - - defmodule MyBot.Slash do - use Dexcord.Slash - - def commands, do: [%{name: "ping", description: "Pong!"}] - - def handle_interaction("ping", itx), do: Dexcord.Slash.respond(itx, "pong") - - # message component (button / select) - keyed on custom_id - def handle_component("refresh", itx), do: Dexcord.Slash.respond(itx, "refreshed") - - # modal submit - keyed on custom_id - def handle_modal("feedback_form", itx), do: Dexcord.Slash.respond(itx, "thanks!") - end - - `dispatch/2` routes a raw `INTERACTION_CREATE` payload on its **top-level** - `interaction["type"]`: type 2 (application command) → `handle_interaction/2` - keyed on `interaction["data"]["name"]`; type 3 (message component) → - `handle_component/2` keyed on `interaction["data"]["custom_id"]`; type 5 (modal - submit) → `handle_modal/2` keyed on `interaction["data"]["custom_id"]`. The - `Dexcord.Dispatcher` calls it automatically for those types when a `slash:` - module is configured (the raw event still reaches the handler). - - ## Response helpers - - All four helpers go through `Dexcord.Api`. Payloads stay string-keyed on the - wire; the `data` maps you pass may use atom **or** string keys for - `content`/`embeds`/`components`, and `ephemeral: true` is translated to the - message `flags` bit (64). - - * `respond/2` - an immediate CHANNEL_MESSAGE_WITH_SOURCE (type 4) - * `respond_later/1` - a DEFERRED response (type 5); "thinking..." while you work - * `followup/2` - a followup message after a deferred/initial response - * `edit_response/2` - edit the original (deferred or immediate) response - """ - - @doc "The command-definition maps to register for this module." - @callback commands() :: [map()] - - @doc "Handles a routed application-command interaction (type 2) for command `name`." - @callback handle_interaction(name :: String.t(), interaction :: map()) :: any() - - @doc "Handles a routed message-component interaction (type 3) for `custom_id`." - @callback handle_component(custom_id :: String.t() | nil, interaction :: map()) :: any() - - @doc "Handles a routed modal-submit interaction (type 5) for `custom_id`." - @callback handle_modal(custom_id :: String.t() | nil, interaction :: map()) :: any() - - # Only `commands/0` and `handle_interaction/2` are required; a module that never - # uses components or modals need not define those callbacks (the injected - # defaults below satisfy the behaviour). - @optional_callbacks handle_component: 2, handle_modal: 2 - - defmacro __using__(_opts) do - quote do - @behaviour Dexcord.Slash - @before_compile Dexcord.Slash - end - end - - defmacro __before_compile__(_env) do - quote do - require Logger - - def handle_interaction(name, _interaction) do - Logger.warning( - "#{inspect(__MODULE__)} has no handle_interaction/2 clause for command " <> - "#{inspect(name)}; ignoring the interaction" - ) - - :ignore - end - - def handle_component(custom_id, _interaction) do - Logger.debug( - "#{inspect(__MODULE__)} has no handle_component/2 clause for custom_id " <> - "#{inspect(custom_id)}; ignoring the component interaction" - ) - - :ignore - end - - def handle_modal(custom_id, _interaction) do - Logger.debug( - "#{inspect(__MODULE__)} has no handle_modal/2 clause for custom_id " <> - "#{inspect(custom_id)}; ignoring the modal interaction" - ) - - :ignore - end - end - end - - # --- routing ------------------------------------------------------------ - - @doc """ - Routes an `INTERACTION_CREATE` payload to `mod` on its top-level `"type"`. - - * type 2 (application command) → `mod.handle_interaction/2`, keyed on - `interaction["data"]["name"]` - * type 3 (message component) → `mod.handle_component/2`, keyed on - `interaction["data"]["custom_id"]` - * type 5 (modal submit) → `mod.handle_modal/2`, keyed on - `interaction["data"]["custom_id"]` - - Any other (or missing) type is ignored - the `Dexcord.Dispatcher` only routes - types 2/3/5 here, so this is defensive. - """ - @spec dispatch(map(), module()) :: any() - def dispatch(interaction, mod) when is_map(interaction) and is_atom(mod) do - case interaction["type"] do - 2 -> mod.handle_interaction(get_in(interaction, ["data", "name"]), interaction) - 3 -> mod.handle_component(get_in(interaction, ["data", "custom_id"]), interaction) - 5 -> mod.handle_modal(get_in(interaction, ["data", "custom_id"]), interaction) - _ -> :ignore - end - end - - # --- response helpers --------------------------------------------------- - - @doc """ - Sends an immediate response (type 4) to `interaction`. - - `text_or_map` is either a binary (used as `content`) or a map supporting - `content`, `embeds`, `components`, and `ephemeral: true`. - """ - @spec respond(map(), String.t() | map()) :: {:ok, map()} | {:ok, nil} | {:error, term()} - def respond(interaction, content) when is_binary(content) do - respond(interaction, %{content: content}) - end - - def respond(interaction, %{} = data) do - Dexcord.Api.create_interaction_response( - interaction["id"], - interaction["token"], - %{"type" => 4, "data" => message_data(data)} - ) - end - - @doc """ - Sends a deferred response (type 5) - shows a loading state while you prepare a - followup or edit the original response. - """ - @spec respond_later(map()) :: {:ok, map()} | {:ok, nil} | {:error, term()} - def respond_later(interaction) do - Dexcord.Api.create_interaction_response( - interaction["id"], - interaction["token"], - %{"type" => 5} - ) - end - - @doc """ - Sends a followup message for `interaction`. - - `text_or_map` is a binary (used as `content`) or a map as in `respond/2`. - """ - @spec followup(map(), String.t() | map()) :: {:ok, map()} | {:error, term()} - def followup(interaction, content) when is_binary(content) do - followup(interaction, %{content: content}) - end - - def followup(interaction, %{} = data) do - Dexcord.Api.create_followup_message( - interaction["application_id"], - interaction["token"], - message_data(data) - ) - end - - @doc """ - Edits the original response for `interaction`. - - `text_or_map` is a binary (used as `content`) or a map as in `respond/2`. - """ - @spec edit_response(map(), String.t() | map()) :: {:ok, map()} | {:error, term()} - def edit_response(interaction, content) when is_binary(content) do - edit_response(interaction, %{content: content}) - end - - def edit_response(interaction, %{} = data) do - Dexcord.Api.edit_original_interaction_response( - interaction["application_id"], - interaction["token"], - message_data(data) - ) - end - - # Builds the string-keyed interaction message-data map from a caller map that - # may use atom or string keys. Only recognised fields are copied through. - # `ephemeral: true` contributes the ephemeral `flags` bit (64), and a - # caller-supplied integer `:flags`/`"flags"` is preserved and OR-ed with it, so - # (e.g.) `flags: 4` passes through as `4` and `flags: 4, ephemeral: true` as `68`. - defp message_data(data) do - base = - Enum.reduce([:content, :embeds, :components], %{}, fn field, acc -> - case fetch_any(data, field) do - {:ok, value} -> Map.put(acc, Atom.to_string(field), value) - :error -> acc - end - end) - - case flags(data) do - nil -> base - value -> Map.put(base, "flags", value) - end - end - - # Combines a caller-supplied integer `flags` with the ephemeral bit (64). - # Returns nil when neither is present so the key is omitted entirely. - defp flags(data) do - caller = - case fetch_any(data, :flags) do - {:ok, value} when is_integer(value) -> value - _ -> nil - end - - ephemeral = if truthy?(fetch_any(data, :ephemeral)), do: 64, else: nil - - case {caller, ephemeral} do - {nil, nil} -> nil - {nil, bit} -> bit - {value, nil} -> value - {value, bit} -> Bitwise.bor(value, bit) - end - end - - defp fetch_any(map, key) do - cond do - Map.has_key?(map, key) -> {:ok, Map.fetch!(map, key)} - Map.has_key?(map, Atom.to_string(key)) -> {:ok, Map.fetch!(map, Atom.to_string(key))} - true -> :error - end - end - - defp truthy?({:ok, value}) when value not in [nil, false], do: true - defp truthy?(_), do: false -end diff --git a/lib/dexcord/slash/registrar.ex b/lib/dexcord/slash/registrar.ex deleted file mode 100644 index b9088ae..0000000 --- a/lib/dexcord/slash/registrar.ex +++ /dev/null @@ -1,180 +0,0 @@ -defmodule Dexcord.Slash.Registrar do - @moduledoc """ - Startup Task that registers a bot's slash commands via REST. - - Added to the supervision tree (right before `Dexcord.Gateway`) **only** when a - `slash:` module is configured, with `restart: :temporary`. It runs REST-only and - independently of the gateway - registration never touches or cycles the gateway - connection. - - On start it: - - 1. calls `GET /oauth2/applications/@me` to learn the application id and caches - it via `Dexcord.Config.put_application_id/1` (a dedicated `:persistent_term` - key, so any process can read it back with `Dexcord.Config.application_id/0`); - 2. bulk-overwrites the module's `commands/0`: - * **guild mode** - when `slash_guild_ids:` is a non-empty list, overwrites - each guild's commands (instant propagation, ideal for development). It - **never** touches global commands: an empty global overwrite would wipe a - bot's real (production) commands, so guild mode logs a hint instead. - * **global mode** - otherwise overwrites the application's global commands - (~1h propagation). As a symmetric footgun guard, an **empty** command list - in global mode is skipped (it would wipe every production command); the - Registrar logs how to do that on purpose instead. - - ## Failure behaviour - - The Registrar owns its own retry policy rather than leaning on supervisor - restarts. It is `restart: :temporary`, so the supervisor never restarts it: a - registration failure can therefore NEVER cycle the gateway (or share the tree's - `max_restarts` budget with it - a burst of fast 4xx failures must not terminate - a healthy session). Instead, on failure it retries in-process a bounded number - of times with increasing back-off (default `2s` then `10s`), and if every - attempt fails it logs a loud `Logger.error` and exits `:normal`. The retry - delays are configurable via `config :dexcord, :registrar_retry_delays, [..]` - (a list of millisecond sleeps between attempts; its length + 1 is the attempt - count). - """ - - require Logger - - @default_retry_delays [2_000, 10_000] - - @doc false - def child_spec(config) do - %{ - id: __MODULE__, - start: {__MODULE__, :start_link, [config]}, - restart: :temporary, - type: :worker - } - end - - @doc false - def start_link(config) do - Task.start_link(fn -> run(config) end) - end - - @doc """ - Runs the registration synchronously, with the bounded in-process retry loop. - - Public so tests can drive it directly. Always returns `:ok` (exiting `:normal` - when the Task wraps it): registration failure is contained here and never - propagated as an abnormal exit, so it can never cycle the gateway. - """ - @spec run(map()) :: :ok - def run(config) do - attempt(config, 1, retry_delays()) - end - - # One registration attempt; on failure either sleeps + retries or gives up loud. - defp attempt(config, n, delays) do - case try_register(config) do - :ok -> - :ok - - {:error, message} -> - case Enum.at(delays, n - 1) do - nil -> - Logger.error( - "Dexcord.Slash.Registrar: giving up after #{n} attempt(s) - #{message}. " <> - "Slash commands were NOT registered; the gateway is unaffected. Fix the cause " <> - "and restart the bot to retry." - ) - - :ok - - delay -> - Logger.warning( - "Dexcord.Slash.Registrar: attempt #{n} failed - #{message}. " <> - "Retrying in #{delay}ms." - ) - - Process.sleep(delay) - attempt(config, n + 1, delays) - end - end - end - - # A single end-to-end registration: resolve the app id, then overwrite commands. - # Returns :ok | {:error, message} - never exits. - defp try_register(config) do - commands = config.slash.commands() - - with {:ok, app_id} <- resolve_app_id() do - Dexcord.Config.put_application_id(app_id) - register(app_id, commands, Map.get(config, :slash_guild_ids)) - end - end - - defp resolve_app_id do - case Dexcord.Api.get_current_application() do - {:ok, %{"id" => id}} when is_binary(id) -> - {:ok, id} - - other -> - {:error, - "could not resolve the application id (GET /oauth2/applications/@me): " <> - inspect(other)} - end - end - - # Guild mode: overwrite each guild, never touch globals. - defp register(app_id, commands, guild_ids) - when is_list(guild_ids) and guild_ids != [] do - result = - Enum.reduce_while(guild_ids, :ok, fn guild_id, _acc -> - case Dexcord.Api.bulk_overwrite_guild_commands(app_id, guild_id, commands) do - {:ok, _} -> - {:cont, :ok} - - {:error, error} -> - {:halt, - {:error, - "guild command overwrite failed for guild #{inspect(guild_id)}: #{inspect(error)}"}} - end - end) - - with :ok <- result do - Logger.info( - "Dexcord.Slash.Registrar: registered #{length(commands)} command(s) to " <> - "#{length(guild_ids)} guild(s). Global commands were NOT modified (guild/dev mode); " <> - "any previously-registered global commands remain - remove them manually if unwanted." - ) - - :ok - end - end - - # Global mode with an empty command list: skip. Symmetric with guild mode - an - # empty global overwrite would wipe every production command every boot. - defp register(app_id, [] = _commands, _no_guild_ids) do - Logger.info( - "Dexcord.Slash.Registrar: commands/0 is empty in global mode; skipping the overwrite so " <> - "existing global commands are NOT wiped. To intentionally remove all global commands, " <> - "call Dexcord.Api.bulk_overwrite_global_commands(#{inspect(app_id)}, []) manually." - ) - - :ok - end - - # Global mode: overwrite the application's global commands. - defp register(app_id, commands, _no_guild_ids) do - case Dexcord.Api.bulk_overwrite_global_commands(app_id, commands) do - {:ok, _} -> - Logger.info( - "Dexcord.Slash.Registrar: registered #{length(commands)} global command(s) " <> - "(propagation can take up to ~1h)." - ) - - :ok - - {:error, error} -> - {:error, "global command overwrite failed: #{inspect(error)}"} - end - end - - defp retry_delays do - Application.get_env(:dexcord, :registrar_retry_delays, @default_retry_delays) - end -end diff --git a/lib/dexcord/supervisor.ex b/lib/dexcord/supervisor.ex index 2f97179..ae765e2 100644 --- a/lib/dexcord/supervisor.ex +++ b/lib/dexcord/supervisor.ex @@ -16,24 +16,11 @@ defmodule Dexcord.Supervisor do `max_restarts` trips in milliseconds and this supervisor terminates - surfacing the failure to the host application instead of hammering Discord. - ## Notes + ## Phase note - `Dexcord.Cache` owns the cache ETS tables and `Dexcord.Dispatcher` is their sole - writer; a Cache crash drops the named tables, which would make the still-running - Dispatcher's next `:ets.insert` raise. To contain that, the two are grouped into - a small nested supervisor with `strategy: :rest_for_one` (Cache first, then the - Dispatcher): a Cache restart re-creates the tables and then restarts the - Dispatcher after it, so the Dispatcher never writes into dropped tables, while a - Dispatcher-only crash restarts just the Dispatcher. This nested pair sits right - after `Dexcord.Session` (tables exist before anything writes) and before `Finch`; - it is isolated from the outer tree, so `Dexcord.Gateway`/`Dexcord.Session` crash - and resume semantics are completely unchanged. `Dexcord.Api.Ratelimit` owns the + `Dexcord.Cache` and `Dexcord.Slash.Registrar` are not started yet + (Cache -> Phase 4, Registrar -> Phase 5). `Dexcord.Api.Ratelimit` owns the REST rate-limit ETS and sits after Finch, before the Task supervisor. - - `Dexcord.Slash.Registrar` is added (as a `:transient` Task) between the - Dispatcher and the Gateway, but **only** when a `slash:` module is configured. - It needs Finch + the rate limiter (both earlier) and runs REST-only, so it - starts and registers commands before / in parallel with the gateway connecting. """ use Supervisor @@ -48,42 +35,16 @@ defmodule Dexcord.Supervisor do # Publish the validated config before any child that reads it starts. Dexcord.Config.put(config) - # Gateway is always last: it depends on everything above. - children = - [ - Dexcord.Session, - cache_and_dispatcher(), - {Finch, name: Dexcord.Finch}, - Dexcord.Api.Ratelimit, - {Task.Supervisor, name: Dexcord.TaskSupervisor} - ] ++ - registrar_child(config) ++ - [Dexcord.Gateway] + children = [ + Dexcord.Session, + {Finch, name: Dexcord.Finch}, + Dexcord.Api.Ratelimit, + {Task.Supervisor, name: Dexcord.TaskSupervisor}, + Dexcord.Dispatcher, + # Gateway is always last: it depends on everything above. + Dexcord.Gateway + ] Supervisor.init(children, strategy: :one_for_one, max_restarts: 5, max_seconds: 30) end - - # Cache + Dispatcher under their own :rest_for_one supervisor: a Cache crash - # (which drops the named ETS tables) restarts Cache and then the Dispatcher that - # writes them, so the Dispatcher can never write into dropped tables. Isolated - # from the outer tree, this leaves Gateway/Session restart semantics untouched. - defp cache_and_dispatcher do - %{ - id: Dexcord.Cache.Supervisor, - start: - {Supervisor, :start_link, - [ - [Dexcord.Cache, Dexcord.Dispatcher], - [strategy: :rest_for_one] - ]}, - type: :supervisor - } - end - - # The slash-command Registrar Task, present only when a slash module is set. - defp registrar_child(%{slash: slash} = config) when is_atom(slash) and not is_nil(slash) do - [{Dexcord.Slash.Registrar, config}] - end - - defp registrar_child(_config), do: [] end diff --git a/mix.exs b/mix.exs index 1e3de45..8aab014 100644 --- a/mix.exs +++ b/mix.exs @@ -1,8 +1,6 @@ defmodule Dexcord.MixProject do use Mix.Project - @source_url "https://github.com/luna/dexcord" - def project do [ app: :dexcord, @@ -10,17 +8,7 @@ defmodule Dexcord.MixProject do elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, - deps: deps(), - name: "Dexcord", - source_url: @source_url, - docs: docs() - ] - end - - defp docs do - [ - main: "readme", - extras: ["README.md"] + deps: deps() ] end diff --git a/test/dexcord/api_integration_test.exs b/test/dexcord/api_integration_test.exs index 92eb31e..df16415 100644 --- a/test/dexcord/api_integration_test.exs +++ b/test/dexcord/api_integration_test.exs @@ -16,7 +16,6 @@ defmodule Dexcord.ApiIntegrationTest do @token "test.token.value" setup do - Dexcord.EnvSandbox.sandbox_env() Dexcord.Config.put(%{token: @token, handler: nil, intents: 0}) start_supervised!({Finch, name: Dexcord.Finch}) @@ -26,6 +25,11 @@ defmodule Dexcord.ApiIntegrationTest do Application.put_env(:dexcord, :api_base_url, FakeRest.base_url()) FakeRest.subscribe(self()) + on_exit(fn -> + Application.delete_env(:dexcord, :api_base_url) + Application.delete_env(:dexcord, :ratelimit_now_fn) + end) + :ok end @@ -53,9 +57,6 @@ defmodule Dexcord.ApiIntegrationTest do assert headers["authorization"] == "Bot #{@token}" assert headers["user-agent"] =~ "DiscordBot" - - # Exactly one request went out. - refute_receive {:rest_hit, _}, 50 end test "204 responses decode to {:ok, nil}" do @@ -136,10 +137,9 @@ defmodule Dexcord.ApiIntegrationTest do {elapsed_us, result} = :timer.tc(fn -> Api.create_message(5, "hi") end) assert {:ok, %{"id" => "ok"}} = result - # Exactly two hits: the 429 and the successful retry - and no more. + # Two hits: the 429 and the successful retry. assert_receive {:rest_hit, _} assert_receive {:rest_hit, _} - refute_receive {:rest_hit, _}, 50 assert elapsed_us >= 200_000 end @@ -176,65 +176,6 @@ defmodule Dexcord.ApiIntegrationTest do assert {:ok, %{"id" => "a"}} = Task.await(task, 5_000) end - test "a wait that would exceed the request deadline returns an error tuple" do - Application.put_env(:dexcord, :api_deadline_ms, 100) - - # Learn bucket DL with remaining 0 and a ~100s window; the next call would - # have to sleep far past the 100ms deadline. - FakeRest.stub( - :post, - "/channels/3/messages", - ok_json(~s({"id":"1"}), bucket: "DL", remaining: 0, reset_after: 100.0) - ) - - assert {:ok, _} = Api.create_message(3, "first") - assert_receive {:rest_hit, _} - - assert {:error, %Error{status: nil, message: "rate limit deadline exceeded"}} = - Api.create_message(3, "second") - - # The deadline tripped before any second request went on the wire. - refute_receive {:rest_hit, _}, 50 - end - - test "a non-JSON 2xx body is returned raw rather than dropped to nil" do - FakeRest.stub( - :get, - "/channels/77", - FakeRest.resp(200, headers: [{"content-type", "text/plain"}], body: "not json") - ) - - assert {:ok, {:raw, "not json"}} = Api.get_channel(77) - end - - test "a non-JSON error body is preserved as a bounded snippet in the message" do - html = "502 Bad Gateway" - - FakeRest.stub( - :get, - "/channels/78", - FakeRest.resp(502, headers: [{"content-type", "text/html"}], body: html) - ) - - assert {:error, %Error{status: 502, code: nil, message: msg}} = Api.get_channel(78) - assert msg =~ "Bad Gateway" - end - - test "an encode that raises still lets the route recover (probe not wedged)" do - FakeRest.stub( - :post, - "/channels/999/messages", - ok_json(~s({"id":"ok"}), bucket: "RC", remaining: 5, reset_after: 1.0) - ) - - # A PID is not JSON-encodable: the first (probing) request raises during - # encode. The probe must still be released so the route isn't wedged. - assert catch_error(Api.create_message(999, %{"content" => self()})) - - # A normal follow-up to the same route completes. - assert {:ok, %{"id" => "ok"}} = Api.create_message(999, "recovered") - end - # --- helpers ------------------------------------------------------------ defp global_lock_set?() do diff --git a/test/dexcord/cache_cascade_test.exs b/test/dexcord/cache_cascade_test.exs deleted file mode 100644 index 9d7d8d1..0000000 --- a/test/dexcord/cache_cascade_test.exs +++ /dev/null @@ -1,84 +0,0 @@ -defmodule Dexcord.CacheCascadeTest do - @moduledoc """ - Finding 4: a `Dexcord.Cache` crash must not cascade into the `Dexcord.Dispatcher` - (which would otherwise write into the dropped named ETS tables and raise), and - must never touch the gateway. Cache + Dispatcher live under a nested - `:rest_for_one` supervisor, so a Cache crash recreates the tables and restarts - the Dispatcher after it; the outer tree (gateway, session) is untouched. - """ - use ExUnit.Case, async: false - - @token "fake.token.value" - - defmodule Handler do - use Dexcord.Handler - end - - # Grab a port, then release it so connecting to it is refused (gateway backs off). - defp closed_port do - {:ok, socket} = :gen_tcp.listen(0, []) - {:ok, port} = :inet.port(socket) - :ok = :gen_tcp.close(socket) - port - end - - defp wait_until(_fun, 0), do: flunk("condition not met in time") - - defp wait_until(fun, tries) do - if fun.() do - :ok - else - Process.sleep(10) - wait_until(fun, tries - 1) - end - end - - setup do - Dexcord.EnvSandbox.sandbox_env() - Application.put_env(:dexcord, :backoff, {50, 500}) - :ok - end - - test "killing the Cache restarts Cache+Dispatcher, caches the next event, and leaves the gateway alive" do - port = closed_port() - - start_supervised!( - {Dexcord, - token: @token, handler: Handler, intents: :default, gateway_url: "ws://127.0.0.1:#{port}"} - ) - - wait_until(fn -> is_pid(Process.whereis(Dexcord.Cache)) end, 200) - - cache = Process.whereis(Dexcord.Cache) - dispatcher = Process.whereis(Dexcord.Dispatcher) - gateway = Process.whereis(Dexcord.Gateway) - assert is_pid(cache) and is_pid(dispatcher) and is_pid(gateway) - - # Kill the Cache: it owns the named ETS tables, which vanish with it. - ref = Process.monitor(cache) - Process.exit(cache, :kill) - assert_receive {:DOWN, ^ref, :process, ^cache, :killed}, 1_000 - - # rest_for_one: Cache restarts, then the Dispatcher after it - both fresh pids. - wait_until( - fn -> - c = Process.whereis(Dexcord.Cache) - d = Process.whereis(Dexcord.Dispatcher) - is_pid(c) and c != cache and is_pid(d) and d != dispatcher - end, - 200 - ) - - # The next dispatch caches fine against the recreated tables - no crash. - guild = %{"id" => "g1", "name" => "Test Guild"} - Dexcord.Dispatcher.dispatch(:GUILD_CREATE, guild) - - wait_until(fn -> match?({:ok, _}, Dexcord.Cache.guild("g1")) end, 200) - assert {:ok, %{"id" => "g1", "name" => "Test Guild"}} = Dexcord.Cache.guild("g1") - - # The gateway was never restarted (still the same, still alive) - crash contained. - assert Process.whereis(Dexcord.Gateway) == gateway - assert Process.alive?(gateway) - assert Process.alive?(Process.whereis(Dexcord.Dispatcher)) - end -end diff --git a/test/dexcord/cache_integration_test.exs b/test/dexcord/cache_integration_test.exs deleted file mode 100644 index 29fb37e..0000000 --- a/test/dexcord/cache_integration_test.exs +++ /dev/null @@ -1,133 +0,0 @@ -defmodule Dexcord.CacheIntegrationTest do - @moduledoc """ - Drives real dispatch events through the full supervision tree (Session, Cache, - Dispatcher, Gateway) against the scripted `Dexcord.FakeGateway`, asserting that - the cache reflects each event AND that the user handler observes the event only - *after* the cache already reflects it (the inline single-writer guarantee). - - Also covers member chunking end to end: a GUILD_CREATE with - `request_guild_members: true` makes the client send op 8, the server replies with - a GUILD_MEMBERS_CHUNK, and the members land in the cache. - """ - use ExUnit.Case, async: false - - alias Dexcord.Cache - alias Dexcord.FakeGateway - - @token "cache.token.value" - - setup do - Dexcord.EnvSandbox.sandbox_env() - - Application.put_env(:dexcord, :backoff, {20, 200}) - Application.put_env(:dexcord, :first_heartbeat_fraction, 1.0) - Application.put_env(:dexcord, :connect_timeout_ms, 3_000) - Application.put_env(:dexcord, :hello_timeout_ms, 3_000) - - FakeGateway.CacheProbeHandler.subscribe(self()) - :ok - end - - @timeout 8_000 - - defp start_fake(opts) do - start_supervised!({FakeGateway, [test_pid: self()] ++ opts}) - end - - defp start_bot(fake, opts) do - start_supervised!( - {Dexcord, - [token: @token, handler: FakeGateway.CacheProbeHandler, gateway_url: FakeGateway.url(fake)] ++ - opts} - ) - end - - defp await_ready do - assert_receive {:handler_saw, :READY, _}, @timeout - end - - test "GUILD_CREATE populates the cache before the handler runs" do - fake = start_fake(hello_interval: 80) - start_bot(fake, intents: :all, cache_presences: true) - await_ready() - - guild = %{ - "id" => "g100", - "name" => "test", - "channels" => [%{"id" => "c100", "type" => 0}], - "roles" => [%{"id" => "r100", "name" => "everyone"}], - "members" => [%{"user" => %{"id" => "u100", "username" => "alice"}, "nick" => "a"}], - "voice_states" => [%{"user_id" => "u100", "channel_id" => "vc100"}], - "presences" => [%{"user" => %{"id" => "u100"}, "status" => "online"}] - } - - FakeGateway.push_dispatch(fake, "GUILD_CREATE", guild, 10) - - # The handler observed a cache that ALREADY had the guild (inline write ordering). - assert_receive {:handler_saw, :GUILD_CREATE, {:ok, %{"id" => "g100"}}}, @timeout - - # And every fan-out table is populated. - assert {:ok, %{"guild_id" => "g100"}} = Cache.channel("c100") - assert {:ok, %{"name" => "everyone"}} = Cache.role("g100", "r100") - assert {:ok, %{"user_id" => "u100"}} = Cache.member("g100", "u100") - assert {:ok, %{"username" => "alice"}} = Cache.user("u100") - assert {:ok, %{"channel_id" => "vc100"}} = Cache.voice_state("g100", "u100") - assert {:ok, %{"status" => "online"}} = Cache.presence("g100", "u100") - end - - test "MESSAGE_CREATE author is cached before the handler observes it" do - fake = start_fake(hello_interval: 80) - start_bot(fake, intents: :all) - await_ready() - - msg = %{"id" => "m1", "author" => %{"id" => "au1", "username" => "bob"}, "content" => "hi"} - FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", msg, 11) - - assert_receive {:handler_saw, :MESSAGE_CREATE, {:ok, %{"username" => "bob"}}}, @timeout - end - - test "chunking: GUILD_CREATE -> client sends op 8 -> CHUNK populates members" do - # request_guild_members needs the :guild_members intent bit; :all includes it. - fake = start_fake(hello_interval: 80) - start_bot(fake, intents: :all, request_guild_members: true, cache_presences: false) - await_ready() - - # A real (non-stub) GUILD_CREATE with no inline members triggers the op 8 request. - FakeGateway.push_dispatch(fake, "GUILD_CREATE", %{"id" => "g200", "name" => "big"}, 20) - - # The client asks for members (op 8) with the documented query/limit/presences. - assert_receive {:fake_gw, :frame, _c, %{"op" => 8, "d" => d}}, @timeout - assert d["guild_id"] == "g200" - assert d["query"] == "" - assert d["limit"] == 0 - assert d["presences"] == false - - # The server answers with a chunk; the members flow through dispatch into cache. - chunk = %{ - "guild_id" => "g200", - "members" => [ - %{"user" => %{"id" => "cu1", "username" => "x"}}, - %{"user" => %{"id" => "cu2", "username" => "y"}} - ] - } - - FakeGateway.push_dispatch(fake, "GUILD_MEMBERS_CHUNK", chunk, 21) - - assert_receive {:handler_saw, :GUILD_MEMBERS_CHUNK, members} when length(members) == 2, - @timeout - - assert {:ok, _} = Cache.member("g200", "cu1") - assert {:ok, _} = Cache.member("g200", "cu2") - end - - test "no op 8 is sent when request_guild_members is false" do - fake = start_fake(hello_interval: 80) - start_bot(fake, intents: :all, request_guild_members: false) - await_ready() - - FakeGateway.push_dispatch(fake, "GUILD_CREATE", %{"id" => "g300", "name" => "q"}, 30) - assert_receive {:handler_saw, :GUILD_CREATE, {:ok, _}}, @timeout - - refute_receive {:fake_gw, :frame, _c, %{"op" => 8}}, 300 - end -end diff --git a/test/dexcord/cache_test.exs b/test/dexcord/cache_test.exs deleted file mode 100644 index 705cf32..0000000 --- a/test/dexcord/cache_test.exs +++ /dev/null @@ -1,329 +0,0 @@ -defmodule Dexcord.CacheTest do - @moduledoc """ - Unit tests for `Dexcord.Cache.handle_dispatch/3` against real ETS. Each test - starts a fresh `Dexcord.Cache` (which owns and creates the tables), feeds it - string-keyed payload maps exactly as they arrive off the wire, and asserts table - contents through the public read API. - """ - use ExUnit.Case, async: false - - alias Dexcord.Cache - - @on %{cache_presences: true} - @off %{cache_presences: false} - - setup do - start_supervised!(Dexcord.Cache) - :ok - end - - defp user(id, name \\ "u"), do: %{"id" => id, "username" => "#{name}#{id}"} - - defp member(uid, extra \\ %{}), - do: Map.merge(%{"user" => user(uid), "nick" => "nick#{uid}"}, extra) - - # A full-ish guild payload as GUILD_CREATE delivers it. - defp guild_create(gid, opts \\ []) do - %{ - "id" => gid, - "name" => "guild#{gid}", - "emojis" => [%{"id" => "e1", "name" => "smile"}], - "channels" => Keyword.get(opts, :channels, [%{"id" => "c#{gid}", "type" => 0}]), - "threads" => Keyword.get(opts, :threads, []), - "roles" => Keyword.get(opts, :roles, [%{"id" => "r#{gid}", "name" => "everyone"}]), - "members" => Keyword.get(opts, :members, [member("m#{gid}")]), - "voice_states" => - Keyword.get(opts, :voice_states, [%{"user_id" => "m#{gid}", "channel_id" => "vc#{gid}"}]), - "presences" => - Keyword.get(opts, :presences, [%{"user" => %{"id" => "m#{gid}"}, "status" => "online"}]) - } - end - - describe "READY" do - test "stores the bot user and unavailable guild stubs" do - data = %{ - "user" => user("bot1", "self"), - "guilds" => [ - %{"id" => "g1", "unavailable" => true}, - %{"id" => "g2", "unavailable" => true} - ] - } - - Cache.handle_dispatch(:READY, data, @off) - - assert {:ok, %{"id" => "bot1"}} = Cache.me() - assert {:ok, %{"unavailable" => true}} = Cache.guild("g1") - assert {:ok, %{"unavailable" => true}} = Cache.guild("g2") - end - end - - describe "GUILD_CREATE" do - test "stores guild row with big arrays stripped and fans out children" do - Cache.handle_dispatch(:GUILD_CREATE, guild_create("g1"), @on) - - {:ok, g} = Cache.guild("g1") - # Big arrays stripped... - for k <- ~w(channels threads roles members presences voice_states) do - refute Map.has_key?(g, k), "expected #{k} stripped from guild row" - end - - # ...emojis stay inline. - assert [%{"name" => "smile"}] = g["emojis"] - - # Fan-out: channel carries an injected guild_id. - assert {:ok, %{"guild_id" => "g1", "type" => 0}} = Cache.channel("cg1") - assert [%{"id" => "cg1"}] = Cache.channels("g1") - - assert {:ok, %{"name" => "everyone"}} = Cache.role("g1", "rg1") - assert [%{"id" => "rg1"}] = Cache.roles("g1") - - # Member: "user" moved to users table, member keeps "user_id". - {:ok, m} = Cache.member("g1", "mg1") - assert m["user_id"] == "mg1" - refute Map.has_key?(m, "user") - assert {:ok, %{"username" => _}} = Cache.user("mg1") - - assert {:ok, %{"channel_id" => "vcg1"}} = Cache.voice_state("g1", "mg1") - assert {:ok, %{"status" => "online"}} = Cache.presence("g1", "mg1") - end - - test "presences are skipped when cache_presences is false" do - Cache.handle_dispatch(:GUILD_CREATE, guild_create("g1"), @off) - assert Cache.presence("g1", "mg1") == :error - assert Cache.presences("g1") == [] - # But members/voice_states still populate. - assert {:ok, _} = Cache.member("g1", "mg1") - assert {:ok, _} = Cache.voice_state("g1", "mg1") - end - end - - describe "GUILD_UPDATE / GUILD_EMOJIS_UPDATE" do - test "GUILD_UPDATE merges into the existing row without touching child tables" do - Cache.handle_dispatch(:GUILD_CREATE, guild_create("g1"), @on) - Cache.handle_dispatch(:GUILD_UPDATE, %{"id" => "g1", "name" => "renamed"}, @on) - - {:ok, g} = Cache.guild("g1") - assert g["name"] == "renamed" - # Members untouched by a guild update. - assert {:ok, _} = Cache.member("g1", "mg1") - end - - test "GUILD_EMOJIS_UPDATE replaces the inline emoji list" do - Cache.handle_dispatch(:GUILD_CREATE, guild_create("g1"), @on) - - Cache.handle_dispatch( - :GUILD_EMOJIS_UPDATE, - %{"guild_id" => "g1", "emojis" => [%{"id" => "e9", "name" => "wave"}]}, - @on - ) - - {:ok, g} = Cache.guild("g1") - assert [%{"id" => "e9", "name" => "wave"}] = g["emojis"] - end - end - - describe "GUILD_DELETE cascade" do - test "purge wipes exactly that guild's rows across every per-guild table, and no others" do - Cache.handle_dispatch(:GUILD_CREATE, guild_create("g1"), @on) - Cache.handle_dispatch(:GUILD_CREATE, guild_create("g2"), @on) - - Cache.handle_dispatch(:GUILD_DELETE, %{"id" => "g1"}, @on) - - # g1 gone everywhere. - assert Cache.guild("g1") == :error - assert Cache.members("g1") == [] - assert Cache.roles("g1") == [] - assert Cache.presences("g1") == [] - assert Cache.voice_states("g1") == [] - assert Cache.channels("g1") == [] - - # g2 fully intact. - assert {:ok, _} = Cache.guild("g2") - assert [_] = Cache.members("g2") - assert [_] = Cache.roles("g2") - assert [_] = Cache.presences("g2") - assert [_] = Cache.voice_states("g2") - assert [_] = Cache.channels("g2") - end - - test "unavailable:true keeps a stub instead of purging" do - Cache.handle_dispatch(:GUILD_CREATE, guild_create("g1"), @on) - Cache.handle_dispatch(:GUILD_DELETE, %{"id" => "g1", "unavailable" => true}, @on) - - assert {:ok, %{"unavailable" => true}} = Cache.guild("g1") - end - end - - describe "channels and threads" do - test "CHANNEL_CREATE/UPDATE/DELETE and THREAD_* upsert and remove" do - Cache.handle_dispatch( - :CHANNEL_CREATE, - %{"id" => "c1", "guild_id" => "g1", "name" => "gen"}, - @off - ) - - assert {:ok, %{"name" => "gen"}} = Cache.channel("c1") - - Cache.handle_dispatch( - :CHANNEL_UPDATE, - %{"id" => "c1", "guild_id" => "g1", "name" => "renamed"}, - @off - ) - - assert {:ok, %{"name" => "renamed"}} = Cache.channel("c1") - - Cache.handle_dispatch(:THREAD_CREATE, %{"id" => "t1", "guild_id" => "g1"}, @off) - assert {:ok, _} = Cache.channel("t1") - - Cache.handle_dispatch(:THREAD_DELETE, %{"id" => "t1"}, @off) - assert Cache.channel("t1") == :error - - Cache.handle_dispatch(:CHANNEL_DELETE, %{"id" => "c1"}, @off) - assert Cache.channel("c1") == :error - end - end - - describe "members" do - test "ADD/UPDATE move user out and merge; REMOVE deletes the member" do - Cache.handle_dispatch(:GUILD_MEMBER_ADD, member("u1", %{"guild_id" => "g1"}), @off) - {:ok, m} = Cache.member("g1", "u1") - assert m["user_id"] == "u1" - assert m["nick"] == "nicku1" - assert {:ok, _} = Cache.user("u1") - - # Update merges (keeps prior fields, changes nick). - Cache.handle_dispatch( - :GUILD_MEMBER_UPDATE, - %{"guild_id" => "g1", "user" => user("u1"), "nick" => "new"}, - @off - ) - - {:ok, m2} = Cache.member("g1", "u1") - assert m2["nick"] == "new" - - Cache.handle_dispatch( - :GUILD_MEMBER_REMOVE, - %{"guild_id" => "g1", "user" => user("u1")}, - @off - ) - - assert Cache.member("g1", "u1") == :error - # User survives removal from one guild. - assert {:ok, _} = Cache.user("u1") - end - - test "GUILD_MEMBERS_CHUNK bulk upserts members + users, and presences when present" do - data = %{ - "guild_id" => "g1", - "members" => [member("u1"), member("u2")], - "presences" => [%{"user" => %{"id" => "u1"}, "status" => "idle"}] - } - - Cache.handle_dispatch(:GUILD_MEMBERS_CHUNK, data, @on) - - assert length(Cache.members("g1")) == 2 - assert {:ok, _} = Cache.user("u1") - assert {:ok, _} = Cache.user("u2") - assert {:ok, %{"status" => "idle"}} = Cache.presence("g1", "u1") - end - end - - describe "roles" do - test "ROLE_CREATE/UPDATE/DELETE" do - Cache.handle_dispatch( - :GUILD_ROLE_CREATE, - %{"guild_id" => "g1", "role" => %{"id" => "r1", "name" => "mod"}}, - @off - ) - - assert {:ok, %{"name" => "mod"}} = Cache.role("g1", "r1") - - Cache.handle_dispatch( - :GUILD_ROLE_UPDATE, - %{"guild_id" => "g1", "role" => %{"id" => "r1", "name" => "admin"}}, - @off - ) - - assert {:ok, %{"name" => "admin"}} = Cache.role("g1", "r1") - - Cache.handle_dispatch(:GUILD_ROLE_DELETE, %{"guild_id" => "g1", "role_id" => "r1"}, @off) - assert Cache.role("g1", "r1") == :error - end - end - - describe "presences toggle" do - test "PRESENCE_UPDATE writes only when cache_presences is true" do - p = %{"guild_id" => "g1", "user" => %{"id" => "u1"}, "status" => "dnd"} - - Cache.handle_dispatch(:PRESENCE_UPDATE, p, @off) - assert Cache.presence("g1", "u1") == :error - - Cache.handle_dispatch(:PRESENCE_UPDATE, p, @on) - assert {:ok, %{"status" => "dnd"}} = Cache.presence("g1", "u1") - end - end - - describe "voice states" do - test "upsert on channel_id, delete when channel_id is nil" do - Cache.handle_dispatch( - :VOICE_STATE_UPDATE, - %{"guild_id" => "g1", "user_id" => "u1", "channel_id" => "vc1"}, - @off - ) - - assert {:ok, %{"channel_id" => "vc1"}} = Cache.voice_state("g1", "u1") - - Cache.handle_dispatch( - :VOICE_STATE_UPDATE, - %{"guild_id" => "g1", "user_id" => "u1", "channel_id" => nil}, - @off - ) - - assert Cache.voice_state("g1", "u1") == :error - end - end - - describe "USER_UPDATE and MESSAGE_CREATE" do - test "USER_UPDATE merges into me" do - Cache.handle_dispatch(:READY, %{"user" => user("bot1", "self"), "guilds" => []}, @off) - Cache.handle_dispatch(:USER_UPDATE, %{"id" => "bot1", "username" => "renamed"}, @off) - assert {:ok, %{"username" => "renamed"}} = Cache.me() - end - - test "MESSAGE_CREATE opportunistically upserts author and member fragment" do - data = %{ - "guild_id" => "g1", - "author" => user("a1"), - "member" => %{"nick" => "frag", "roles" => ["r1"]} - } - - Cache.handle_dispatch(:MESSAGE_CREATE, data, @off) - - assert {:ok, _} = Cache.user("a1") - {:ok, m} = Cache.member("g1", "a1") - assert m["user_id"] == "a1" - assert m["nick"] == "frag" - end - - test "MESSAGE_CREATE ignores webhook authors" do - data = %{"webhook_id" => "wh1", "author" => %{"id" => "wh1", "username" => "hook"}} - Cache.handle_dispatch(:MESSAGE_CREATE, data, @off) - assert Cache.user("wh1") == :error - end - end - - describe "read API shapes" do - test "bang variants return the value or raise" do - Cache.handle_dispatch(:GUILD_CREATE, guild_create("g1"), @on) - assert %{"id" => "g1"} = Cache.guild!("g1") - assert_raise RuntimeError, fn -> Cache.guild!("nope") end - assert_raise RuntimeError, fn -> Cache.member!("g1", "nope") end - end - - test "listings return plain lists (empty, never :error)" do - assert Cache.guilds() == [] - assert Cache.channels("g1") == [] - assert Cache.members("g1") == [] - end - end -end diff --git a/test/dexcord/config_validation_test.exs b/test/dexcord/config_validation_test.exs deleted file mode 100644 index a591a36..0000000 --- a/test/dexcord/config_validation_test.exs +++ /dev/null @@ -1,78 +0,0 @@ -defmodule Dexcord.ConfigValidationTest do - use ExUnit.Case, async: true - - @base [token: "t", handler: MyBot.Handler] - - defp validate(extra), do: Dexcord.validate(@base ++ extra) - - describe "intents" do - test "a tuple raises a friendly ArgumentError, not a FunctionClauseError" do - assert_raise ArgumentError, ~r/:intents must be :all, :default/, fn -> - validate(intents: {:guilds}) - end - end - - test "a negative integer raises a friendly ArgumentError" do - assert_raise ArgumentError, ~r/:intents must be/, fn -> validate(intents: -1) end - end - - test "an unknown intent atom in a list keeps the specific message" do - assert_raise ArgumentError, ~r/unknown intent/, fn -> - validate(intents: [:guilds, :not_a_real_intent]) - end - end - - test "valid specs resolve" do - assert %{intents: bits} = validate(intents: :all) - assert is_integer(bits) and bits > 0 - assert %{intents: 0} = validate(intents: 0) - end - end - - describe "gateway_url" do - test "a non-ws/http url raises" do - assert_raise ArgumentError, ~r/:gateway_url must start with/, fn -> - validate(gateway_url: "gopher://x") - end - end - - test "a non-binary raises instead of being silently ignored" do - assert_raise ArgumentError, ~r/:gateway_url must be a URL string/, fn -> - validate(gateway_url: 123) - end - end - - test "accepts ws:// wss:// http:// https:// and nil" do - for url <- ["ws://x", "wss://x", "http://x", "https://x"] do - assert %{gateway_url: ^url} = validate(gateway_url: url) - end - - assert %{gateway_url: nil} = validate([]) - end - end - - describe "slash + slash_guild_ids" do - test "slash must be a module" do - assert_raise ArgumentError, ~r/:slash must be a module/, fn -> validate(slash: "MyBot") end - assert_raise ArgumentError, ~r/:slash must be a module/, fn -> validate(slash: 123) end - end - - test "slash accepts a module or nil" do - assert %{slash: MyBot.Slash} = validate(slash: MyBot.Slash) - assert %{slash: nil} = validate([]) - end - - test "slash_guild_ids must be a list of binaries/integers" do - assert_raise ArgumentError, ~r/:slash_guild_ids/, fn -> validate(slash_guild_ids: "123") end - - assert_raise ArgumentError, ~r/:slash_guild_ids/, fn -> - validate(slash_guild_ids: [%{}]) - end - end - - test "slash_guild_ids accepts strings, integers, and nil" do - assert %{slash_guild_ids: ["1", 2]} = validate(slash_guild_ids: ["1", 2]) - assert %{slash_guild_ids: nil} = validate([]) - end - end -end diff --git a/test/dexcord/ergonomics_integration_test.exs b/test/dexcord/ergonomics_integration_test.exs deleted file mode 100644 index 6b17e59..0000000 --- a/test/dexcord/ergonomics_integration_test.exs +++ /dev/null @@ -1,138 +0,0 @@ -defmodule Dexcord.ErgTest do - @moduledoc false - # Test sink: relay messages from the handler / router / slash module back to the - # controlling test process (whose pid lives in persistent_term). - @key {__MODULE__, :sink} - def set_sink(pid), do: :persistent_term.put(@key, pid) - - def notify(msg) do - case :persistent_term.get(@key, nil) do - nil -> :ok - pid -> send(pid, msg) - end - end -end - -defmodule Dexcord.ErgTest.Router do - @moduledoc false - use Dexcord.Prefix.Router - def handle_command(cmd, args, msg), do: Dexcord.ErgTest.notify({:command, cmd, args, msg}) -end - -defmodule Dexcord.ErgTest.Slash do - @moduledoc false - use Dexcord.Slash - def commands, do: [%{name: "ping", description: "Pong!"}] - def handle_interaction(name, itx), do: Dexcord.ErgTest.notify({:slash, name, itx}) -end - -defmodule Dexcord.ErgTest.Handler do - @moduledoc false - # Not `use Dexcord.Handler`: we want an explicit catch-all 2-tuple clause that - # relays every other event, which would collide with the injected catch-all. - @behaviour Dexcord.Handler - - def handle_event({:MESSAGE_CREATE, msg}), - do: Dexcord.Prefix.dispatch(msg, prefix: "!", to: Dexcord.ErgTest.Router) - - def handle_event({name, data}), do: Dexcord.ErgTest.notify({:raw, name, data}) -end - -defmodule Dexcord.ErgonomicsIntegrationTest do - @moduledoc """ - End-to-end ergonomics: prefix commands, slash auto-routing, and `update_presence` - driven through the real gateway (`Dexcord.FakeGateway`) with slash registration - served by `Dexcord.FakeRest`. - """ - use ExUnit.Case, async: false - - alias Dexcord.FakeGateway - alias Dexcord.FakeRest - - @token "test.token.value" - @timeout 8_000 - - setup do - Dexcord.EnvSandbox.sandbox_env() - - Application.put_env(:dexcord, :backoff, {20, 200}) - Application.put_env(:dexcord, :first_heartbeat_fraction, 1.0) - Application.put_env(:dexcord, :identify_gap_ms, 50) - Application.put_env(:dexcord, :connect_timeout_ms, 2_500) - Application.put_env(:dexcord, :hello_timeout_ms, 2_500) - Application.put_env(:dexcord, :tcp_connect_timeout_ms, 2_500) - - Dexcord.ErgTest.set_sink(self()) - - # Registrar (started because slash: is set) needs a REST server to answer. - start_supervised!(FakeRest) - Application.put_env(:dexcord, :api_base_url, FakeRest.base_url()) - - FakeRest.stub( - :get, - "/oauth2/applications/@me", - FakeRest.resp(200, body: ~s({"id":"app-erg"})) - ) - - FakeRest.stub(:put, "/applications/app-erg/commands", FakeRest.resp(200, body: "[]")) - - fake = start_supervised!({FakeGateway, test_pid: self(), hello_interval: 80}) - :ok = start_bot(fake) - - # Synchronise on READY so the gateway is :connected before we push anything. - assert_receive {:raw, :READY, _}, @timeout - {:ok, fake: fake} - end - - defp start_bot(fake) do - start_supervised!( - {Dexcord, - token: @token, - handler: Dexcord.ErgTest.Handler, - intents: :all, - slash: Dexcord.ErgTest.Slash, - gateway_url: FakeGateway.url(fake)} - ) - - :ok - end - - test "prefix command end-to-end: MESSAGE_CREATE reaches the router; bot authors are skipped", - %{fake: fake} do - user_msg = %{"content" => "!ping a b", "author" => %{"id" => "u1", "bot" => false}} - FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", user_msg, 10) - - assert_receive {:command, "ping", ["a", "b"], ^user_msg}, @timeout - - bot_msg = %{"content" => "!ping", "author" => %{"id" => "b1", "bot" => true}} - FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", bot_msg, 11) - - refute_receive {:command, _, _, _}, 300 - end - - test "INTERACTION_CREATE (type 2) auto-routes to the slash module AND reaches the raw handler", - %{fake: fake} do - itx = %{"id" => "i2", "type" => 2, "token" => "tok", "data" => %{"name" => "ping"}} - FakeGateway.push_dispatch(fake, "INTERACTION_CREATE", itx, 20) - - assert_receive {:slash, "ping", ^itx}, @timeout - assert_receive {:raw, :INTERACTION_CREATE, ^itx}, @timeout - end - - test "an autocomplete interaction (type 4) reaches the raw handler but is not auto-routed", - %{fake: fake} do - itx = %{"id" => "i4", "type" => 4, "data" => %{"name" => "ping"}} - FakeGateway.push_dispatch(fake, "INTERACTION_CREATE", itx, 21) - - assert_receive {:raw, :INTERACTION_CREATE, ^itx}, @timeout - refute_receive {:slash, _, _}, 300 - end - - test "update_presence/1 flows through the send budget to the gateway", %{fake: _fake} do - presence = %{"status" => "dnd", "activities" => [%{"name" => "with fire", "type" => 0}]} - assert :ok = Dexcord.update_presence(presence) - - assert_receive {:fake_gw, :frame, _conn, %{"op" => 3} = frame}, @timeout - assert frame["d"] == presence - end -end diff --git a/test/dexcord/gateway_backoff_test.exs b/test/dexcord/gateway_backoff_test.exs index 7cdf698..ebb1f81 100644 --- a/test/dexcord/gateway_backoff_test.exs +++ b/test/dexcord/gateway_backoff_test.exs @@ -5,7 +5,14 @@ defmodule Dexcord.GatewayBackoffTest do alias Dexcord.Gateway setup do - Dexcord.EnvSandbox.sandbox_env() + prev = Application.get_env(:dexcord, :backoff) + + on_exit(fn -> + if prev == nil, + do: Application.delete_env(:dexcord, :backoff), + else: Application.put_env(:dexcord, :backoff, prev) + end) + :ok end diff --git a/test/dexcord/gateway_fatal_test.exs b/test/dexcord/gateway_fatal_test.exs index c3235a2..186fe7f 100644 --- a/test/dexcord/gateway_fatal_test.exs +++ b/test/dexcord/gateway_fatal_test.exs @@ -12,13 +12,24 @@ defmodule Dexcord.GatewayFatalTest do alias Dexcord.FakeGateway setup do - Dexcord.EnvSandbox.sandbox_env() + prev = + for key <- [:backoff, :connect_timeout_ms, :hello_timeout_ms, :tcp_connect_timeout_ms] do + {key, Application.get_env(:dexcord, key)} + end Application.put_env(:dexcord, :backoff, {10, 50}) Application.put_env(:dexcord, :connect_timeout_ms, 2_000) Application.put_env(:dexcord, :hello_timeout_ms, 2_000) Application.put_env(:dexcord, :tcp_connect_timeout_ms, 2_000) + on_exit(fn -> + for {key, val} <- prev do + if val == nil, + do: Application.delete_env(:dexcord, key), + else: Application.put_env(:dexcord, key, val) + end + end) + FakeGateway.TestHandler.subscribe(self()) :ok end @@ -42,24 +53,7 @@ defmodule Dexcord.GatewayFatalTest do Process.flag(:trap_exit, true) {:ok, sup} = Dexcord.Supervisor.start_link(config) - # AWAIT full teardown: this supervisor owns the globally-named singletons - # (Session/Cache/Ratelimit/Finch/Dispatcher/Gateway) and their `:named_table` - # ETS. If it is still terminating when the next test's tree starts, that test - # hits already-registered names / ETS-name clashes. Monitor and block until the - # supervisor (and therefore its children + tables) is truly gone. - on_exit(fn -> - if Process.alive?(sup) do - ref = Process.monitor(sup) - Process.exit(sup, :kill) - - receive do - {:DOWN, ^ref, :process, ^sup, _reason} -> :ok - after - 5_000 -> :ok - end - end - end) - + on_exit(fn -> if Process.alive?(sup), do: Process.exit(sup, :kill) end) {fake, sup} end diff --git a/test/dexcord/gateway_integration_test.exs b/test/dexcord/gateway_integration_test.exs index 04b8cdb..cc475ce 100644 --- a/test/dexcord/gateway_integration_test.exs +++ b/test/dexcord/gateway_integration_test.exs @@ -10,16 +10,26 @@ defmodule Dexcord.GatewayIntegrationTest do use ExUnit.Case, async: false alias Dexcord.FakeGateway - alias Dexcord.Gateway alias Dexcord.Intents alias Dexcord.Session @token "test.token.value" setup do - # Deterministic, fast timing knobs. The env sandbox restores everything (whatever - # keys these tests set) wholesale after each test. - Dexcord.EnvSandbox.sandbox_env() + # Deterministic, fast timing knobs. Restored after each test. + prev = + for key <- [ + :backoff, + :invalid_session_delay_ms, + :identify_gap_ms, + :first_heartbeat_fraction, + :max_resume_attempts, + :connect_timeout_ms, + :hello_timeout_ms, + :tcp_connect_timeout_ms + ] do + {key, Application.get_env(:dexcord, key)} + end Application.put_env(:dexcord, :backoff, {20, 200}) Application.put_env(:dexcord, :first_heartbeat_fraction, 1.0) @@ -34,6 +44,14 @@ defmodule Dexcord.GatewayIntegrationTest do Application.put_env(:dexcord, :hello_timeout_ms, 2_500) Application.put_env(:dexcord, :tcp_connect_timeout_ms, 2_500) + on_exit(fn -> + for {key, val} <- prev do + if val == nil, + do: Application.delete_env(:dexcord, key), + else: Application.put_env(:dexcord, key, val) + end + end) + FakeGateway.TestHandler.subscribe(self()) :ok end @@ -195,107 +213,6 @@ defmodule Dexcord.GatewayIntegrationTest do assert_receive {:fake_gw, :frame, _c, %{"op" => 1}}, 500 end - # --- REVIEW FIX 1: op-0 frame with t:null must not crash the statem ------ - - # Verifies review finding #1. An op-0 dispatch whose `t` is JSON null used to - # crash the gen_statem: bump_and_dispatch called EventNames.to_atom(nil), whose - # only clause is is_binary-guarded, raising FunctionClauseError and killing the - # process. The op-0 handling is now tolerant of a non-binary `t` (logs debug, - # still bumps seq, skips dispatch), so the statem survives and stays connected. - test "FIX: an op-0 frame with t:null is tolerated - statem survives and stays connected" do - fake = start_fake(hello_interval: 80, session_id: "sess-tnull") - start_bot(fake) - - assert_frame(2) - assert_receive {:handler_event, {:READY, _}}, @event_timeout - baseline = FakeGateway.conn_count(fake) - - # Malformed dispatch: op 0, "t": null, but carrying a sequence number. - FakeGateway.push_dispatch(fake, nil, %{"garbage" => true}, 5) - # Seq is still bumped from the (garbage) frame even though nothing is dispatched. - wait_until(fn -> Session.last_seq() == 5 end) - assert Session.last_seq() == 5 - - # The statem survived: a following real dispatch still reaches the user handler. - FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "alive"}, 6) - assert_receive {:handler_event, {:MESSAGE_CREATE, %{"content" => "alive"}}}, @event_timeout - - # And it never crashed/reconnected: heartbeats keep flowing on the same connection. - assert_receive {:fake_gw, :frame, _c, %{"op" => 1}}, @event_timeout - assert FakeGateway.conn_count(fake) == baseline, "statem crashed and reconnected" - end - - # --- REVIEW FIX 2b: abandoned-session frames must not repopulate last_seq - - - # Verifies review finding #2. After op 9 d:false clears the session (last_seq -> nil) - # the client waits out the invalid-session delay before IDENTIFYing on the same - # socket. A dispatch trickling in during that wait belongs to the ABANDONED session; - # it must be delivered to the handler but must NOT bump last_seq, or the foreign - # sequence would cross into the fresh session about to be established. - test "FIX: a dispatch during the op 9 d:false reidentify wait does not repopulate last_seq" do - # Large invalid-session wait so the reidentify window comfortably spans the - # trickle+assert (no READY can arrive to reseed last_seq before we check). - Application.put_env(:dexcord, :invalid_session_delay_ms, {800, 1_200}) - Application.put_env(:dexcord, :identify_gap_ms, 10) - disable_handshake_retries() - - fake = - start_fake(hello_interval: 100, session_id: "sess-seq", on_resume: {:invalid, false}) - - start_bot(fake) - - assert_frame(2) - assert_receive {:handler_event, {:READY, _}}, @event_timeout - - # Advance the live session's seq. - FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "x"}, 42) - assert_receive {:handler_event, {:MESSAGE_CREATE, %{"content" => "x"}}}, @event_timeout - wait_until(fn -> Session.last_seq() == 42 end) - - # Force a resume; the server rejects it with op 9 d:false, clearing the session. - FakeGateway.push_op7(fake) - {_rconn, 6, _} = assert_reconnect_frame() - wait_until(fn -> Session.last_seq() == nil end) - - # Trickle a dispatch from the abandoned session during the reidentify wait. - FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "noise"}, 9_999) - assert_receive {:handler_event, {:MESSAGE_CREATE, %{"content" => "noise"}}}, @event_timeout - - # It was delivered, but its foreign seq must not have repopulated last_seq. - assert Session.last_seq() == nil, "abandoned-session frame repopulated last_seq" - end - - # --- REVIEW FIX 4: op-8/op-3 casts outside :connected are queued, not dropped - - - # Verifies review finding #4. A request_guild_members (op 8) issued while the client - # is parked in :resuming (e.g. reacting to GUILD_CREATEs mid-replay) used to be - # dropped by the cast catch-all. It is now enqueued and drains once :connected is - # re-entered, so the member-chunk request is not lost. - test "FIX: request_guild_members cast during :resuming is queued and sent after RESUMED" do - # :manual resume keeps the client parked in :resuming until we push RESUMED. - fake = start_fake(hello_interval: 100, session_id: "sess-q8", on_resume: :manual) - start_bot(fake, intents: :all) - - assert_frame(2) - assert_receive {:handler_event, {:READY, _}}, @event_timeout - - # Force a resume; the client sends op 6 and (manual) stays in :resuming. - FakeGateway.push_op7(fake) - {_rconn, 6, _} = assert_reconnect_frame() - - # Cast an op-8 while parked in :resuming: it must be queued, not dropped. - Gateway.request_guild_members("123", limit: 0) - - # Nothing goes out while still resuming... - refute_receive {:fake_gw, :frame, _c, %{"op" => 8}}, 200 - - # ...then RESUMED promotes us to :connected and the queue drains. - FakeGateway.push_resumed(fake) - - assert_receive {:fake_gw, :frame, _c, %{"op" => 8, "d" => %{"guild_id" => "123"}}}, - @event_timeout - end - # --- zombie detection --------------------------------------------------- test "zombie: dropping ACKs closes ~2x interval later and RESUMEs with correct session/seq" do diff --git a/test/dexcord/gateway_send_budget_test.exs b/test/dexcord/gateway_send_budget_test.exs deleted file mode 100644 index afd4eac..0000000 --- a/test/dexcord/gateway_send_budget_test.exs +++ /dev/null @@ -1,152 +0,0 @@ -defmodule Dexcord.GatewaySendBudgetTest do - @moduledoc """ - Exercises the gateway send-budget token bucket with shrunk knobs: op-3 presence - updates and op-8 member requests consume tokens and queue when the budget is - exhausted, draining as tokens refill, while heartbeats bypass the bucket - entirely. The queue is bounded and over-bound frames are dropped. - """ - use ExUnit.Case, async: false - - alias Dexcord.FakeGateway - alias Dexcord.Gateway - - @token "budget.token.value" - @timeout 8_000 - - setup do - Dexcord.EnvSandbox.sandbox_env() - - Application.put_env(:dexcord, :backoff, {20, 200}) - Application.put_env(:dexcord, :first_heartbeat_fraction, 1.0) - Application.put_env(:dexcord, :connect_timeout_ms, 3_000) - Application.put_env(:dexcord, :hello_timeout_ms, 3_000) - - FakeGateway.TestHandler.subscribe(self()) - :ok - end - - defp start_bot(fake) do - start_supervised!( - {Dexcord, - token: @token, - handler: FakeGateway.TestHandler, - intents: :all, - gateway_url: FakeGateway.url(fake)} - ) - end - - # Drain op-3 frames from the mailbox, returning the count once quiet for `quiet` - # ms. Leaves non-op-3 frames (heartbeats) in the mailbox. - defp count_op3(quiet), do: count_op3(0, quiet) - - defp count_op3(n, quiet) do - receive do - {:fake_gw, :frame, _c, %{"op" => 3}} -> count_op3(n + 1, quiet) - after - quiet -> n - end - end - - test "presence updates beyond the budget queue and drain as tokens refill" do - # 2 tokens per 250ms window -> 1 token every 125ms. Generous queue so nothing - # drops: all five sends should eventually be delivered. - Application.put_env(:dexcord, :send_budget, {2, 250}) - Application.put_env(:dexcord, :send_queue_max, 128) - - fake = start_supervised!({FakeGateway, test_pid: self(), hello_interval: 60}) - start_bot(fake) - - assert_receive {:handler_event, {:READY, _}}, @timeout - - for i <- 1..5, do: Gateway.update_presence(%{"status" => "s#{i}"}) - - # First two go out immediately (tokens), the rest are queued and drained on - # refill ticks. All five arrive. - assert count_op3(1_200) == 5 - - # Heartbeats bypass the budget entirely and keep flowing even while op-3 was - # queued/throttled. - assert_receive {:fake_gw, :frame, _c, %{"op" => 1}}, @timeout - end - - # Drain op-3 status strings from the mailbox in arrival order until quiet. - defp op3_statuses(quiet), do: op3_statuses([], quiet) |> Enum.reverse() - - defp op3_statuses(acc, quiet) do - receive do - {:fake_gw, :frame, _c, %{"op" => 3, "d" => %{"status" => s}}} -> - op3_statuses([s | acc], quiet) - after - quiet -> acc - end - end - - # Verifies review finding #3: queue-first FIFO. When frames are already queued and a - # token later becomes available, a newly-cast frame must fall in BEHIND the queued - # ones, not jump ahead of them (Discord is last-write-wins, so reordering an op-3 - # would let a stale presence overwrite a newer one). - # - # Determinism trick: arm the refill tick with a LONG window (so it won't fire during - # the test), then shrink the window via app-env so `refill/1` - which reads config - # live - credits a full token quickly. That produces the exact state the bug needs - # (queue non-empty AND a token available) with no pending drain tick to race. - test "queue-first FIFO: a frame cast after a refill does not jump earlier queued frames" do - # One token; huge window means the enqueue-armed refill tick lands ~5s out and - # never fires during the test. - Application.put_env(:dexcord, :send_budget, {1, 5_000}) - Application.put_env(:dexcord, :send_queue_max, 128) - - fake = start_supervised!({FakeGateway, test_pid: self(), hello_interval: 60}) - start_bot(fake) - - assert_receive {:handler_event, {:READY, _}}, @timeout - - # Spend the single initial token on a warmup send (goes out immediately). - Gateway.update_presence(%{"status" => "warmup"}) - assert_receive {:fake_gw, :frame, _c, %{"op" => 3, "d" => %{"status" => "warmup"}}}, @timeout - - # Budget now empty; A and B queue (refill tick armed ~5s out with the huge window). - Gateway.update_presence(%{"status" => "A"}) - Gateway.update_presence(%{"status" => "B"}) - - # Synchronize: casts are async, so block until the statem has actually PROCESSED - # both (a gen_statem sync call is FIFO-ordered behind the casts). This pins - # send_last_refill at B's processing time, so the sleep below genuinely elapses - # between B and C being handled - without it the refill window is a race and the - # test is nondeterministic. Also confirms both frames really queued. - {_state, data} = :sys.get_state(Dexcord.Gateway) - assert :queue.len(data.send_queue) == 2, "A and B should be queued (budget empty)" - - # Shrink the window so a token accrues fast; the already-armed ~5s tick is stale. - Application.put_env(:dexcord, :send_budget, {1, 100}) - # Past one shrunk window: refill/1 will now credit >= 1 token on C's cast. - Process.sleep(150) - - # Cast C. A token is available AND the queue is non-empty - the exact case the - # bug mishandled. With the fix C queues behind A,B and the drain emits A,B,C; with - # the bug C jumps the queue (sent immediately) and A,B are left stranded. - Gateway.update_presence(%{"status" => "C"}) - - assert op3_statuses(1_000) == ["A", "B", "C"] - end - - test "queue bound drops excess frames while heartbeats still bypass" do - # 1 token per 400ms, queue holds at most 1: of five rapid sends, 1 goes out on - # the initial token, 1 is queued, the other 3 are dropped. The queued one drains - # after ~400ms -> exactly 2 op-3 frames ever delivered. - Application.put_env(:dexcord, :send_budget, {1, 400}) - Application.put_env(:dexcord, :send_queue_max, 1) - - fake = start_supervised!({FakeGateway, test_pid: self(), hello_interval: 60}) - start_bot(fake) - - assert_receive {:handler_event, {:READY, _}}, @timeout - - for i <- 1..5, do: Gateway.update_presence(%{"status" => "s#{i}"}) - - assert count_op3(1_000) == 2 - - # And heartbeats never stopped despite the op-3 budget being pinned at empty. - assert_receive {:fake_gw, :frame, _c, %{"op" => 1}}, @timeout - end -end diff --git a/test/dexcord/prefix_test.exs b/test/dexcord/prefix_test.exs deleted file mode 100644 index 80f2104..0000000 --- a/test/dexcord/prefix_test.exs +++ /dev/null @@ -1,106 +0,0 @@ -defmodule Dexcord.PrefixTest do - use ExUnit.Case, async: true - - alias Dexcord.Prefix - - doctest Dexcord.Prefix - - describe "parse/2" do - test "matches a bare command with no args" do - assert Prefix.parse("!ping", "!") == {:ok, "ping", "", []} - end - - test "splits multi-word args" do - assert Prefix.parse("!echo hello world", "!") == - {:ok, "echo", "hello world", ["hello", "world"]} - end - - test "collapses runs of whitespace in the args list but preserves them in arg_string" do - assert Prefix.parse("!echo a b ", "!") == - {:ok, "echo", "a b", ["a", "b"]} - end - - test "tolerates whitespace between the prefix and the command" do - assert Prefix.parse("! ping arg", "!") == - {:ok, "ping", "arg", ["arg"]} - end - - test "returns :nomatch when the prefix is absent" do - assert Prefix.parse("ping", "!") == :nomatch - assert Prefix.parse("?ping", "!") == :nomatch - end - - test "returns :nomatch for a prefix with nothing after it" do - assert Prefix.parse("!", "!") == :nomatch - assert Prefix.parse("! ", "!") == :nomatch - end - - test "supports multi-character prefixes" do - assert Prefix.parse(">>roll 2d6", ">>") == {:ok, "roll", "2d6", ["2d6"]} - end - - test "handles unicode commands and args" do - assert Prefix.parse("!café ☕ latte", "!") == - {:ok, "café", "☕ latte", ["☕", "latte"]} - end - - test "handles a unicode prefix" do - assert Prefix.parse("✨spell fireball", "✨") == - {:ok, "spell", "fireball", ["fireball"]} - end - - test "returns :nomatch for non-binary inputs or an empty prefix" do - assert Prefix.parse(nil, "!") == :nomatch - assert Prefix.parse("!ping", "") == :nomatch - assert Prefix.parse(123, "!") == :nomatch - end - end - - describe "dispatch/2 + Router" do - defmodule Router do - use Dexcord.Prefix.Router - - def handle_command("ping", args, msg) do - send(self(), {:routed, "ping", args, msg}) - :handled - end - end - - setup do - # dispatch/2's bot-author self-check reads Dexcord.Cache.me/0, so the cache - # ETS tables must exist. - start_supervised!(Dexcord.Cache) - :ok - end - - test "skips the bot's own messages via the cached self id, even without a bot flag" do - Dexcord.Cache.handle_dispatch(:READY, %{"user" => %{"id" => "me-id"}}, %{}) - msg = %{"content" => "!ping", "author" => %{"id" => "me-id"}} - assert Prefix.dispatch(msg, prefix: "!", to: Router) == :ignore - refute_received {:routed, _, _, _} - end - - test "routes a matching command to the router" do - msg = %{"content" => "!ping a b", "author" => %{"id" => "7", "bot" => false}} - assert Prefix.dispatch(msg, prefix: "!", to: Router) == :handled - assert_received {:routed, "ping", ["a", "b"], ^msg} - end - - test "the injected catch-all returns :ignore for unknown commands" do - msg = %{"content" => "!unknown", "author" => %{"id" => "7"}} - assert Prefix.dispatch(msg, prefix: "!", to: Router) == :ignore - end - - test "returns :ignore on a non-match without touching the router" do - msg = %{"content" => "no prefix", "author" => %{"id" => "7"}} - assert Prefix.dispatch(msg, prefix: "!", to: Router) == :ignore - refute_received {:routed, _, _, _} - end - - test "skips messages whose author is a bot" do - msg = %{"content" => "!ping", "author" => %{"id" => "7", "bot" => true}} - assert Prefix.dispatch(msg, prefix: "!", to: Router) == :ignore - refute_received {:routed, _, _, _} - end - end -end diff --git a/test/dexcord/ratelimit_test.exs b/test/dexcord/ratelimit_test.exs index b1fb053..0853131 100644 --- a/test/dexcord/ratelimit_test.exs +++ b/test/dexcord/ratelimit_test.exs @@ -43,50 +43,16 @@ defmodule Dexcord.Api.RatelimitTest do "PUT /channels/1/messages/:id/reactions/:id/@me" end - test "webhook id stays literal; the secret token becomes a stable digest" do - digest = - :crypto.hash(:sha256, "abctoken") - |> Base.url_encode64(padding: false) - |> binary_part(0, 12) + test "webhook id + token are both major (literal)" do + assert Ratelimit.route_key(:post, "/webhooks/111/abctoken") == + "POST /webhooks/111/abctoken" - post = Ratelimit.route_key(:post, "/webhooks/111/abctoken") - patch = Ratelimit.route_key(:patch, "/webhooks/111/abctoken/messages/@original") + assert Ratelimit.route_key(:patch, "/webhooks/111/abctoken/messages/@original") == + "PATCH /webhooks/111/abctoken/messages/@original" - assert post == "POST /webhooks/111/#{digest}" - assert patch == "PATCH /webhooks/111/#{digest}/messages/@original" - - # The raw secret must never appear literally in a public route key. - refute post =~ "abctoken" - refute patch =~ "abctoken" - - # interaction id is minor (collapses to :id), but the secret token becomes a - # per-interaction digest so one interaction's 429 can't stall the rest. - tok_digest = - :crypto.hash(:sha256, "tok") - |> Base.url_encode64(padding: false) - |> binary_part(0, 12) - - interaction = Ratelimit.route_key(:post, "/interactions/222/tok/callback") - assert interaction == "POST /interactions/:id/#{tok_digest}/callback" - # The raw secret token must never appear literally in the route key. - refute interaction =~ "tok" - end - - test "different interaction tokens get different (digest-separated) route keys" do - a = Ratelimit.route_key(:post, "/interactions/1/token-aaa/callback") - b = Ratelimit.route_key(:post, "/interactions/1/token-bbb/callback") - - # Per-interaction separation: distinct tokens => distinct buckets, so a - # stalled ack on one interaction doesn't gate every other interaction. - refute a == b - refute a =~ "token-aaa" - refute b =~ "token-bbb" - - # The interaction id still collapses; only the token digest distinguishes. - assert String.starts_with?(a, "POST /interactions/:id/") - assert String.ends_with?(a, "/callback") - assert String.starts_with?(b, "POST /interactions/:id/") - assert String.ends_with?(b, "/callback") + # interaction id is minor; the token stays literal. + assert Ratelimit.route_key(:post, "/interactions/222/tok/callback") == + "POST /interactions/:id/tok/callback" end test "query strings are ignored" do @@ -97,10 +63,10 @@ defmodule Dexcord.Api.RatelimitTest do describe "bucket + global math (injected clock)" do setup do - Dexcord.EnvSandbox.sandbox_env() {:ok, clock} = Agent.start_link(fn -> 0 end) Application.put_env(:dexcord, :ratelimit_now_fn, fn -> Agent.get(clock, & &1) end) start_supervised!(Ratelimit) + on_exit(fn -> Application.delete_env(:dexcord, :ratelimit_now_fn) end) %{clock: clock} end @@ -169,150 +135,5 @@ defmodule Dexcord.Api.RatelimitTest do assert {:wait, ms2} = Ratelimit.acquire(route) assert ms2 in 100..102 end - - test "a reset window admits only ~limit callers, not a thundering herd", - %{clock: clock} do - set_clock(clock, 0) - route = "POST /herd" - # limit is 10 (from headers/3); the window resets at t=5000. - :ok = Ratelimit.update(route, headers("hb", 0, "5.0")) - - set_clock(clock, 5_000) - results = for _ <- 1..15, do: Ratelimit.acquire(route) - - # The reset consumes one token and hands out limit-1 more before waits - # begin: exactly `limit` (10) callers pass, then the rest must wait. - assert Enum.count(results, &(&1 == :ok)) == 10 - assert {:wait, _} = List.last(results) - end - - test "the idle-TTL sweep evicts stale buckets and their route mappings", - %{clock: clock} do - Application.put_env(:dexcord, :ratelimit_idle_ttl_ms, 1_000) - - set_clock(clock, 0) - route = "GET /stale" - # reset_at = 0 + 2000 = 2000. - :ok = Ratelimit.update(route, headers("sb", 5, "2.0")) - - # Not yet idle past the TTL (now 2500, cutoff 1500 < reset_at 2000): survives. - set_clock(clock, 2_500) - :ok = Ratelimit.sweep() - assert [_] = :ets.lookup(Ratelimit.table(), {:bucket, "sb"}) - assert [_] = :ets.lookup(Ratelimit.table(), {:route_bucket, route}) - - # Idle past reset + TTL (now 3001, cutoff 2001 > reset_at 2000): evicted, - # and the dangling route→bucket mapping goes with it. - set_clock(clock, 3_001) - :ok = Ratelimit.sweep() - assert [] = :ets.lookup(Ratelimit.table(), {:bucket, "sb"}) - assert [] = :ets.lookup(Ratelimit.table(), {:route_bucket, route}) - end - - test "a probe caller's death releases parked waiters instead of wedging the route" do - route = "POST /probe-death" - test = self() - - # First caller is elected the probe and holds it open (never settles). - probe = - spawn(fn -> - send(test, {:probe, Ratelimit.acquire(route)}) - Process.sleep(:infinity) - end) - - assert_receive {:probe, :ok} - - # A second caller parks behind the in-flight probe (blocks in acquire). - spawn(fn -> send(test, {:waiter, Ratelimit.acquire(route)}) end) - # Let it register as a parked waiter before we kill the probe. - Process.sleep(20) - refute_received {:waiter, _} - - # Killing the probe must release the waiter (via :DOWN) to re-probe and - # complete - not leave it blocked in a GenServer.call forever. - Process.exit(probe, :kill) - assert_receive {:waiter, :ok}, 1_000 - end - - test "a still-alive caller that times out abandons its probe instead of wedging" do - route = "POST /abandon" - test = self() - - # probe_timeout 0 (a spent deadline): the GenServer.call deterministically - # times out on a non-yielding `after 0` while the server still elects this - # caller the probe. The caller stays ALIVE, so :DOWN can never rescue the - # route - only the explicit abandon_probe cast can. - probe = - spawn(fn -> - send(test, {:probe, Ratelimit.acquire(route, 0)}) - Process.sleep(:infinity) - end) - - assert_receive {:probe, {:error, :timeout}} - - # A fresh caller on the same route must acquire promptly (route not wedged). - spawn(fn -> send(test, {:next, Ratelimit.acquire(route)}) end) - assert_receive {:next, :ok}, 500 - - assert Process.alive?(probe), "the probe abandoned while still alive; :DOWN must not be why" - Process.exit(probe, :kill) - end - - test "a stale abandon for a different pid does not clobber the current probe" do - route = "POST /no-clobber" - test = self() - - # A healthy probe holds the route open (never settles yet). - probe = - spawn(fn -> - send(test, {:probe, Ratelimit.acquire(route)}) - Process.sleep(:infinity) - end) - - assert_receive {:probe, :ok} - - # A waiter parks behind the healthy probe. - spawn(fn -> send(test, {:waiter, Ratelimit.acquire(route)}) end) - Process.sleep(20) - refute_received {:waiter, _} - - # A stale abandon from an UNRELATED pid (the test process, not the elected - # probe) must be ignored - the pid comparison guards against clobbering a - # different, in-flight probe. Flush the cast with a sync call (FIFO). - Ratelimit.abandon_probe(route) - :ok = Ratelimit.sweep() - refute_received {:waiter, _} - - # The real probe dying still releases the waiter, proving it was never freed. - Process.exit(probe, :kill) - assert_receive {:waiter, :ok}, 1_000 - end - - test "the server-side force-settle timer relinquishes a silently wedged probe" do - # `sandbox_env/0` in setup already restores this introduced key on exit. - Application.put_env(:dexcord, :ratelimit_probe_force_settle_ms, 100) - - route = "POST /wedged" - test = self() - - # A probe that stays alive and neither settles nor abandons (a lost abandon - # cast): only the belt-and-braces force-settle timer can free the route. - probe = - spawn(fn -> - send(test, {:probe, Ratelimit.acquire(route)}) - Process.sleep(:infinity) - end) - - assert_receive {:probe, :ok} - - spawn(fn -> send(test, {:waiter, Ratelimit.acquire(route)}) end) - Process.sleep(20) - refute_received {:waiter, _} - - # Released strictly by the ~100ms force-settle timer, not by :DOWN. - assert_receive {:waiter, :ok}, 1_000 - assert Process.alive?(probe) - Process.exit(probe, :kill) - end end end diff --git a/test/dexcord/registrar_integration_test.exs b/test/dexcord/registrar_integration_test.exs deleted file mode 100644 index 0100a7e..0000000 --- a/test/dexcord/registrar_integration_test.exs +++ /dev/null @@ -1,118 +0,0 @@ -defmodule Dexcord.RegistrarIntegrationTest do - @moduledoc """ - Drives `Dexcord.Slash.Registrar.run/1` against the scripted `Dexcord.FakeRest` - server to assert the guild vs global routing, the never-wipe-globals rule, and - the cached application id. - """ - use ExUnit.Case, async: false - - import ExUnit.CaptureLog - - alias Dexcord.Api.Ratelimit - alias Dexcord.FakeRest - alias Dexcord.Slash.Registrar - - @token "test.token.value" - - defmodule Commands do - use Dexcord.Slash - def commands, do: [%{name: "ping", description: "Pong!"}] - def handle_interaction(_name, _itx), do: :ok - end - - defmodule EmptyCommands do - use Dexcord.Slash - def commands, do: [] - def handle_interaction(_name, _itx), do: :ok - end - - setup do - Dexcord.EnvSandbox.sandbox_env() - Dexcord.Config.put(%{token: @token, handler: nil, intents: 0}) - - start_supervised!({Finch, name: Dexcord.Finch}) - start_supervised!(Ratelimit) - start_supervised!(FakeRest) - - Application.put_env(:dexcord, :api_base_url, FakeRest.base_url()) - FakeRest.subscribe(self()) - - FakeRest.stub( - :get, - "/oauth2/applications/@me", - FakeRest.resp(200, body: ~s({"id":"app-99"})) - ) - - :ok - end - - test "global mode overwrites application commands and caches the app id" do - FakeRest.stub(:put, "/applications/app-99/commands", FakeRest.resp(200, body: "[]")) - - config = %{slash: Commands, slash_guild_ids: nil} - assert Registrar.run(config) == :ok - - assert Dexcord.Config.application_id() == "app-99" - - assert_receive {:rest_hit, %{method: "GET", path: "/oauth2/applications/@me"}} - - assert_receive {:rest_hit, - %{method: "PUT", path: "/applications/app-99/commands", body: body}} - - assert JSON.decode!(body) == [%{"name" => "ping", "description" => "Pong!"}] - end - - test "guild mode overwrites each guild and NEVER wipes global commands" do - FakeRest.stub(:put, "/applications/app-99/guilds/g1/commands", FakeRest.resp(200, body: "[]")) - FakeRest.stub(:put, "/applications/app-99/guilds/g2/commands", FakeRest.resp(200, body: "[]")) - - config = %{slash: Commands, slash_guild_ids: ["g1", "g2"]} - assert Registrar.run(config) == :ok - - assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/guilds/g1/commands"}} - assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/guilds/g2/commands"}} - - # The global-commands route must never be hit in guild (dev) mode. - refute_received {:rest_hit, %{path: "/applications/app-99/commands"}} - end - - test "empty commands in global mode SKIPS the overwrite (never wipes) and logs a hint" do - config = %{slash: EmptyCommands, slash_guild_ids: nil} - - log = - capture_log(fn -> - assert Registrar.run(config) == :ok - end) - - assert_receive {:rest_hit, %{method: "GET", path: "/oauth2/applications/@me"}} - # The global-commands overwrite must NOT be attempted for an empty list. - refute_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/commands"}} - - assert log =~ "commands/0 is empty in global mode" - assert log =~ "bulk_overwrite_global_commands" - end - - test "a repeatedly-failing overwrite retries N times then gives up gracefully (no abnormal exit)" do - # Fast, deterministic retries: 2 sleeps of 0ms => 3 total attempts. - Dexcord.EnvSandbox.sandbox_env() - Application.put_env(:dexcord, :registrar_retry_delays, [0, 0]) - - FakeRest.stub(:put, "/applications/app-99/commands", FakeRest.resp(403, body: "forbidden")) - - config = %{slash: Commands, slash_guild_ids: nil} - - log = - capture_log(fn -> - # run/1 returns :ok (exits :normal in the Task) - it never raises/exits abnormally. - assert Registrar.run(config) == :ok - end) - - # Exactly 3 overwrite attempts hit the fake, and not a 4th. - assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/commands"}} - assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/commands"}} - assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/commands"}} - refute_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/commands"}} - - assert log =~ "giving up after 3 attempt(s)" - end -end diff --git a/test/dexcord/registrar_tree_test.exs b/test/dexcord/registrar_tree_test.exs deleted file mode 100644 index f2d2f42..0000000 --- a/test/dexcord/registrar_tree_test.exs +++ /dev/null @@ -1,83 +0,0 @@ -defmodule Dexcord.RegistrarTreeTest do - @moduledoc """ - Verifies the flagship reliability property of finding 1: a persistently-failing - slash registration must NEVER cycle the gateway or terminate the tree. The - Registrar is `restart: :temporary` and owns a bounded in-process retry loop, so - when it gives up it exits `:normal` and everything else - supervisor, gateway, - session - keeps running untouched. - """ - use ExUnit.Case, async: false - - alias Dexcord.FakeRest - - @token "fake.token.value" - - defmodule Handler do - use Dexcord.Handler - end - - defmodule Commands do - use Dexcord.Slash - def commands, do: [%{name: "ping", description: "Pong!"}] - def handle_interaction(_name, _itx), do: :ok - end - - # Grab a port, then release it so connecting to it is refused (gateway backs off). - defp closed_port do - {:ok, socket} = :gen_tcp.listen(0, []) - {:ok, port} = :inet.port(socket) - :ok = :gen_tcp.close(socket) - port - end - - setup do - Dexcord.EnvSandbox.sandbox_env() - - # Fast, deterministic registrar retries: 2 sleeps of 0ms => 3 total attempts. - Application.put_env(:dexcord, :registrar_retry_delays, [0, 0]) - Application.put_env(:dexcord, :backoff, {50, 500}) - - start_supervised!(FakeRest) - Application.put_env(:dexcord, :api_base_url, FakeRest.base_url()) - FakeRest.subscribe(self()) - - FakeRest.stub(:get, "/oauth2/applications/@me", FakeRest.resp(200, body: ~s({"id":"app-1"}))) - # Registration fails forever with a 4xx. - FakeRest.stub(:put, "/applications/app-1/commands", FakeRest.resp(403, body: "forbidden")) - - :ok - end - - test "a persistently-failing Registrar gives up without cycling the gateway or tree" do - port = closed_port() - - sup = - start_supervised!( - {Dexcord, - token: @token, - handler: Handler, - intents: :default, - slash: Commands, - gateway_url: "ws://127.0.0.1:#{port}"} - ) - - # Exactly 3 overwrite attempts hit the fake (2 retries), then the Registrar - # gives up - no 4th attempt. - assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-1/commands"}}, 2_000 - assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-1/commands"}}, 2_000 - assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-1/commands"}}, 2_000 - refute_receive {:rest_hit, %{method: "PUT", path: "/applications/app-1/commands"}}, 500 - - # The whole tree is unharmed: supervisor and gateway are alive. - assert Process.alive?(sup) - - children = Supervisor.which_children(Dexcord.Supervisor) - gateway = Enum.find(children, fn {id, _pid, _t, _m} -> id == Dexcord.Gateway end) - assert {Dexcord.Gateway, gw_pid, _t, _m} = gateway - assert is_pid(gw_pid) and Process.alive?(gw_pid) - - # The Registrar exited :normal and, being :temporary, was not restarted. - registrar = Enum.find(children, fn {id, _pid, _t, _m} -> id == Dexcord.Slash.Registrar end) - assert registrar == nil or match?({_, :undefined, _, _}, registrar) - end -end diff --git a/test/dexcord/session_test.exs b/test/dexcord/session_test.exs index e986e45..ed521f0 100644 --- a/test/dexcord/session_test.exs +++ b/test/dexcord/session_test.exs @@ -47,22 +47,6 @@ defmodule Dexcord.SessionTest do refute Session.resumable?() end - test "establish/2 resets last_seq so a new session never inherits the old sequence" do - # Simulate a live prior session with an advanced sequence number. - Session.bump_seq(4242) - assert Session.last_seq() == 4242 - - # Establishing a fresh session (new READY) must wipe the stale seq; the new - # session's own first frame reseeds it. - Session.establish("new-sess", "wss://resume.example") - assert Session.last_seq() == nil - assert Session.session_id() == "new-sess" - - # And a subsequent bump seeds the fresh session cleanly. - Session.bump_seq(1) - assert Session.last_seq() == 1 - end - test "mark_fatal records a fatal tuple" do Session.mark_fatal(4014) assert Session.fatal() == {:fatal_close, 4014} @@ -94,11 +78,8 @@ defmodule Dexcord.SessionTest do # the Session GenServer (not the writer), the value must persist. task = Task.async(fn -> - # establish/2 resets last_seq, so bump AFTER establishing (a fresh session's - # seq is seeded by its own frames). This test only cares that state written by - # a now-dead process persists via the Session-owned ETS table. - Session.establish("s1", "wss://x") Session.bump_seq(777) + Session.establish("s1", "wss://x") end) Task.await(task) diff --git a/test/dexcord/slash_test.exs b/test/dexcord/slash_test.exs deleted file mode 100644 index cf9376c..0000000 --- a/test/dexcord/slash_test.exs +++ /dev/null @@ -1,223 +0,0 @@ -defmodule Dexcord.SlashTest do - use ExUnit.Case, async: false - - import ExUnit.CaptureLog - - alias Dexcord.Api.Ratelimit - alias Dexcord.FakeRest - alias Dexcord.Slash - - @token "test.token.value" - - defmodule Commands do - use Dexcord.Slash - - def commands do - [ - %{name: "ping", description: "Pong!"}, - %{name: "echo", description: "Echo", options: [%{type: 3, name: "text"}]} - ] - end - - def handle_interaction("ping", itx) do - send(self(), {:handled, "ping", itx}) - :ok - end - - def handle_component("refresh", itx) do - send(self(), {:component, "refresh", itx}) - :ok - end - - def handle_modal("feedback", itx) do - send(self(), {:modal, "feedback", itx}) - :ok - end - end - - # A module compiled defining ONLY handle_interaction/2 - it must still compile - # (the component/modal callbacks are optional) and route those via the injected - # defaults without crashing. - defmodule LegacyCommands do - use Dexcord.Slash - def commands, do: [%{name: "ping", description: "Pong!"}] - def handle_interaction("ping", itx), do: send(self(), {:legacy, itx}) - end - - describe "behaviour + use" do - test "commands/0 returns the definition maps" do - assert [%{name: "ping"}, %{name: "echo", options: _}] = Commands.commands() - end - - test "dispatch/2 routes a type-2 interaction to handle_interaction/2 by name" do - itx = %{"type" => 2, "data" => %{"name" => "ping"}, "id" => "1"} - assert Slash.dispatch(itx, Commands) == :ok - assert_received {:handled, "ping", ^itx} - end - - test "dispatch/2 routes a type-3 (component) interaction to handle_component/2 by custom_id" do - itx = %{"type" => 3, "data" => %{"custom_id" => "refresh"}, "id" => "2"} - assert Slash.dispatch(itx, Commands) == :ok - assert_received {:component, "refresh", ^itx} - end - - test "dispatch/2 routes a type-5 (modal) interaction to handle_modal/2 by custom_id" do - itx = %{"type" => 5, "data" => %{"custom_id" => "feedback"}, "id" => "3"} - assert Slash.dispatch(itx, Commands) == :ok - assert_received {:modal, "feedback", ^itx} - end - - test "the injected catch-all logs a warning for an unhandled command name" do - itx = %{"type" => 2, "data" => %{"name" => "unknown"}} - - log = - capture_log(fn -> - assert Slash.dispatch(itx, Commands) == :ignore - end) - - assert log =~ "no handle_interaction/2 clause" - assert log =~ "\"unknown\"" - end - - test "the injected component/modal catch-alls log at debug, not warning" do - component = %{"type" => 3, "data" => %{"custom_id" => "nope"}} - modal = %{"type" => 5, "data" => %{"custom_id" => "nope"}} - - log = - capture_log([level: :debug], fn -> - assert Slash.dispatch(component, Commands) == :ignore - assert Slash.dispatch(modal, Commands) == :ignore - end) - - assert log =~ "no handle_component/2 clause" - assert log =~ "no handle_modal/2 clause" - refute log =~ "[warning]" - end - - test "a module defining only handle_interaction/2 still routes components/modals via defaults" do - itx2 = %{"type" => 2, "data" => %{"name" => "ping"}} - Slash.dispatch(itx2, LegacyCommands) - assert_received {:legacy, ^itx2} - - component = %{"type" => 3, "data" => %{"custom_id" => "x"}} - modal = %{"type" => 5, "data" => %{"custom_id" => "y"}} - - capture_log([level: :debug], fn -> - assert Slash.dispatch(component, LegacyCommands) == :ignore - assert Slash.dispatch(modal, LegacyCommands) == :ignore - end) - end - end - - describe "response helpers (payload shape via FakeRest)" do - setup do - Dexcord.EnvSandbox.sandbox_env() - Dexcord.Config.put(%{token: @token, handler: nil, intents: 0}) - - start_supervised!({Finch, name: Dexcord.Finch}) - start_supervised!(Ratelimit) - start_supervised!(FakeRest) - - Application.put_env(:dexcord, :api_base_url, FakeRest.base_url()) - FakeRest.subscribe(self()) - :ok - end - - @itx %{ - "id" => "int-id", - "token" => "int-token", - "application_id" => "app-id", - "data" => %{"name" => "ping"} - } - - test "respond/2 with a string sends a type-4 content response to the callback route" do - FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204)) - - assert {:ok, nil} = Slash.respond(@itx, "pong") - - assert_receive {:rest_hit, info} - assert info.method == "POST" - assert info.path == "/interactions/int-id/int-token/callback" - assert JSON.decode!(info.body) == %{"type" => 4, "data" => %{"content" => "pong"}} - end - - test "respond/2 with a map maps ephemeral: true to flags 64 and passes embeds through" do - FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204)) - - assert {:ok, nil} = - Slash.respond(@itx, %{content: "secret", embeds: [%{title: "x"}], ephemeral: true}) - - assert_receive {:rest_hit, info} - body = JSON.decode!(info.body) - assert body["type"] == 4 - assert body["data"]["content"] == "secret" - assert body["data"]["flags"] == 64 - assert body["data"]["embeds"] == [%{"title" => "x"}] - end - - test "respond/2 accepts string-keyed data maps too" do - FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204)) - - assert {:ok, nil} = Slash.respond(@itx, %{"content" => "hi", "ephemeral" => true}) - - assert_receive {:rest_hit, info} - body = JSON.decode!(info.body) - assert body["data"] == %{"content" => "hi", "flags" => 64} - end - - test "respond/2 passes a caller-supplied integer flags through unchanged" do - FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204)) - - assert {:ok, nil} = Slash.respond(@itx, %{content: "hi", flags: 4}) - - assert_receive {:rest_hit, info} - body = JSON.decode!(info.body) - assert body["data"]["flags"] == 4 - end - - test "respond/2 OR-s a caller flags with the ephemeral bit (4 + 64 = 68)" do - FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204)) - - assert {:ok, nil} = Slash.respond(@itx, %{content: "hi", flags: 4, ephemeral: true}) - - assert_receive {:rest_hit, info} - body = JSON.decode!(info.body) - assert body["data"]["flags"] == 68 - end - - test "respond_later/1 sends a bare type-5 deferred response" do - FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204)) - - assert {:ok, nil} = Slash.respond_later(@itx) - - assert_receive {:rest_hit, info} - assert JSON.decode!(info.body) == %{"type" => 5} - end - - test "followup/2 posts to the application webhook route" do - FakeRest.stub(:post, "/webhooks/app-id/int-token", FakeRest.resp(200, body: ~s({"id":"9"}))) - - assert {:ok, %{"id" => "9"}} = Slash.followup(@itx, "more") - - assert_receive {:rest_hit, info} - assert info.method == "POST" - assert info.path == "/webhooks/app-id/int-token" - assert JSON.decode!(info.body) == %{"content" => "more"} - end - - test "edit_response/2 patches the original message route" do - FakeRest.stub( - :patch, - "/webhooks/app-id/int-token/messages/@original", - FakeRest.resp(200, body: ~s({"id":"9"})) - ) - - assert {:ok, %{"id" => "9"}} = Slash.edit_response(@itx, %{content: "edited"}) - - assert_receive {:rest_hit, info} - assert info.method == "PATCH" - assert info.path == "/webhooks/app-id/int-token/messages/@original" - assert JSON.decode!(info.body) == %{"content" => "edited"} - end - end -end diff --git a/test/dexcord/supervisor_smoke_test.exs b/test/dexcord/supervisor_smoke_test.exs index 463baf7..3ba19b7 100644 --- a/test/dexcord/supervisor_smoke_test.exs +++ b/test/dexcord/supervisor_smoke_test.exs @@ -34,20 +34,12 @@ defmodule Dexcord.SupervisorSmokeTest do ids = Enum.map(children, fn {id, _pid, _type, _mods} -> id end) assert Dexcord.Session in ids - # Cache + Dispatcher now live under a nested :rest_for_one supervisor. - assert Dexcord.Cache.Supervisor in ids + assert Dexcord.Dispatcher in ids assert Dexcord.Gateway in ids - # Every top-level child is a live process (gateway is retrying, not crashed). + # Every child is a live process (gateway is retrying, not crashed). for {_id, child_pid, _type, _mods} <- children do assert is_pid(child_pid) and Process.alive?(child_pid) end - - # The nested supervisor really does hold Cache and the Dispatcher. - {_, nested_pid, _, _} = Enum.find(children, &(elem(&1, 0) == Dexcord.Cache.Supervisor)) - nested_ids = Enum.map(Supervisor.which_children(nested_pid), &elem(&1, 0)) - - assert Dexcord.Cache in nested_ids - assert Dexcord.Dispatcher in nested_ids end end diff --git a/test/support/cache_probe_handler.ex b/test/support/cache_probe_handler.ex deleted file mode 100644 index 0fe5a73..0000000 --- a/test/support/cache_probe_handler.ex +++ /dev/null @@ -1,27 +0,0 @@ -defmodule Dexcord.FakeGateway.CacheProbeHandler do - @moduledoc false - # A `Dexcord.Handler` used to prove ordering: the Dispatcher writes the cache - # INLINE before spawning the handler Task, so by the time this handler runs the - # cache must already reflect the event. For each event it reads the relevant - # cache entry and relays what it observed to the subscribed test process as - # `{:handler_saw, name, cache_result}`. - - @behaviour Dexcord.Handler - - @key {__MODULE__, :sink} - - def subscribe(pid), do: :persistent_term.put(@key, pid) - - @impl true - def handle_event({name, data}) do - case :persistent_term.get(@key, nil) do - nil -> :ok - pid -> send(pid, {:handler_saw, name, probe(name, data)}) - end - end - - defp probe(:GUILD_CREATE, %{"id" => id}), do: Dexcord.Cache.guild(id) - defp probe(:MESSAGE_CREATE, %{"author" => %{"id" => uid}}), do: Dexcord.Cache.user(uid) - defp probe(:GUILD_MEMBERS_CHUNK, %{"guild_id" => gid}), do: Dexcord.Cache.members(gid) - defp probe(_name, _data), do: :na -end diff --git a/test/support/env_sandbox.ex b/test/support/env_sandbox.ex deleted file mode 100644 index c658184..0000000 --- a/test/support/env_sandbox.ex +++ /dev/null @@ -1,41 +0,0 @@ -defmodule Dexcord.EnvSandbox do - @moduledoc false - # A single, bulletproof application-env fixture for the test suite. - # - # Many tests tune `:dexcord` application env (timing knobs, budgets, rate-limit - # clocks, base URLs, ...) to exercise real logic quickly. Any key a test forgets - # to restore leaks into whatever test runs next and produces order-dependent, - # seed-sensitive failures. Rather than have every file maintain its own hand-rolled - # save/restore list (and get it subtly wrong), tests call `sandbox_env/0` once in - # `setup`: it snapshots the ENTIRE `:dexcord` env up front and, via `on_exit`, - # restores it wholesale afterwards - deleting any key the test introduced and - # resetting every pre-existing key to its captured value. Tests then `put_env` - # freely with no per-key bookkeeping. - - import ExUnit.Callbacks, only: [on_exit: 1] - - @doc """ - Snapshots all `:dexcord` application env and registers wholesale restoration on - test exit. Call once from a test's `setup`. - """ - @spec sandbox_env :: :ok - def sandbox_env do - before = Application.get_all_env(:dexcord) - before_keys = MapSet.new(before, fn {k, _v} -> k end) - - on_exit(fn -> - # Drop any key the test introduced that wasn't there before. - for {key, _val} <- Application.get_all_env(:dexcord), - not MapSet.member?(before_keys, key) do - Application.delete_env(:dexcord, key) - end - - # Restore every snapshotted key to exactly its captured value. - for {key, val} <- before do - Application.put_env(:dexcord, key, val) - end - end) - - :ok - end -end