diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index cc06c46..0000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"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 18c0e7e..79bfb80 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ erl_crash.dump # Ignore package tarball (built via "mix hex.build"). dexcord-*.tar + +.claude diff --git a/README.md b/README.md index 78523ed..e0fabad 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,502 @@ # Dexcord -**TODO: Add description** +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). ## Installation -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`: +Dexcord isn't published on Hex yet. Depend on it via `path:` (local checkout) +or `git:`: ```elixir def deps do [ - {:dexcord, "~> 0.1.0"} + {:dexcord, path: "../dexcord"} + # or: + # {:dexcord, git: "https://github.com/luna/dexcord.git"} ] end ``` -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 . +## Quickstart +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 new file mode 100644 index 0000000..e3ac3b4 --- /dev/null +++ b/examples/echo_bot.exs @@ -0,0 +1,89 @@ +# 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 bf07a62..bb97fbc 100644 --- a/lib/dexcord.ex +++ b/lib/dexcord.ex @@ -20,6 +20,11 @@ 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. @@ -27,6 +32,10 @@ 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. @@ -58,19 +67,102 @@ defmodule Dexcord do end intents = Keyword.get(opts, :intents, :default) - intents_bits = Dexcord.Intents.resolve(intents) + 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) %{ token: token, handler: handler, intents: intents_bits, intents_spec: intents, - 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) + cache_presences: cache_presences, + request_guild_members: request_guild_members, + slash: slash, + slash_guild_ids: slash_guild_ids, + gateway_url: 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 aa6c463..3234358 100644 --- a/lib/dexcord/api.ex +++ b/lib/dexcord/api.ex @@ -10,8 +10,9 @@ 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), or - `{:error, %Dexcord.Api.Error{}}`. + 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. 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 @@ -28,6 +29,11 @@ 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. @@ -36,67 +42,106 @@ 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} | {:error, Error.t()} + {:ok, map()} | {:ok, nil} | {:ok, {:raw, binary()}} | {:error, Error.t()} def request(method, path, body \\ nil, opts \\ []) do route = Ratelimit.route_key(method, path) - do_request(method, path, body, opts, route, 0) + deadline = mono_now() + deadline_ms() + do_request(method, path, body, opts, route, 0, deadline) end - defp do_request(method, path, body, opts, route, attempt) do - case Ratelimit.acquire(route) do + defp do_request(method, path, body, opts, route, attempt, deadline) do + case Ratelimit.acquire(route, max(deadline - mono_now(), 0)) do {:wait, ms} -> - Process.sleep(ms + @wait_padding_ms) - do_request(method, path, body, opts, route, attempt) + 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() :ok -> - issue(method, path, body, opts, route, attempt) + issue(method, path, body, opts, route, attempt, deadline) end end - defp issue(method, path, body, opts, route, attempt) do + 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 {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) - Ratelimit.probe_settled(route) - handle_429(method, path, body, opts, route, attempt, resp_headers, resp_body) + {:retry_429, resp_headers, resp_body} {:ok, %Finch.Response{status: status, headers: resp_headers} = resp} when status in 200..299 -> Ratelimit.update(route, resp_headers) - Ratelimit.probe_settled(route) - if status == 204, do: {:ok, nil}, else: {:ok, decode_body(resp.body)} + {:done, success_result(status, resp.body)} {:ok, %Finch.Response{headers: resp_headers} = resp} -> Ratelimit.update(route, resp_headers) - Ratelimit.probe_settled(route) - {:error, error(resp)} + {:done, {:error, error(resp)}} {:error, reason} -> - Ratelimit.probe_settled(route) - {:error, %Error{status: nil, code: nil, message: inspect(reason), errors: nil}} + {:done, {: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) + defp handle_429(method, path, body, opts, route, attempt, headers, resp_body, deadline) when attempt < @max_retries do retry_ms = retry_after_ms(resp_body, headers) - 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 + 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) - do_request(method, path, body, opts, route, attempt + 1) + 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) + end end - defp handle_429(_method, _path, _body, _opts, _route, _attempt, _headers, resp_body) do + defp handle_429(_method, _path, _body, _opts, _route, _attempt, _headers, resp_body, _deadline) do decoded = decoded_map(resp_body) {:error, @@ -131,6 +176,27 @@ 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 @@ -149,16 +215,28 @@ defmodule Dexcord.Api do end defp error(%Finch.Response{status: status, body: body}) do - decoded = decoded_map(body) + 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") + } - %Error{ - status: status, - code: Map.get(decoded, "code"), - message: Map.get(decoded, "message"), - errors: Map.get(decoded, "errors") - } + _ -> + # 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 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 9e2330f..a5a0877 100644 --- a/lib/dexcord/api/ratelimit.ex +++ b/lib/dexcord/api/ratelimit.ex @@ -9,15 +9,20 @@ 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/1`** runs in the *caller's* process. For a known route it reads + * **`acquire/2`** 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/1` serializes through the GenServer so + * For an **unknown route**, `acquire/2` 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/1` against the freshly learned bucket. + 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. * **`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 @@ -33,6 +38,13 @@ 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 @@ -47,6 +59,9 @@ 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 @@ -62,10 +77,18 @@ 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` + `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). + (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. iex> Dexcord.Api.Ratelimit.route_key(:post, "/channels/123/messages") "POST /channels/123/messages" @@ -92,13 +115,21 @@ defmodule Dexcord.Api.Ratelimit do end end - # webhook_id + webhook_token are both major - keep both literal. + # 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. defp templatize(["webhooks", id, token | rest]), - do: ["webhooks", id, token | templatize(rest)] + do: ["webhooks", id, token_digest(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)] @@ -116,14 +147,27 @@ 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()) :: :ok | {:wait, non_neg_integer()} - def acquire(route_key) do + @spec acquire(String.t(), timeout()) :: + :ok | {:wait, non_neg_integer()} | {:error, :timeout} + def acquire(route_key, probe_timeout \\ :infinity) do now = now() case global_lock_wait(now) do @@ -132,7 +176,7 @@ defmodule Dexcord.Api.Ratelimit do :ok -> case lookup_bucket(route_key) do - nil -> acquire_unknown(route_key) + nil -> acquire_unknown(route_key, probe_timeout) :none -> :ok bucket -> take_token(bucket, now) end @@ -160,13 +204,31 @@ 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/1`. + releasing any callers parked behind it so they re-`acquire/2`. """ @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 @@ -178,11 +240,20 @@ defmodule Dexcord.Api.Ratelimit do # --- caller-process internals ------------------------------------------ - defp acquire_unknown(route_key) do - case GenServer.call(@server, {:acquire_probe, route_key}) do + defp acquire_unknown(route_key, probe_timeout) do + case GenServer.call(@server, {:acquire_probe, route_key, probe_timeout}, probe_timeout) do :ok -> :ok - :retry -> acquire(route_key) + :retry -> acquire(route_key, probe_timeout) 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 @@ -197,20 +268,41 @@ 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: allow optimistically; the next response's headers - # refresh `remaining`/`reset_at` for the new window. - :ok + # 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) true -> - new = :ets.update_counter(@table, {:bucket, bucket}, {2, -1}) + # 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}) 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 @@ -233,11 +325,18 @@ defmodule Dexcord.Api.Ratelimit do write_concurrency: true ]) - {:ok, %{probing: %{}}} + schedule_sweep() + # `probing`: route_key => {probe_monitor_ref, [parked_waiters]} + # `mons`: probe_monitor_ref => route_key (reverse lookup for :DOWN) + {:ok, %{probing: %{}, mons: %{}}} end @impl true - def handle_call({:acquire_probe, route_key}, from, %{probing: probing} = state) do + def handle_call( + {:acquire_probe, route_key, probe_timeout}, + from, + %{probing: probing, mons: mons} = state + ) do cond do :ets.member(@table, {:route_bucket, route_key}) -> # Learned since the caller's ETS read - just retry the fast path. @@ -245,14 +344,43 @@ defmodule Dexcord.Api.Ratelimit do Map.has_key?(probing, route_key) -> # A probe is already in flight; park until it settles. - waiters = Map.fetch!(probing, route_key) - {:noreply, %{state | probing: Map.put(probing, route_key, [from | waiters])}} + {ref, pid, timer, waiters} = Map.fetch!(probing, route_key) + + {:noreply, + %{state | probing: Map.put(probing, route_key, {ref, pid, timer, [from | waiters]})}} true -> - {:reply, :ok, %{state | probing: Map.put(probing, route_key, [])}} + # 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) + }} 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) @@ -283,17 +411,105 @@ defmodule Dexcord.Api.Ratelimit do end @impl true - def handle_cast({:probe_settled, route_key}, %{probing: probing} = state) do - case Map.pop(probing, route_key) do - {nil, _} -> - {:noreply, state} + def handle_cast({:probe_settled, route_key}, state) do + {:noreply, release_probe(state, route_key)} + end - {waiters, rest} -> - Enum.each(waiters, &GenServer.reply(&1, :retry)) - {:noreply, %{state | probing: rest}} + # 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 + case Map.pop(probing, route_key) do + {nil, _} -> + state + + {{ref, _pid, timer, waiters}, rest} -> + if is_reference(ref), do: Process.demonitor(ref, [:flush]) + if is_reference(timer), do: Process.cancel_timer(timer) + Enum.each(waiters, &GenServer.reply(&1, :retry)) + %{state | probing: rest, mons: Map.delete(mons, ref)} + 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 new file mode 100644 index 0000000..f534101 --- /dev/null +++ b/lib/dexcord/cache.ex @@ -0,0 +1,442 @@ +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 cff1639..ca6095f 100644 --- a/lib/dexcord/config.ex +++ b/lib/dexcord/config.ex @@ -10,6 +10,7 @@ defmodule Dexcord.Config do """ @pt_key __MODULE__ + @app_id_key {__MODULE__, :application_id} @doc "Stores the validated config map." @spec put(map()) :: :ok @@ -36,4 +37,17 @@ 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 8708357..2f6cbad 100644 --- a/lib/dexcord/dispatcher.ex +++ b/lib/dexcord/dispatcher.ex @@ -33,16 +33,60 @@ defmodule Dexcord.Dispatcher do @impl true def handle_cast({:dispatch, name, data}, state) do - # 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. + config = Dexcord.Config.get() - handler = Dexcord.Config.handler() + # 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) Task.Supervisor.start_child(@task_supervisor, fn -> - handler.handle_event({name, data}) + config.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 d01298c..c7d2c4c 100644 --- a/lib/dexcord/gateway.ex +++ b/lib/dexcord/gateway.ex @@ -14,21 +14,27 @@ defmodule Dexcord.Gateway do RESUME* (`session_id`, `last_seq`, `resume_gateway_url`, backoff, fatal flag) lives in `Dexcord.Session`'s ETS, not here. - ## Phase 0-1 scope + ## Reliability behaviour - 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. + 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. ## `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 Phase 2 - FakeGateway test suite connects against. + `:http` Mint scheme (TCP, no TLS); `wss://` to `:https`. This is what the + `Dexcord.FakeGateway` test suite connects against. """ @behaviour :gen_statem @@ -107,16 +113,49 @@ 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 + 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() } {:ok, :disconnected, data} @@ -153,6 +192,7 @@ defmodule Dexcord.Gateway do data | status: nil, resp_headers: [], + pending_data: <<>>, websocket: nil, reidentify_pending?: false } @@ -241,6 +281,20 @@ 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 @@ -288,21 +342,31 @@ defmodule Dexcord.Gateway do # --- casts -------------------------------------------------------------- - def handle_event(:cast, {:update_presence, presence}, :connected, data) do - send_and_keep(data, Payload.presence_update(presence)) + def handle_event(:cast, {:update_presence, presence}, state, data) do + cast_budgeted(state, data, Payload.presence_update(presence)) end - def handle_event(:cast, {:request_guild_members, guild_id, opts}, :connected, data) do - send_and_keep(data, Payload.request_guild_members(guild_id, opts)) + def handle_event(:cast, {:request_guild_members, guild_id, opts}, state, data) do + cast_budgeted(state, 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 @@ -433,8 +497,11 @@ 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. - bump_and_dispatch(payload) + # 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) {:keep_state, data} true -> @@ -460,8 +527,30 @@ defmodule Dexcord.Gateway do _ -> :ok end - name = Dexcord.EventNames.to_atom(payload["t"]) - Dexcord.Dispatcher.dispatch(name, payload["d"]) + 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 end # --- close handling ----------------------------------------------------- @@ -610,11 +699,28 @@ 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} - process_responses(data, rest, [{:next_event, :internal, :upgraded} | actions]) + 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 {:error, conn, reason} -> {:error, %{data | conn: conn}, reason} @@ -642,6 +748,26 @@ 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} @@ -666,13 +792,139 @@ defmodule Dexcord.Gateway do end end - defp send_and_keep(data, frame_map) do - case send_frame(data, {:text, JSON.encode!(frame_map)}) do - {:ok, data} -> {:keep_state, data} - {:error, data, _reason} -> fail_reconnect(data) + # --- 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} + + 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} + 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 @@ -776,6 +1028,16 @@ 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 new file mode 100644 index 0000000..2136053 --- /dev/null +++ b/lib/dexcord/prefix.ex @@ -0,0 +1,148 @@ +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 8a286fb..945d0ed 100644 --- a/lib/dexcord/session.ex +++ b/lib/dexcord/session.ex @@ -94,11 +94,20 @@ 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 new file mode 100644 index 0000000..c10c435 --- /dev/null +++ b/lib/dexcord/slash.ex @@ -0,0 +1,256 @@ +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 new file mode 100644 index 0000000..b9088ae --- /dev/null +++ b/lib/dexcord/slash/registrar.ex @@ -0,0 +1,180 @@ +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 ae765e2..2f97179 100644 --- a/lib/dexcord/supervisor.ex +++ b/lib/dexcord/supervisor.ex @@ -16,11 +16,24 @@ defmodule Dexcord.Supervisor do `max_restarts` trips in milliseconds and this supervisor terminates - surfacing the failure to the host application instead of hammering Discord. - ## Phase note + ## Notes - `Dexcord.Cache` and `Dexcord.Slash.Registrar` are not started yet - (Cache -> Phase 4, Registrar -> Phase 5). `Dexcord.Api.Ratelimit` owns the + `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 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 @@ -35,16 +48,42 @@ defmodule Dexcord.Supervisor do # Publish the validated config before any child that reads it starts. Dexcord.Config.put(config) - 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 - ] + # 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] 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 8aab014..1e3de45 100644 --- a/mix.exs +++ b/mix.exs @@ -1,6 +1,8 @@ defmodule Dexcord.MixProject do use Mix.Project + @source_url "https://github.com/luna/dexcord" + def project do [ app: :dexcord, @@ -8,7 +10,17 @@ defmodule Dexcord.MixProject do elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, - deps: deps() + deps: deps(), + name: "Dexcord", + source_url: @source_url, + docs: docs() + ] + end + + defp docs do + [ + main: "readme", + extras: ["README.md"] ] end diff --git a/test/dexcord/api_integration_test.exs b/test/dexcord/api_integration_test.exs index df16415..92eb31e 100644 --- a/test/dexcord/api_integration_test.exs +++ b/test/dexcord/api_integration_test.exs @@ -16,6 +16,7 @@ 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}) @@ -25,11 +26,6 @@ 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 @@ -57,6 +53,9 @@ 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 @@ -137,9 +136,10 @@ defmodule Dexcord.ApiIntegrationTest do {elapsed_us, result} = :timer.tc(fn -> Api.create_message(5, "hi") end) assert {:ok, %{"id" => "ok"}} = result - # Two hits: the 429 and the successful retry. + # Exactly two hits: the 429 and the successful retry - and no more. assert_receive {:rest_hit, _} assert_receive {:rest_hit, _} + refute_receive {:rest_hit, _}, 50 assert elapsed_us >= 200_000 end @@ -176,6 +176,65 @@ 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 new file mode 100644 index 0000000..9d7d8d1 --- /dev/null +++ b/test/dexcord/cache_cascade_test.exs @@ -0,0 +1,84 @@ +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 new file mode 100644 index 0000000..29fb37e --- /dev/null +++ b/test/dexcord/cache_integration_test.exs @@ -0,0 +1,133 @@ +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 new file mode 100644 index 0000000..705cf32 --- /dev/null +++ b/test/dexcord/cache_test.exs @@ -0,0 +1,329 @@ +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 new file mode 100644 index 0000000..a591a36 --- /dev/null +++ b/test/dexcord/config_validation_test.exs @@ -0,0 +1,78 @@ +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 new file mode 100644 index 0000000..6b17e59 --- /dev/null +++ b/test/dexcord/ergonomics_integration_test.exs @@ -0,0 +1,138 @@ +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 ebb1f81..7cdf698 100644 --- a/test/dexcord/gateway_backoff_test.exs +++ b/test/dexcord/gateway_backoff_test.exs @@ -5,14 +5,7 @@ defmodule Dexcord.GatewayBackoffTest do alias Dexcord.Gateway setup do - 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) - + Dexcord.EnvSandbox.sandbox_env() :ok end diff --git a/test/dexcord/gateway_fatal_test.exs b/test/dexcord/gateway_fatal_test.exs index 186fe7f..c3235a2 100644 --- a/test/dexcord/gateway_fatal_test.exs +++ b/test/dexcord/gateway_fatal_test.exs @@ -12,24 +12,13 @@ defmodule Dexcord.GatewayFatalTest do alias Dexcord.FakeGateway setup do - prev = - for key <- [:backoff, :connect_timeout_ms, :hello_timeout_ms, :tcp_connect_timeout_ms] do - {key, Application.get_env(:dexcord, key)} - end + Dexcord.EnvSandbox.sandbox_env() 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 @@ -53,7 +42,24 @@ defmodule Dexcord.GatewayFatalTest do Process.flag(:trap_exit, true) {:ok, sup} = Dexcord.Supervisor.start_link(config) - on_exit(fn -> if Process.alive?(sup), do: Process.exit(sup, :kill) end) + # 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) + {fake, sup} end diff --git a/test/dexcord/gateway_integration_test.exs b/test/dexcord/gateway_integration_test.exs index cc475ce..04b8cdb 100644 --- a/test/dexcord/gateway_integration_test.exs +++ b/test/dexcord/gateway_integration_test.exs @@ -10,26 +10,16 @@ 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. 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 + # Deterministic, fast timing knobs. The env sandbox restores everything (whatever + # keys these tests set) wholesale after each test. + Dexcord.EnvSandbox.sandbox_env() Application.put_env(:dexcord, :backoff, {20, 200}) Application.put_env(:dexcord, :first_heartbeat_fraction, 1.0) @@ -44,14 +34,6 @@ 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 @@ -213,6 +195,107 @@ 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 new file mode 100644 index 0000000..afd4eac --- /dev/null +++ b/test/dexcord/gateway_send_budget_test.exs @@ -0,0 +1,152 @@ +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 new file mode 100644 index 0000000..80f2104 --- /dev/null +++ b/test/dexcord/prefix_test.exs @@ -0,0 +1,106 @@ +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 0853131..b1fb053 100644 --- a/test/dexcord/ratelimit_test.exs +++ b/test/dexcord/ratelimit_test.exs @@ -43,16 +43,50 @@ defmodule Dexcord.Api.RatelimitTest do "PUT /channels/1/messages/:id/reactions/:id/@me" end - test "webhook id + token are both major (literal)" do - assert Ratelimit.route_key(:post, "/webhooks/111/abctoken") == - "POST /webhooks/111/abctoken" + 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) - assert Ratelimit.route_key(:patch, "/webhooks/111/abctoken/messages/@original") == - "PATCH /webhooks/111/abctoken/messages/@original" + post = Ratelimit.route_key(:post, "/webhooks/111/abctoken") + patch = Ratelimit.route_key(:patch, "/webhooks/111/abctoken/messages/@original") - # interaction id is minor; the token stays literal. - assert Ratelimit.route_key(:post, "/interactions/222/tok/callback") == - "POST /interactions/:id/tok/callback" + 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") end test "query strings are ignored" do @@ -63,10 +97,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 @@ -135,5 +169,150 @@ 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 new file mode 100644 index 0000000..0100a7e --- /dev/null +++ b/test/dexcord/registrar_integration_test.exs @@ -0,0 +1,118 @@ +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 new file mode 100644 index 0000000..f2d2f42 --- /dev/null +++ b/test/dexcord/registrar_tree_test.exs @@ -0,0 +1,83 @@ +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 ed521f0..e986e45 100644 --- a/test/dexcord/session_test.exs +++ b/test/dexcord/session_test.exs @@ -47,6 +47,22 @@ 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} @@ -78,8 +94,11 @@ defmodule Dexcord.SessionTest do # the Session GenServer (not the writer), the value must persist. task = Task.async(fn -> - Session.bump_seq(777) + # 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) end) Task.await(task) diff --git a/test/dexcord/slash_test.exs b/test/dexcord/slash_test.exs new file mode 100644 index 0000000..cf9376c --- /dev/null +++ b/test/dexcord/slash_test.exs @@ -0,0 +1,223 @@ +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 3ba19b7..463baf7 100644 --- a/test/dexcord/supervisor_smoke_test.exs +++ b/test/dexcord/supervisor_smoke_test.exs @@ -34,12 +34,20 @@ defmodule Dexcord.SupervisorSmokeTest do ids = Enum.map(children, fn {id, _pid, _type, _mods} -> id end) assert Dexcord.Session in ids - assert Dexcord.Dispatcher in ids + # Cache + Dispatcher now live under a nested :rest_for_one supervisor. + assert Dexcord.Cache.Supervisor in ids assert Dexcord.Gateway in ids - # Every child is a live process (gateway is retrying, not crashed). + # Every top-level 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 new file mode 100644 index 0000000..0fe5a73 --- /dev/null +++ b/test/support/cache_probe_handler.ex @@ -0,0 +1,27 @@ +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 new file mode 100644 index 0000000..c658184 --- /dev/null +++ b/test/support/env_sandbox.ex @@ -0,0 +1,41 @@ +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