Compare commits
10 commits
ef8d1fc34d
...
229cb9d9a8
| Author | SHA1 | Date | |
|---|---|---|---|
| 229cb9d9a8 | |||
| 99c16c52d4 | |||
| c350767a7a | |||
| 9ae463a91b | |||
| 04a43fd33c | |||
| 1b0914d60e | |||
| 45688193d6 | |||
| f44d30954a | |||
| 2dec62e72d | |||
| 92cb967a0f |
36 changed files with 4402 additions and 180 deletions
|
|
@ -1 +0,0 @@
|
||||||
{"sessionId":"d0a8b446-9af2-42d3-81d4-b722b4678616","pid":18056,"procStart":"21098558","acquiredAt":1783128435683}
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -22,3 +22,5 @@ erl_crash.dump
|
||||||
# Ignore package tarball (built via "mix hex.build").
|
# Ignore package tarball (built via "mix hex.build").
|
||||||
dexcord-*.tar
|
dexcord-*.tar
|
||||||
|
|
||||||
|
|
||||||
|
.claude
|
||||||
|
|
|
||||||
495
README.md
495
README.md
|
|
@ -1,21 +1,502 @@
|
||||||
# Dexcord
|
# 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
|
## Installation
|
||||||
|
|
||||||
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
|
Dexcord isn't published on Hex yet. Depend on it via `path:` (local checkout)
|
||||||
by adding `dexcord` to your list of dependencies in `mix.exs`:
|
or `git:`:
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
def deps do
|
def deps do
|
||||||
[
|
[
|
||||||
{:dexcord, "~> 0.1.0"}
|
{:dexcord, path: "../dexcord"}
|
||||||
|
# or:
|
||||||
|
# {:dexcord, git: "https://github.com/luna/dexcord.git"}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
|
## Quickstart
|
||||||
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
|
|
||||||
be found at <https://hexdocs.pm/dexcord>.
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
|
||||||
89
examples/echo_bot.exs
Normal file
89
examples/echo_bot.exs
Normal file
|
|
@ -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)
|
||||||
100
lib/dexcord.ex
100
lib/dexcord.ex
|
|
@ -20,6 +20,11 @@ defmodule Dexcord do
|
||||||
* `:cache_presences` - cache PRESENCE_UPDATE (default `false`)
|
* `:cache_presences` - cache PRESENCE_UPDATE (default `false`)
|
||||||
* `:request_guild_members` - request full member lists on GUILD_CREATE
|
* `:request_guild_members` - request full member lists on GUILD_CREATE
|
||||||
(default `false`)
|
(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"`);
|
* `:gateway_url` - override the gateway URL (e.g. `"ws://127.0.0.1:4000"`);
|
||||||
when set, `GET /gateway/bot` is skipped. Primarily for testing.
|
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.
|
`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 """
|
@doc """
|
||||||
Builds the supervisor child spec, validating options first.
|
Builds the supervisor child spec, validating options first.
|
||||||
|
|
||||||
|
|
@ -58,19 +67,102 @@ defmodule Dexcord do
|
||||||
end
|
end
|
||||||
|
|
||||||
intents = Keyword.get(opts, :intents, :default)
|
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,
|
token: token,
|
||||||
handler: handler,
|
handler: handler,
|
||||||
intents: intents_bits,
|
intents: intents_bits,
|
||||||
intents_spec: intents,
|
intents_spec: intents,
|
||||||
cache_presences: Keyword.get(opts, :cache_presences, false),
|
cache_presences: cache_presences,
|
||||||
request_guild_members: Keyword.get(opts, :request_guild_members, false),
|
request_guild_members: request_guild_members,
|
||||||
gateway_url: Keyword.get(opts, :gateway_url)
|
slash: slash,
|
||||||
|
slash_guild_ids: slash_guild_ids,
|
||||||
|
gateway_url: gateway_url
|
||||||
}
|
}
|
||||||
end
|
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
|
defp require_opt(opts, key) do
|
||||||
case Keyword.fetch(opts, key) do
|
case Keyword.fetch(opts, key) do
|
||||||
{:ok, value} -> value
|
{:ok, value} -> value
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,9 @@ defmodule Dexcord.Api do
|
||||||
up to #{3} times.
|
up to #{3} times.
|
||||||
|
|
||||||
All bodies are string-keyed maps; JSON encode/decode uses the built-in `JSON`
|
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
|
module. Returns `{:ok, map}`, `{:ok, nil}` (204 / empty 2xx body),
|
||||||
`{:error, %Dexcord.Api.Error{}}`.
|
`{: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
|
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
|
`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
|
# Small cushion added to every computed sleep so we wake just after a window
|
||||||
# or retry-after boundary rather than a hair before it.
|
# or retry-after boundary rather than a hair before it.
|
||||||
@wait_padding_ms 20
|
@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 """
|
@doc """
|
||||||
Performs a REST request.
|
Performs a REST request.
|
||||||
|
|
@ -36,67 +42,106 @@ defmodule Dexcord.Api do
|
||||||
* `path` - path relative to the API base, e.g. `"/channels/123/messages"`
|
* `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
|
* `body` - a map or list to JSON-encode, or `nil` for no body
|
||||||
* `opts` - `:audit_log_reason` supported (sent as `x-audit-log-reason`)
|
* `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()) ::
|
@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
|
def request(method, path, body \\ nil, opts \\ []) do
|
||||||
route = Ratelimit.route_key(method, path)
|
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
|
end
|
||||||
|
|
||||||
defp do_request(method, path, body, opts, route, attempt) do
|
defp do_request(method, path, body, opts, route, attempt, deadline) do
|
||||||
case Ratelimit.acquire(route) do
|
case Ratelimit.acquire(route, max(deadline - mono_now(), 0)) do
|
||||||
{:wait, ms} ->
|
{:wait, ms} ->
|
||||||
|
if mono_now() + ms >= deadline do
|
||||||
|
deadline_error()
|
||||||
|
else
|
||||||
Process.sleep(ms + @wait_padding_ms)
|
Process.sleep(ms + @wait_padding_ms)
|
||||||
do_request(method, path, body, opts, route, attempt)
|
do_request(method, path, body, opts, route, attempt, deadline)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, :timeout} ->
|
||||||
|
deadline_error()
|
||||||
|
|
||||||
:ok ->
|
:ok ->
|
||||||
issue(method, path, body, opts, route, attempt)
|
issue(method, path, body, opts, route, attempt, deadline)
|
||||||
end
|
end
|
||||||
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)
|
{headers, encoded} = build(body, opts)
|
||||||
request = Finch.build(method, base_url() <> path, headers, encoded)
|
request = Finch.build(method, base_url() <> path, headers, encoded)
|
||||||
|
|
||||||
case Finch.request(request, @finch) do
|
case Finch.request(request, @finch) do
|
||||||
{:ok, %Finch.Response{status: 429, headers: resp_headers, body: resp_body}} ->
|
{:ok, %Finch.Response{status: 429, headers: resp_headers, body: resp_body}} ->
|
||||||
Ratelimit.update(route, resp_headers)
|
Ratelimit.update(route, resp_headers)
|
||||||
Ratelimit.probe_settled(route)
|
{:retry_429, resp_headers, resp_body}
|
||||||
handle_429(method, path, body, opts, route, attempt, resp_headers, resp_body)
|
|
||||||
|
|
||||||
{:ok, %Finch.Response{status: status, headers: resp_headers} = resp}
|
{:ok, %Finch.Response{status: status, headers: resp_headers} = resp}
|
||||||
when status in 200..299 ->
|
when status in 200..299 ->
|
||||||
Ratelimit.update(route, resp_headers)
|
Ratelimit.update(route, resp_headers)
|
||||||
Ratelimit.probe_settled(route)
|
{:done, success_result(status, resp.body)}
|
||||||
if status == 204, do: {:ok, nil}, else: {:ok, decode_body(resp.body)}
|
|
||||||
|
|
||||||
{:ok, %Finch.Response{headers: resp_headers} = resp} ->
|
{:ok, %Finch.Response{headers: resp_headers} = resp} ->
|
||||||
Ratelimit.update(route, resp_headers)
|
Ratelimit.update(route, resp_headers)
|
||||||
Ratelimit.probe_settled(route)
|
{:done, {:error, error(resp)}}
|
||||||
{:error, error(resp)}
|
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
Ratelimit.probe_settled(route)
|
{:done, {:error, %Error{status: nil, code: nil, message: inspect(reason), errors: nil}}}
|
||||||
{:error, %Error{status: nil, code: nil, message: inspect(reason), errors: nil}}
|
|
||||||
end
|
end
|
||||||
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
|
when attempt < @max_retries do
|
||||||
retry_ms = retry_after_ms(resp_body, headers)
|
retry_ms = retry_after_ms(resp_body, headers)
|
||||||
|
|
||||||
if global_429?(headers, resp_body) do
|
cond do
|
||||||
|
global_429?(headers, resp_body) ->
|
||||||
# The global lock now gates re-acquire; don't double-sleep here.
|
# The global lock now gates re-acquire; don't double-sleep here.
|
||||||
Ratelimit.set_global_lock(retry_ms)
|
Ratelimit.set_global_lock(retry_ms)
|
||||||
else
|
do_request(method, path, body, opts, route, attempt + 1, deadline)
|
||||||
|
|
||||||
|
mono_now() + retry_ms >= deadline ->
|
||||||
|
deadline_error()
|
||||||
|
|
||||||
|
true ->
|
||||||
Process.sleep(retry_ms + @wait_padding_ms)
|
Process.sleep(retry_ms + @wait_padding_ms)
|
||||||
|
do_request(method, path, body, opts, route, attempt + 1, deadline)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
do_request(method, path, body, opts, route, attempt + 1)
|
defp handle_429(_method, _path, _body, _opts, _route, _attempt, _headers, resp_body, _deadline) do
|
||||||
end
|
|
||||||
|
|
||||||
defp handle_429(_method, _path, _body, _opts, _route, _attempt, _headers, resp_body) do
|
|
||||||
decoded = decoded_map(resp_body)
|
decoded = decoded_map(resp_body)
|
||||||
|
|
||||||
{:error,
|
{:error,
|
||||||
|
|
@ -131,6 +176,27 @@ defmodule Dexcord.Api do
|
||||||
|
|
||||||
defp base_url, do: Application.get_env(:dexcord, :api_base_url, @default_base_url)
|
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(""), do: nil
|
||||||
defp decode_body(nil), do: nil
|
defp decode_body(nil), do: nil
|
||||||
|
|
||||||
|
|
@ -149,15 +215,27 @@ defmodule Dexcord.Api do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp error(%Finch.Response{status: status, body: body}) do
|
defp error(%Finch.Response{status: status, body: body}) do
|
||||||
decoded = decoded_map(body)
|
case decode_body(body) do
|
||||||
|
map when is_map(map) ->
|
||||||
%Error{
|
%Error{
|
||||||
status: status,
|
status: status,
|
||||||
code: Map.get(decoded, "code"),
|
code: Map.get(map, "code"),
|
||||||
message: Map.get(decoded, "message"),
|
message: Map.get(map, "message"),
|
||||||
errors: Map.get(decoded, "errors")
|
errors: Map.get(map, "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
|
||||||
|
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
|
defp retry_after_ms(body, headers) do
|
||||||
from_body =
|
from_body =
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,20 @@ defmodule Dexcord.Api.Ratelimit do
|
||||||
response for that route (via the `X-RateLimit-Bucket` header). Until then a
|
response for that route (via the `X-RateLimit-Bucket` header). Until then a
|
||||||
route's bucket is unknown. So the model is:
|
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
|
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
|
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
|
process ever waits on the server. It returns `:ok` or `{:wait, ms}`; the
|
||||||
caller sleeps and retries.
|
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 -
|
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
|
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):
|
* **`update/2`** feeds a response's headers back in (through the GenServer):
|
||||||
it learns the `X-RateLimit-Bucket` route→bucket mapping and stores the
|
it learns the `X-RateLimit-Bucket` route→bucket mapping and stores the
|
||||||
window's `remaining` plus a **monotonic** reset deadline derived from
|
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
|
zero-arity function. Tests inject a controllable clock so the bucket math is
|
||||||
deterministic with no real sleeping.
|
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)
|
## ETS layout (`:dexcord_ratelimit`, public)
|
||||||
|
|
||||||
* `{:global_lock_until, mono_ms}` - global lock deadline
|
* `{:global_lock_until, mono_ms}` - global lock deadline
|
||||||
|
|
@ -47,6 +59,9 @@ defmodule Dexcord.Api.Ratelimit do
|
||||||
@table :dexcord_ratelimit
|
@table :dexcord_ratelimit
|
||||||
@server __MODULE__
|
@server __MODULE__
|
||||||
|
|
||||||
|
# Short provisional window used at reset time, before the real headers land.
|
||||||
|
@provisional_reset_ms 1_000
|
||||||
|
|
||||||
# --- Public API ---------------------------------------------------------
|
# --- Public API ---------------------------------------------------------
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
|
|
@ -62,10 +77,18 @@ defmodule Dexcord.Api.Ratelimit do
|
||||||
|
|
||||||
Pure function (exported for tests). The key is `"METHOD /template"` where the
|
Pure function (exported for tests). The key is `"METHOD /template"` where the
|
||||||
template keeps the **major** parameters literal - `channel_id` and `guild_id`
|
template keeps the **major** parameters literal - `channel_id` and `guild_id`
|
||||||
(the segment after `channels`/`guilds`) and `webhook_id` + `webhook_token`
|
(the segment after `channels`/`guilds`) and `webhook_id` (the id segment after
|
||||||
(the two segments after `webhooks`) - collapses every other snowflake and the
|
`webhooks`) - collapses every other snowflake and the reaction emoji to `:id`,
|
||||||
reaction emoji to `:id`, and gives `DELETE …/messages/:id` its own key family
|
and gives `DELETE …/messages/:id` its own key family (Discord rate-limits
|
||||||
(Discord rate-limits message deletes separately).
|
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/<token>/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")
|
iex> Dexcord.Api.Ratelimit.route_key(:post, "/channels/123/messages")
|
||||||
"POST /channels/123/messages"
|
"POST /channels/123/messages"
|
||||||
|
|
@ -92,13 +115,21 @@ defmodule Dexcord.Api.Ratelimit do
|
||||||
end
|
end
|
||||||
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]),
|
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]),
|
defp templatize(["webhooks", id | rest]),
|
||||||
do: ["webhooks", id | templatize(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.
|
# channel_id / guild_id are major - keep the id literal.
|
||||||
defp templatize([major, id | rest]) when major in ["channels", "guilds"],
|
defp templatize([major, id | rest]) when major in ["channels", "guilds"],
|
||||||
do: [major, id | templatize(rest)]
|
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+$/)
|
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 """
|
@doc """
|
||||||
Attempts to take a token for `route_key`, in the calling process.
|
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
|
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.
|
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()}
|
@spec acquire(String.t(), timeout()) ::
|
||||||
def acquire(route_key) do
|
:ok | {:wait, non_neg_integer()} | {:error, :timeout}
|
||||||
|
def acquire(route_key, probe_timeout \\ :infinity) do
|
||||||
now = now()
|
now = now()
|
||||||
|
|
||||||
case global_lock_wait(now) do
|
case global_lock_wait(now) do
|
||||||
|
|
@ -132,7 +176,7 @@ defmodule Dexcord.Api.Ratelimit do
|
||||||
|
|
||||||
:ok ->
|
:ok ->
|
||||||
case lookup_bucket(route_key) do
|
case lookup_bucket(route_key) do
|
||||||
nil -> acquire_unknown(route_key)
|
nil -> acquire_unknown(route_key, probe_timeout)
|
||||||
:none -> :ok
|
:none -> :ok
|
||||||
bucket -> take_token(bucket, now)
|
bucket -> take_token(bucket, now)
|
||||||
end
|
end
|
||||||
|
|
@ -160,13 +204,31 @@ defmodule Dexcord.Api.Ratelimit do
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Signals that a route's probe request has completed (success or failure),
|
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
|
@spec probe_settled(String.t()) :: :ok
|
||||||
def probe_settled(route_key) do
|
def probe_settled(route_key) do
|
||||||
GenServer.cast(@server, {:probe_settled, route_key})
|
GenServer.cast(@server, {:probe_settled, route_key})
|
||||||
end
|
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`."
|
@doc "The monotonic clock in ms, honoring an injected `:ratelimit_now_fn`."
|
||||||
@spec now() :: integer()
|
@spec now() :: integer()
|
||||||
def now do
|
def now do
|
||||||
|
|
@ -178,11 +240,20 @@ defmodule Dexcord.Api.Ratelimit do
|
||||||
|
|
||||||
# --- caller-process internals ------------------------------------------
|
# --- caller-process internals ------------------------------------------
|
||||||
|
|
||||||
defp acquire_unknown(route_key) do
|
defp acquire_unknown(route_key, probe_timeout) do
|
||||||
case GenServer.call(@server, {:acquire_probe, route_key}) do
|
case GenServer.call(@server, {:acquire_probe, route_key, probe_timeout}, probe_timeout) do
|
||||||
:ok -> :ok
|
:ok -> :ok
|
||||||
:retry -> acquire(route_key)
|
:retry -> acquire(route_key, probe_timeout)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
defp lookup_bucket(route_key) do
|
defp lookup_bucket(route_key) do
|
||||||
|
|
@ -197,20 +268,41 @@ defmodule Dexcord.Api.Ratelimit do
|
||||||
[] ->
|
[] ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
[{_, _remaining, reset_at, _limit}] ->
|
[{_, _remaining, reset_at, limit}] ->
|
||||||
cond do
|
cond do
|
||||||
is_integer(reset_at) and now >= reset_at ->
|
is_integer(reset_at) and now >= reset_at ->
|
||||||
# Window elapsed: allow optimistically; the next response's headers
|
# Window elapsed: reset the counter to `limit - 1` under a fresh
|
||||||
# refresh `remaining`/`reset_at` for the new window.
|
# provisional deadline so only ~limit concurrent callers pass before
|
||||||
:ok
|
# the next response's headers land - instead of admitting everyone
|
||||||
|
# (the window-reset thundering herd).
|
||||||
|
reset_window(bucket, reset_at, limit, now)
|
||||||
|
|
||||||
true ->
|
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)}
|
if new >= 0, do: :ok, else: {:wait, wait_until(reset_at, now)}
|
||||||
end
|
end
|
||||||
end
|
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) when is_integer(reset_at), do: max(reset_at - now, 0) + 1
|
||||||
defp wait_until(_reset_at, _now), do: 1
|
defp wait_until(_reset_at, _now), do: 1
|
||||||
|
|
||||||
|
|
@ -233,11 +325,18 @@ defmodule Dexcord.Api.Ratelimit do
|
||||||
write_concurrency: true
|
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
|
end
|
||||||
|
|
||||||
@impl true
|
@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
|
cond do
|
||||||
:ets.member(@table, {:route_bucket, route_key}) ->
|
:ets.member(@table, {:route_bucket, route_key}) ->
|
||||||
# Learned since the caller's ETS read - just retry the fast path.
|
# 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) ->
|
Map.has_key?(probing, route_key) ->
|
||||||
# A probe is already in flight; park until it settles.
|
# A probe is already in flight; park until it settles.
|
||||||
waiters = Map.fetch!(probing, route_key)
|
{ref, pid, timer, waiters} = Map.fetch!(probing, route_key)
|
||||||
{:noreply, %{state | probing: Map.put(probing, route_key, [from | waiters])}}
|
|
||||||
|
{:noreply,
|
||||||
|
%{state | probing: Map.put(probing, route_key, {ref, pid, timer, [from | waiters]})}}
|
||||||
|
|
||||||
true ->
|
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
|
||||||
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
|
def handle_call({:update, route_key, headers}, _from, state) do
|
||||||
h = downcase_headers(headers)
|
h = downcase_headers(headers)
|
||||||
|
|
||||||
|
|
@ -283,17 +411,105 @@ defmodule Dexcord.Api.Ratelimit do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_cast({:probe_settled, route_key}, %{probing: probing} = state) do
|
def handle_cast({:probe_settled, route_key}, state) do
|
||||||
|
{:noreply, release_probe(state, route_key)}
|
||||||
|
end
|
||||||
|
|
||||||
|
# A caller that was elected the probe but abandoned it on a deadline timeout.
|
||||||
|
# Only settle if the entry's monitored pid still matches the abandoning pid -
|
||||||
|
# otherwise this is a stale abandon and a *different*, later probe now holds the
|
||||||
|
# route, which must not be clobbered.
|
||||||
|
def handle_cast({:abandon_probe, route_key, pid}, %{probing: probing} = state) do
|
||||||
|
case Map.get(probing, route_key) do
|
||||||
|
{_ref, ^pid, _timer, _waiters} -> {:noreply, release_probe(state, route_key)}
|
||||||
|
_ -> {:noreply, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:DOWN, ref, :process, _pid, _reason}, %{mons: mons} = state) do
|
||||||
|
case Map.get(mons, ref) do
|
||||||
|
nil -> {:noreply, state}
|
||||||
|
route_key -> {:noreply, release_probe(state, route_key)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Belt-and-braces force-settle: only fires if the route is still being probed by
|
||||||
|
# the exact same election (same ref). A settled/re-elected probe has a different
|
||||||
|
# ref (or no entry), so this can never clobber a healthy in-flight probe.
|
||||||
|
def handle_info({:force_settle, route_key, ref}, %{probing: probing} = state) do
|
||||||
|
case Map.get(probing, route_key) do
|
||||||
|
{^ref, _pid, _timer, _waiters} -> {:noreply, release_probe(state, route_key)}
|
||||||
|
_ -> {:noreply, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info(:sweep, state) do
|
||||||
|
do_sweep(idle_ttl())
|
||||||
|
schedule_sweep()
|
||||||
|
{:noreply, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Drops the probe for `route_key` and releases its parked waiters to re-acquire
|
||||||
|
# (they either take the freshly learned bucket or re-elect a new probe).
|
||||||
|
defp release_probe(%{probing: probing, mons: mons} = state, route_key) do
|
||||||
case Map.pop(probing, route_key) do
|
case Map.pop(probing, route_key) do
|
||||||
{nil, _} ->
|
{nil, _} ->
|
||||||
{:noreply, state}
|
state
|
||||||
|
|
||||||
{waiters, rest} ->
|
{{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))
|
Enum.each(waiters, &GenServer.reply(&1, :retry))
|
||||||
{:noreply, %{state | probing: rest}}
|
%{state | probing: rest, mons: Map.delete(mons, ref)}
|
||||||
end
|
end
|
||||||
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 -----------------------------------------------------
|
# --- header parsing -----------------------------------------------------
|
||||||
|
|
||||||
defp downcase_headers(headers) do
|
defp downcase_headers(headers) do
|
||||||
|
|
|
||||||
442
lib/dexcord/cache.ex
Normal file
442
lib/dexcord/cache.ex
Normal file
|
|
@ -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
|
||||||
|
|
@ -10,6 +10,7 @@ defmodule Dexcord.Config do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@pt_key __MODULE__
|
@pt_key __MODULE__
|
||||||
|
@app_id_key {__MODULE__, :application_id}
|
||||||
|
|
||||||
@doc "Stores the validated config map."
|
@doc "Stores the validated config map."
|
||||||
@spec put(map()) :: :ok
|
@spec put(map()) :: :ok
|
||||||
|
|
@ -36,4 +37,17 @@ defmodule Dexcord.Config do
|
||||||
@doc "Returns the configured event handler module."
|
@doc "Returns the configured event handler module."
|
||||||
@spec handler :: module()
|
@spec handler :: module()
|
||||||
def handler, do: get(:handler)
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -33,16 +33,60 @@ defmodule Dexcord.Dispatcher do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_cast({:dispatch, name, data}, state) do
|
def handle_cast({:dispatch, name, data}, state) do
|
||||||
# TODO: CACHE - once Dexcord.Cache exists, call Cache.handle_dispatch(name, data)
|
config = Dexcord.Config.get()
|
||||||
# INLINE HERE (before spawning the handler Task) so the cache reflects the
|
|
||||||
# event before the user's handler observes it. Ordered, single-writer.
|
|
||||||
|
|
||||||
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 ->
|
Task.Supervisor.start_child(@task_supervisor, fn ->
|
||||||
handler.handle_event({name, data})
|
config.handler.handle_event({name, data})
|
||||||
end)
|
end)
|
||||||
|
|
||||||
{:noreply, state}
|
{:noreply, state}
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -14,21 +14,27 @@ defmodule Dexcord.Gateway do
|
||||||
RESUME* (`session_id`, `last_seq`, `resume_gateway_url`, backoff, fatal flag)
|
RESUME* (`session_id`, `last_seq`, `resume_gateway_url`, backoff, fatal flag)
|
||||||
lives in `Dexcord.Session`'s ETS, not here.
|
lives in `Dexcord.Session`'s ETS, not here.
|
||||||
|
|
||||||
## Phase 0-1 scope
|
## Reliability behaviour
|
||||||
|
|
||||||
This implements the happy path fully (connect -> HELLO -> IDENTIFY -> READY ->
|
The full connect -> HELLO -> IDENTIFY/RESUME -> heartbeat -> dispatch path is
|
||||||
heartbeat with ACK/zombie tracking -> dispatch) plus basic
|
implemented and hardened: replayed dispatches during `:resuming` are forwarded
|
||||||
disconnect -> backoff -> reconnect, and the real Mint/heartbeat/Session
|
in order and re-arm the resume watchdog on every frame (so a large replay
|
||||||
plumbing. The resume protocol structure is present; its edge cases (replay
|
backlog can't trip it, while true silence still does), op 9 is handled for
|
||||||
ordering guarantees, op 9 timing, resume-loop cap, stale-socket guard) are
|
both `d: true` (resumable, RESUME again) and `d: false` (clear session,
|
||||||
hardened in Phase 2.
|
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
|
## `gateway_url:` override
|
||||||
|
|
||||||
If config carries `:gateway_url` (e.g. `"ws://127.0.0.1:4000"`), that URL is used
|
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
|
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
|
`:http` Mint scheme (TCP, no TLS); `wss://` to `:https`. This is what the
|
||||||
FakeGateway test suite connects against.
|
`Dexcord.FakeGateway` test suite connects against.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@behaviour :gen_statem
|
@behaviour :gen_statem
|
||||||
|
|
@ -107,16 +113,49 @@ defmodule Dexcord.Gateway do
|
||||||
ref: nil,
|
ref: nil,
|
||||||
status: nil,
|
status: nil,
|
||||||
resp_headers: [],
|
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_interval: nil,
|
||||||
hb_acked?: true,
|
hb_acked?: true,
|
||||||
# Set on op 9 `d:false` in :resuming; while true the :resuming op 0 handler
|
# 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
|
# must not re-arm the resume watchdog (it would clobber the pending
|
||||||
# :reidentify/:identify timer). Cleared on a fresh connection and once we
|
# :reidentify/:identify timer). Cleared on a fresh connection and once we
|
||||||
# actually IDENTIFY. See handle_payload/3 op 9 + op 0 in :resuming.
|
# 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,
|
reidentify_pending?: false,
|
||||||
# One-shot floor (ms) for the next reconnect delay, e.g. the mandated
|
# 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.
|
# 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}
|
{:ok, :disconnected, data}
|
||||||
|
|
@ -153,6 +192,7 @@ defmodule Dexcord.Gateway do
|
||||||
data
|
data
|
||||||
| status: nil,
|
| status: nil,
|
||||||
resp_headers: [],
|
resp_headers: [],
|
||||||
|
pending_data: <<>>,
|
||||||
websocket: nil,
|
websocket: nil,
|
||||||
reidentify_pending?: false
|
reidentify_pending?: false
|
||||||
}
|
}
|
||||||
|
|
@ -241,6 +281,20 @@ defmodule Dexcord.Gateway do
|
||||||
end
|
end
|
||||||
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) ------------
|
# --- shared: inbound socket traffic (mode: :active -> :info) ------------
|
||||||
|
|
||||||
def handle_event(:info, msg, _state, data) do
|
def handle_event(:info, msg, _state, data) do
|
||||||
|
|
@ -288,21 +342,31 @@ defmodule Dexcord.Gateway do
|
||||||
|
|
||||||
# --- casts --------------------------------------------------------------
|
# --- casts --------------------------------------------------------------
|
||||||
|
|
||||||
def handle_event(:cast, {:update_presence, presence}, :connected, data) do
|
def handle_event(:cast, {:update_presence, presence}, state, data) do
|
||||||
send_and_keep(data, Payload.presence_update(presence))
|
cast_budgeted(state, data, Payload.presence_update(presence))
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event(:cast, {:request_guild_members, guild_id, opts}, :connected, data) do
|
def handle_event(:cast, {:request_guild_members, guild_id, opts}, state, data) do
|
||||||
send_and_keep(data, Payload.request_guild_members(guild_id, opts))
|
cast_budgeted(state, data, Payload.request_guild_members(guild_id, opts))
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event(:cast, _msg, _state, _data) do
|
def handle_event(:cast, _msg, _state, _data) do
|
||||||
# Presence/member requests while not connected are dropped this phase.
|
|
||||||
:keep_state_and_data
|
:keep_state_and_data
|
||||||
end
|
end
|
||||||
|
|
||||||
# --- catch-all: ignore stray events, never crash the statem -------------
|
# --- 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(:enter, _old, _state, _data), do: :keep_state_and_data
|
||||||
|
|
||||||
def handle_event(type, content, state, _data) do
|
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
|
# 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
|
# 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
|
# :reidentify/:identify state_timeout and wedge us in :resuming with a cleared
|
||||||
# session forever. Dispatch it and leave the timers untouched.
|
# session forever. Dispatch it and leave the timers untouched. We also do NOT
|
||||||
bump_and_dispatch(payload)
|
# 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}
|
{:keep_state, data}
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
|
|
@ -460,8 +527,30 @@ defmodule Dexcord.Gateway do
|
||||||
_ -> :ok
|
_ -> :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
name = Dexcord.EventNames.to_atom(payload["t"])
|
dispatch_only(payload)
|
||||||
Dexcord.Dispatcher.dispatch(name, payload["d"])
|
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
|
end
|
||||||
|
|
||||||
# --- close handling -----------------------------------------------------
|
# --- close handling -----------------------------------------------------
|
||||||
|
|
@ -610,11 +699,28 @@ defmodule Dexcord.Gateway do
|
||||||
{:headers, ref, headers} when ref == data.ref ->
|
{:headers, ref, headers} when ref == data.ref ->
|
||||||
process_responses(%{data | resp_headers: data.resp_headers ++ headers}, rest, actions)
|
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 ->
|
{:done, ref} when ref == data.ref and data.websocket == nil ->
|
||||||
case Mint.WebSocket.new(data.conn, ref, data.status, data.resp_headers) do
|
case Mint.WebSocket.new(data.conn, ref, data.status, data.resp_headers) do
|
||||||
{:ok, conn, websocket} ->
|
{:ok, conn, websocket} ->
|
||||||
data = %{data | conn: conn, websocket: 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, conn, reason} ->
|
||||||
{:error, %{data | conn: conn}, reason}
|
{:error, %{data | conn: conn}, reason}
|
||||||
|
|
@ -642,6 +748,26 @@ defmodule Dexcord.Gateway do
|
||||||
end
|
end
|
||||||
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.
|
# Sends a websocket frame, threading the Mint structs. Never crashes.
|
||||||
defp send_frame(%{websocket: nil} = data, _frame), do: {:error, data, :no_websocket}
|
defp send_frame(%{websocket: nil} = data, _frame), do: {:error, data, :no_websocket}
|
||||||
|
|
||||||
|
|
@ -666,13 +792,139 @@ defmodule Dexcord.Gateway do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp send_and_keep(data, frame_map) do
|
# --- gateway send budget (token bucket) ---------------------------------
|
||||||
case send_frame(data, {:text, JSON.encode!(frame_map)}) do
|
|
||||||
{:ok, data} -> {:keep_state, data}
|
# Send a budgeted (op-3/op-8) frame if a token is available, else queue it. The
|
||||||
{:error, data, _reason} -> fail_reconnect(data)
|
# 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
|
||||||
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 --------------------------------
|
# --- identify / resume / heartbeat sends --------------------------------
|
||||||
|
|
||||||
# Enforce the identify gap on EVERY identify path. If a gap is still pending
|
# 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)
|
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
|
@doc false
|
||||||
# Reconnect backoff: min(base * 2^attempt, cap) with +-20% jitter; 0 on the
|
# 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}`.
|
# first attempt. `:backoff` config `{base_ms, cap_ms}` defaults to `{1000, 60000}`.
|
||||||
|
|
|
||||||
148
lib/dexcord/prefix.ex
Normal file
148
lib/dexcord/prefix.ex
Normal file
|
|
@ -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
|
||||||
|
|
@ -94,11 +94,20 @@ defmodule Dexcord.Session do
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Records a freshly-established session from a READY payload.
|
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
|
@spec establish(String.t(), String.t()) :: :ok
|
||||||
def establish(session_id, resume_gateway_url) do
|
def establish(session_id, resume_gateway_url) do
|
||||||
put(:session_id, session_id)
|
put(:session_id, session_id)
|
||||||
put(:resume_gateway_url, resume_gateway_url)
|
put(:resume_gateway_url, resume_gateway_url)
|
||||||
|
put(:last_seq, nil)
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
256
lib/dexcord/slash.ex
Normal file
256
lib/dexcord/slash.ex
Normal file
|
|
@ -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
|
||||||
180
lib/dexcord/slash/registrar.ex
Normal file
180
lib/dexcord/slash/registrar.ex
Normal file
|
|
@ -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
|
||||||
|
|
@ -16,11 +16,24 @@ defmodule Dexcord.Supervisor do
|
||||||
`max_restarts` trips in milliseconds and this supervisor terminates - surfacing
|
`max_restarts` trips in milliseconds and this supervisor terminates - surfacing
|
||||||
the failure to the host application instead of hammering Discord.
|
the failure to the host application instead of hammering Discord.
|
||||||
|
|
||||||
## Phase note
|
## Notes
|
||||||
|
|
||||||
`Dexcord.Cache` and `Dexcord.Slash.Registrar` are not started yet
|
`Dexcord.Cache` owns the cache ETS tables and `Dexcord.Dispatcher` is their sole
|
||||||
(Cache -> Phase 4, Registrar -> Phase 5). `Dexcord.Api.Ratelimit` owns the
|
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.
|
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
|
use Supervisor
|
||||||
|
|
@ -35,16 +48,42 @@ defmodule Dexcord.Supervisor do
|
||||||
# Publish the validated config before any child that reads it starts.
|
# Publish the validated config before any child that reads it starts.
|
||||||
Dexcord.Config.put(config)
|
Dexcord.Config.put(config)
|
||||||
|
|
||||||
children = [
|
# Gateway is always last: it depends on everything above.
|
||||||
|
children =
|
||||||
|
[
|
||||||
Dexcord.Session,
|
Dexcord.Session,
|
||||||
|
cache_and_dispatcher(),
|
||||||
{Finch, name: Dexcord.Finch},
|
{Finch, name: Dexcord.Finch},
|
||||||
Dexcord.Api.Ratelimit,
|
Dexcord.Api.Ratelimit,
|
||||||
{Task.Supervisor, name: Dexcord.TaskSupervisor},
|
{Task.Supervisor, name: Dexcord.TaskSupervisor}
|
||||||
Dexcord.Dispatcher,
|
] ++
|
||||||
# Gateway is always last: it depends on everything above.
|
registrar_child(config) ++
|
||||||
Dexcord.Gateway
|
[Dexcord.Gateway]
|
||||||
]
|
|
||||||
|
|
||||||
Supervisor.init(children, strategy: :one_for_one, max_restarts: 5, max_seconds: 30)
|
Supervisor.init(children, strategy: :one_for_one, max_restarts: 5, max_seconds: 30)
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
14
mix.exs
14
mix.exs
|
|
@ -1,6 +1,8 @@
|
||||||
defmodule Dexcord.MixProject do
|
defmodule Dexcord.MixProject do
|
||||||
use Mix.Project
|
use Mix.Project
|
||||||
|
|
||||||
|
@source_url "https://github.com/luna/dexcord"
|
||||||
|
|
||||||
def project do
|
def project do
|
||||||
[
|
[
|
||||||
app: :dexcord,
|
app: :dexcord,
|
||||||
|
|
@ -8,7 +10,17 @@ defmodule Dexcord.MixProject do
|
||||||
elixir: "~> 1.18",
|
elixir: "~> 1.18",
|
||||||
elixirc_paths: elixirc_paths(Mix.env()),
|
elixirc_paths: elixirc_paths(Mix.env()),
|
||||||
start_permanent: Mix.env() == :prod,
|
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
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ defmodule Dexcord.ApiIntegrationTest do
|
||||||
@token "test.token.value"
|
@token "test.token.value"
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
|
Dexcord.EnvSandbox.sandbox_env()
|
||||||
Dexcord.Config.put(%{token: @token, handler: nil, intents: 0})
|
Dexcord.Config.put(%{token: @token, handler: nil, intents: 0})
|
||||||
|
|
||||||
start_supervised!({Finch, name: Dexcord.Finch})
|
start_supervised!({Finch, name: Dexcord.Finch})
|
||||||
|
|
@ -25,11 +26,6 @@ defmodule Dexcord.ApiIntegrationTest do
|
||||||
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
|
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
|
||||||
FakeRest.subscribe(self())
|
FakeRest.subscribe(self())
|
||||||
|
|
||||||
on_exit(fn ->
|
|
||||||
Application.delete_env(:dexcord, :api_base_url)
|
|
||||||
Application.delete_env(:dexcord, :ratelimit_now_fn)
|
|
||||||
end)
|
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -57,6 +53,9 @@ defmodule Dexcord.ApiIntegrationTest do
|
||||||
|
|
||||||
assert headers["authorization"] == "Bot #{@token}"
|
assert headers["authorization"] == "Bot #{@token}"
|
||||||
assert headers["user-agent"] =~ "DiscordBot"
|
assert headers["user-agent"] =~ "DiscordBot"
|
||||||
|
|
||||||
|
# Exactly one request went out.
|
||||||
|
refute_receive {:rest_hit, _}, 50
|
||||||
end
|
end
|
||||||
|
|
||||||
test "204 responses decode to {:ok, nil}" do
|
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)
|
{elapsed_us, result} = :timer.tc(fn -> Api.create_message(5, "hi") end)
|
||||||
|
|
||||||
assert {:ok, %{"id" => "ok"}} = result
|
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, _}
|
||||||
assert_receive {:rest_hit, _}
|
assert_receive {:rest_hit, _}
|
||||||
|
refute_receive {:rest_hit, _}, 50
|
||||||
assert elapsed_us >= 200_000
|
assert elapsed_us >= 200_000
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -176,6 +176,65 @@ defmodule Dexcord.ApiIntegrationTest do
|
||||||
assert {:ok, %{"id" => "a"}} = Task.await(task, 5_000)
|
assert {:ok, %{"id" => "a"}} = Task.await(task, 5_000)
|
||||||
end
|
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 = "<html><body>502 Bad Gateway</body></html>"
|
||||||
|
|
||||||
|
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 ------------------------------------------------------------
|
# --- helpers ------------------------------------------------------------
|
||||||
|
|
||||||
defp global_lock_set?() do
|
defp global_lock_set?() do
|
||||||
|
|
|
||||||
84
test/dexcord/cache_cascade_test.exs
Normal file
84
test/dexcord/cache_cascade_test.exs
Normal file
|
|
@ -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
|
||||||
133
test/dexcord/cache_integration_test.exs
Normal file
133
test/dexcord/cache_integration_test.exs
Normal file
|
|
@ -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
|
||||||
329
test/dexcord/cache_test.exs
Normal file
329
test/dexcord/cache_test.exs
Normal file
|
|
@ -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
|
||||||
78
test/dexcord/config_validation_test.exs
Normal file
78
test/dexcord/config_validation_test.exs
Normal file
|
|
@ -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
|
||||||
138
test/dexcord/ergonomics_integration_test.exs
Normal file
138
test/dexcord/ergonomics_integration_test.exs
Normal file
|
|
@ -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
|
||||||
|
|
@ -5,14 +5,7 @@ defmodule Dexcord.GatewayBackoffTest do
|
||||||
alias Dexcord.Gateway
|
alias Dexcord.Gateway
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
prev = Application.get_env(:dexcord, :backoff)
|
Dexcord.EnvSandbox.sandbox_env()
|
||||||
|
|
||||||
on_exit(fn ->
|
|
||||||
if prev == nil,
|
|
||||||
do: Application.delete_env(:dexcord, :backoff),
|
|
||||||
else: Application.put_env(:dexcord, :backoff, prev)
|
|
||||||
end)
|
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,24 +12,13 @@ defmodule Dexcord.GatewayFatalTest do
|
||||||
alias Dexcord.FakeGateway
|
alias Dexcord.FakeGateway
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
prev =
|
Dexcord.EnvSandbox.sandbox_env()
|
||||||
for key <- [:backoff, :connect_timeout_ms, :hello_timeout_ms, :tcp_connect_timeout_ms] do
|
|
||||||
{key, Application.get_env(:dexcord, key)}
|
|
||||||
end
|
|
||||||
|
|
||||||
Application.put_env(:dexcord, :backoff, {10, 50})
|
Application.put_env(:dexcord, :backoff, {10, 50})
|
||||||
Application.put_env(:dexcord, :connect_timeout_ms, 2_000)
|
Application.put_env(:dexcord, :connect_timeout_ms, 2_000)
|
||||||
Application.put_env(:dexcord, :hello_timeout_ms, 2_000)
|
Application.put_env(:dexcord, :hello_timeout_ms, 2_000)
|
||||||
Application.put_env(:dexcord, :tcp_connect_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())
|
FakeGateway.TestHandler.subscribe(self())
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
@ -53,7 +42,24 @@ defmodule Dexcord.GatewayFatalTest do
|
||||||
Process.flag(:trap_exit, true)
|
Process.flag(:trap_exit, true)
|
||||||
{:ok, sup} = Dexcord.Supervisor.start_link(config)
|
{: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}
|
{fake, sup}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,26 +10,16 @@ defmodule Dexcord.GatewayIntegrationTest do
|
||||||
use ExUnit.Case, async: false
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
alias Dexcord.FakeGateway
|
alias Dexcord.FakeGateway
|
||||||
|
alias Dexcord.Gateway
|
||||||
alias Dexcord.Intents
|
alias Dexcord.Intents
|
||||||
alias Dexcord.Session
|
alias Dexcord.Session
|
||||||
|
|
||||||
@token "test.token.value"
|
@token "test.token.value"
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
# Deterministic, fast timing knobs. Restored after each test.
|
# Deterministic, fast timing knobs. The env sandbox restores everything (whatever
|
||||||
prev =
|
# keys these tests set) wholesale after each test.
|
||||||
for key <- [
|
Dexcord.EnvSandbox.sandbox_env()
|
||||||
:backoff,
|
|
||||||
:invalid_session_delay_ms,
|
|
||||||
:identify_gap_ms,
|
|
||||||
:first_heartbeat_fraction,
|
|
||||||
:max_resume_attempts,
|
|
||||||
:connect_timeout_ms,
|
|
||||||
:hello_timeout_ms,
|
|
||||||
:tcp_connect_timeout_ms
|
|
||||||
] do
|
|
||||||
{key, Application.get_env(:dexcord, key)}
|
|
||||||
end
|
|
||||||
|
|
||||||
Application.put_env(:dexcord, :backoff, {20, 200})
|
Application.put_env(:dexcord, :backoff, {20, 200})
|
||||||
Application.put_env(:dexcord, :first_heartbeat_fraction, 1.0)
|
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, :hello_timeout_ms, 2_500)
|
||||||
Application.put_env(:dexcord, :tcp_connect_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())
|
FakeGateway.TestHandler.subscribe(self())
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
@ -213,6 +195,107 @@ defmodule Dexcord.GatewayIntegrationTest do
|
||||||
assert_receive {:fake_gw, :frame, _c, %{"op" => 1}}, 500
|
assert_receive {:fake_gw, :frame, _c, %{"op" => 1}}, 500
|
||||||
end
|
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 ---------------------------------------------------
|
# --- zombie detection ---------------------------------------------------
|
||||||
|
|
||||||
test "zombie: dropping ACKs closes ~2x interval later and RESUMEs with correct session/seq" do
|
test "zombie: dropping ACKs closes ~2x interval later and RESUMEs with correct session/seq" do
|
||||||
|
|
|
||||||
152
test/dexcord/gateway_send_budget_test.exs
Normal file
152
test/dexcord/gateway_send_budget_test.exs
Normal file
|
|
@ -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
|
||||||
106
test/dexcord/prefix_test.exs
Normal file
106
test/dexcord/prefix_test.exs
Normal file
|
|
@ -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
|
||||||
|
|
@ -43,16 +43,50 @@ defmodule Dexcord.Api.RatelimitTest do
|
||||||
"PUT /channels/1/messages/:id/reactions/:id/@me"
|
"PUT /channels/1/messages/:id/reactions/:id/@me"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "webhook id + token are both major (literal)" do
|
test "webhook id stays literal; the secret token becomes a stable digest" do
|
||||||
assert Ratelimit.route_key(:post, "/webhooks/111/abctoken") ==
|
digest =
|
||||||
"POST /webhooks/111/abctoken"
|
:crypto.hash(:sha256, "abctoken")
|
||||||
|
|> Base.url_encode64(padding: false)
|
||||||
|
|> binary_part(0, 12)
|
||||||
|
|
||||||
assert Ratelimit.route_key(:patch, "/webhooks/111/abctoken/messages/@original") ==
|
post = Ratelimit.route_key(:post, "/webhooks/111/abctoken")
|
||||||
"PATCH /webhooks/111/abctoken/messages/@original"
|
patch = Ratelimit.route_key(:patch, "/webhooks/111/abctoken/messages/@original")
|
||||||
|
|
||||||
# interaction id is minor; the token stays literal.
|
assert post == "POST /webhooks/111/#{digest}"
|
||||||
assert Ratelimit.route_key(:post, "/interactions/222/tok/callback") ==
|
assert patch == "PATCH /webhooks/111/#{digest}/messages/@original"
|
||||||
"POST /interactions/:id/tok/callback"
|
|
||||||
|
# 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
|
end
|
||||||
|
|
||||||
test "query strings are ignored" do
|
test "query strings are ignored" do
|
||||||
|
|
@ -63,10 +97,10 @@ defmodule Dexcord.Api.RatelimitTest do
|
||||||
|
|
||||||
describe "bucket + global math (injected clock)" do
|
describe "bucket + global math (injected clock)" do
|
||||||
setup do
|
setup do
|
||||||
|
Dexcord.EnvSandbox.sandbox_env()
|
||||||
{:ok, clock} = Agent.start_link(fn -> 0 end)
|
{:ok, clock} = Agent.start_link(fn -> 0 end)
|
||||||
Application.put_env(:dexcord, :ratelimit_now_fn, fn -> Agent.get(clock, & &1) end)
|
Application.put_env(:dexcord, :ratelimit_now_fn, fn -> Agent.get(clock, & &1) end)
|
||||||
start_supervised!(Ratelimit)
|
start_supervised!(Ratelimit)
|
||||||
on_exit(fn -> Application.delete_env(:dexcord, :ratelimit_now_fn) end)
|
|
||||||
%{clock: clock}
|
%{clock: clock}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -135,5 +169,150 @@ defmodule Dexcord.Api.RatelimitTest do
|
||||||
assert {:wait, ms2} = Ratelimit.acquire(route)
|
assert {:wait, ms2} = Ratelimit.acquire(route)
|
||||||
assert ms2 in 100..102
|
assert ms2 in 100..102
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
118
test/dexcord/registrar_integration_test.exs
Normal file
118
test/dexcord/registrar_integration_test.exs
Normal file
|
|
@ -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
|
||||||
83
test/dexcord/registrar_tree_test.exs
Normal file
83
test/dexcord/registrar_tree_test.exs
Normal file
|
|
@ -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
|
||||||
|
|
@ -47,6 +47,22 @@ defmodule Dexcord.SessionTest do
|
||||||
refute Session.resumable?()
|
refute Session.resumable?()
|
||||||
end
|
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
|
test "mark_fatal records a fatal tuple" do
|
||||||
Session.mark_fatal(4014)
|
Session.mark_fatal(4014)
|
||||||
assert Session.fatal() == {:fatal_close, 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.
|
# the Session GenServer (not the writer), the value must persist.
|
||||||
task =
|
task =
|
||||||
Task.async(fn ->
|
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.establish("s1", "wss://x")
|
||||||
|
Session.bump_seq(777)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
Task.await(task)
|
Task.await(task)
|
||||||
|
|
|
||||||
223
test/dexcord/slash_test.exs
Normal file
223
test/dexcord/slash_test.exs
Normal file
|
|
@ -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
|
||||||
|
|
@ -34,12 +34,20 @@ defmodule Dexcord.SupervisorSmokeTest do
|
||||||
ids = Enum.map(children, fn {id, _pid, _type, _mods} -> id end)
|
ids = Enum.map(children, fn {id, _pid, _type, _mods} -> id end)
|
||||||
|
|
||||||
assert Dexcord.Session in ids
|
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
|
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
|
for {_id, child_pid, _type, _mods} <- children do
|
||||||
assert is_pid(child_pid) and Process.alive?(child_pid)
|
assert is_pid(child_pid) and Process.alive?(child_pid)
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
27
test/support/cache_probe_handler.ex
Normal file
27
test/support/cache_probe_handler.ex
Normal file
|
|
@ -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
|
||||||
41
test/support/env_sandbox.ex
Normal file
41
test/support/env_sandbox.ex
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue