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